Skip to main content

Modern Date and Time dengan java.time di Android

API date dan time lama seperti SimpleDateFormat, Date, dan Calendar masih sering ditemukan di project Android. API tersebut mudah disalahgunakan karena mutable, memiliki perilaku yang kurang eksplisit, dan—khusus formatter lama—tidak aman digunakan bersama oleh beberapa thread.

Penggantinya adalah package java.time, yang menyediakan tipe berbeda untuk kebutuhan berbeda. Class di package ini immutable dan thread-safe sehingga lebih aman dipakai di Repository, UseCase, ViewModel, coroutine, dan UI.

Koreksi istilah: deprecated vs legacy

Tidak semua API lama memiliki status resmi @Deprecated.

API lamaStatusMasalah utamaPengganti yang umum
android.text.format.TimeDeprecated sejak API 22Mutable dan API timezone lamaZonedDateTime
SimpleDateFormat / DateFormatTidak deprecatedMutable dan tidak thread-safeDateTimeFormatter
java.util.DateClass tidak deprecated; banyak constructor dan method lamanya deprecatedMutable dan namanya seolah mewakili date, padahal menyimpan instantInstant
Calendar / GregorianCalendarTidak deprecatedMutable, lenient, field-based, dan month lama zero-basedTipe java.time sesuai kebutuhan
System.currentTimeMillis()Tidak deprecatedSulit di-test jika dipanggil langsung dan unit hanya berupa angkaInstant + Clock

Jadi, alasan migrasi bukan hanya warning deprecation. Alasan yang lebih penting adalah semantic clarity, immutability, thread-safety, timezone correctness, dan testability.

Mengapa SimpleDateFormat bermasalah di Android?

SimpleDateFormat menyimpan state internal yang berubah selama proses format() dan parse(). Instance yang sama tidak aman dipakai secara concurrent.

object LegacyDateFormatter {
// Berbahaya: singleton ini dapat dipanggil dari banyak thread.
private val formatter = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
Locale.US,
)

fun format(date: Date): String = formatter.format(date)
}

Pada Android, object tersebut mungkin dipakai oleh beberapa coroutine atau request sekaligus:

Dispatchers.IO thread 1 ──┐
├──> instance SimpleDateFormat yang sama
Dispatchers.IO thread 2 ──┘

Akibatnya dapat berupa hasil format yang salah, parsing gagal secara acak, atau bug yang sulit direproduksi. Memindahkan pekerjaan ke Dispatchers.IO tidak membuat object mutable menjadi thread-safe.

Solusi lama adalah membuat formatter baru setiap pemanggilan, memakai synchronized, atau menyimpan formatter dalam ThreadLocal. Untuk code baru, solusi yang lebih sederhana adalah DateTimeFormatter.

private val apiFormatter =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss")

fun format(value: LocalDateTime): String {
return value.format(apiFormatter)
}

DateTimeFormatter immutable dan thread-safe, sehingga aman disimpan sebagai top-level property atau di companion object.

Pilih tipe berdasarkan makna datanya

Kesalahan date-time biasanya terjadi karena developer memilih satu tipe untuk semua kebutuhan. Sebelum menulis code, tentukan apakah data mewakili tanggal kalender, jam dinding, atau titik absolut pada timeline.

TipeMemiliki tanggalMemiliki waktuOffset/zoneGunakan untuk
LocalDateYaTidakTidakTanggal lahir, tanggal laporan
LocalTimeTidakYaTidakJam buka harian, waktu alarm lokal
LocalDateTimeYaYaTidakDate-time lokal yang zone-nya belum ditentukan
InstantTimeline absolutTimeline absolutUTCTimestamp backend, audit log, waktu dibuat
OffsetDateTimeYaYaFixed offsetPayload API dengan +07:00 atau Z
ZonedDateTimeYaYaZoneId + aturan zoneJadwal yang terkait wilayah seperti Asia/Jakarta
DurationJarak berbasis detik/nanodetik
PeriodJarak berbasis hari/bulan/tahun kalender

LocalDate

Mewakili tanggal tanpa jam dan timezone.

val birthday = LocalDate.of(1995, Month.JULY, 16)
val parsed = LocalDate.parse("2026-07-16")
val tomorrow = parsed.plusDays(1)

Gunakan LocalDate jika nilai tetap 16 Juli di mana pun user berada. Jangan menyimpan tanggal lahir sebagai midnight UTC karena konversi timezone dapat menggesernya ke tanggal sebelumnya atau berikutnya.

LocalTime

Mewakili jam dinding tanpa tanggal dan timezone.

val storeOpensAt = LocalTime.of(9, 0)
val reminderTime = LocalTime.parse("20:30")

LocalTime cocok untuk "setiap hari pukul 09.00". Nilai ini belum dapat dikonversi menjadi epoch sampai digabungkan dengan tanggal dan zone.

LocalDateTime

Mewakili tanggal dan jam tanpa offset atau timezone.

val appointment = LocalDateTime.of(
2026,
Month.JULY,
16,
9,
30,
)

LocalDateTime bukan timestamp absolut. 2026-07-16T09:30 dapat berarti waktu berbeda di Jakarta, Tokyo, dan London.

Gunakan hanya ketika zone memang belum ditentukan atau tidak relevan. Untuk event yang harus terjadi pada satu titik waktu yang sama di seluruh dunia, gunakan Instant.

Instant

Mewakili satu titik absolut pada timeline UTC.

val createdAt = Instant.now()
val fromEpochMillis = Instant.ofEpochMilli(1_768_000_000_000L)
val epochMillis = createdAt.toEpochMilli()

Pilihan default yang baik untuk:

  • createdAt dan updatedAt
  • audit log
  • token expiration
  • timestamp database
  • membandingkan urutan event

Konversikan ke timezone user hanya ketika akan ditampilkan.

val userZone = ZoneId.systemDefault()
val displayTime = createdAt.atZone(userZone)

OffsetDateTime

Mewakili date-time dengan fixed offset dari UTC.

val apiValue = OffsetDateTime.parse("2026-07-16T09:30:00+07:00")
val instant = apiValue.toInstant()

+07:00 adalah offset, bukan identitas timezone. Offset tidak menyimpan aturan historis atau perubahan daylight saving time.

Gunakan OffsetDateTime pada boundary API jika contract server mengirim ISO-8601 dengan Z atau +07:00. Untuk business logic internal yang hanya memerlukan titik waktu, normalisasikan ke Instant.

ZonedDateTime

Mewakili date-time, offset, dan ZoneId seperti Asia/Jakarta.

val jakartaZone = ZoneId.of("Asia/Jakarta")
val scheduledAt = ZonedDateTime.of(
LocalDate.of(2026, Month.JULY, 16),
LocalTime.of(9, 30),
jakartaZone,
)

Gunakan ketika aturan wilayah memang bagian dari requirement, misalnya meeting pukul 09.00 waktu kantor atau jadwal berulang berdasarkan timezone user.

val sameMomentInTokyo = scheduledAt.withZoneSameInstant(
ZoneId.of("Asia/Tokyo"),
)

Perbedaan penting:

  • withZoneSameInstant() mempertahankan titik waktu, lalu mengubah tampilan lokalnya.
  • withZoneSameLocal() mempertahankan jam lokal, tetapi dapat menghasilkan titik waktu berbeda.

Duration berbeda dengan Period

Duration menghitung jarak pada timeline, sedangkan Period menghitung jarak kalender.

val timeout = Duration.ofSeconds(30)
val sessionLength = Duration.between(startInstant, endInstant)

val billingPeriod = Period.ofMonths(1)
val age = Period.between(birthday, LocalDate.now())

Satu bulan tidak selalu memiliki jumlah detik atau hari yang sama. Karena itu, jangan mengganti Period.ofMonths(1) dengan Duration.ofDays(30) jika requirement-nya mengikuti kalender.

Parsing dan formatting yang aman

Gunakan format ISO bawaan jika contract memungkinkan.

val instant = Instant.parse("2026-07-16T02:30:00Z")
val offset = OffsetDateTime.parse("2026-07-16T09:30:00+07:00")
val date = LocalDate.parse("2026-07-16")

Gunakan formatter custom hanya jika format eksternal memang berbeda.

private val displayDateFormatter = DateTimeFormatter.ofPattern(
"dd MMMM uuuu",
Locale.forLanguageTag("id-ID"),
)

val label = LocalDate.of(2026, 7, 16).format(displayDateFormatter)
// 16 Juli 2026

Kesalahan pattern yang umum

PatternMakna
MMBulan
mmMenit
HHJam 00–23
hhJam 01–12; biasanya membutuhkan a
uuuuProleptic year; pilihan aman untuk ISO date
yyyyYear-of-era; membutuhkan perhatian pada era
XXXOffset seperti +07:00 atau Z

Untuk input dari backend, tetapkan locale secara eksplisit dan jangan mengandalkan locale device.

private val backendFormatter = DateTimeFormatter.ofPattern(
"dd-MMM-uuuu HH:mm:ss XXX",
Locale.US,
)

Untuk teks yang dilihat user, gunakan locale user dan pertimbangkan preferensi format 12/24 jam Android.

Android: timezone dan tampilan UI

Simpan instant, tampilkan dalam zone user

Arsitektur yang umum:

API / Database: Instant atau ISO-8601 timestamp

Domain: Instant

Presentation: ZonedDateTime dalam ZoneId user

UI: String yang sudah dilokalkan
fun Instant.toDisplayText(
zoneId: ZoneId,
locale: Locale,
): String {
val formatter = DateTimeFormatter.ofPattern(
"dd MMM uuuu, HH:mm",
locale,
)

return atZone(zoneId).format(formatter)
}

Jangan mem-format timestamp menjadi String di Repository jika string tersebut hanya untuk UI. Menjaga tipe date-time sampai presentation layer membuat sorting, comparison, dan perubahan timezone tetap aman.

Device timezone dapat berubah

ZoneId.systemDefault() mengikuti default timezone proses. User dapat mengubah timezone device atau sistem dapat memperbaruinya berdasarkan lokasi/network.

Jangan menyimpan hasil ZoneId.systemDefault() selamanya jika fitur harus merespons perubahan setting. Ambil ketika dibutuhkan atau perbarui state saat aplikasi menerima perubahan konfigurasi yang relevan.

Alarm dan scheduling

Untuk jadwal absolut dari server, simpan Instant. Untuk jadwal seperti "setiap hari pukul 08.00", simpan LocalTime beserta ZoneId, lalu hitung occurrence berikutnya.

fun nextOccurrence(
time: LocalTime,
zone: ZoneId,
clock: Clock,
): ZonedDateTime {
val now = ZonedDateTime.now(clock.withZone(zone))
val today = now.toLocalDate().atTime(time).atZone(zone)

return if (today.isAfter(now)) {
today
} else {
today.plusDays(1)
}
}

Ketika menggunakan AlarmManager atau WorkManager, pahami apakah requirement mengikuti elapsed time, wall clock, atau timezone user. java.time membantu memodelkan waktunya, tetapi mekanisme scheduling Android tetap harus dipilih sesuai kebutuhan aplikasi.

Mendukung Android sebelum API 26

java.time tersedia native mulai API 26. Untuk minSdk yang lebih rendah, gunakan core library desugaring dari Android Gradle Plugin.

build.gradle.kts
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

dependencies {
coreLibraryDesugaring(
"com.android.tools:desugar_jdk_libs:<version-compatible-with-AGP>",
)
}

Gunakan versi desugar_jdk_libs yang kompatibel dengan versi Android Gradle Plugin project. Tidak semua method baru dari versi Java terbaru otomatis tersedia; periksa tabel API desugaring ketika memakai method yang lebih baru.

Migrasi dari API lama

Date ke Instant

val legacyDate: Date = obtainLegacyDate()
val instant: Instant = legacyDate.toInstant()

val backToDate: Date = Date.from(instant)

Gunakan konversi ini pada boundary jika SDK pihak ketiga atau Android API lama masih membutuhkan Date.

Calendar ke ZonedDateTime

val calendar: Calendar = obtainLegacyCalendar()
val zonedDateTime = calendar.toInstant()
.atZone(calendar.timeZone.toZoneId())

SimpleDateFormat ke DateTimeFormatter

// Lama
val legacy = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ssXXX",
Locale.US,
)
legacy.timeZone = TimeZone.getTimeZone("UTC")

// Modern: timestamp absolut
val instant = Instant.parse("2026-07-16T02:30:00Z")

// Modern: custom representation dengan offset
val formatter = DateTimeFormatter.ofPattern(
"uuuu-MM-dd'T'HH:mm:ssXXX",
Locale.US,
)
val value = OffsetDateTime.parse(
"2026-07-16T09:30:00+07:00",
formatter,
)

Jangan hanya menerjemahkan class satu per satu. Tentukan dulu makna datanya, lalu pilih tipe java.time yang tepat.

Buat code date-time mudah di-test dengan Clock

Pemanggilan Instant.now() atau LocalDate.now() langsung memakai system clock sehingga test bergantung pada waktu saat test dijalankan.

class SessionValidator(
private val clock: Clock,
) {
fun isExpired(expiresAt: Instant): Boolean {
return !expiresAt.isAfter(clock.instant())
}
}

Production:

val validator = SessionValidator(
clock = Clock.systemUTC(),
)

Test:

@Test
fun `session is expired when expiration equals current instant`() {
val now = Instant.parse("2026-07-16T02:30:00Z")
val fixedClock = Clock.fixed(now, ZoneOffset.UTC)
val validator = SessionValidator(fixedClock)

val result = validator.isExpired(expiresAt = now)

assertTrue(result)
}

Dengan Clock.fixed(), test tidak flaky dan tidak membutuhkan delay atau manipulasi waktu global.

Decision guide untuk Android developer

Gunakan aturan singkat berikut:

  1. Tanggal saja → LocalDate.
  2. Jam saja → LocalTime.
  3. Timestamp absolut → Instant.
  4. Payload API membawa Z atau +07:00 → parse dengan Instant atau OffsetDateTime sesuai contract.
  5. Nama wilayah dan aturan timezone penting → ZonedDateTime + ZoneId.
  6. Date-time tanpa zone → LocalDateTime, tetapi pastikan kehilangan zone memang disengaja.
  7. Selisih berbasis detik → Duration.
  8. Selisih berbasis kalender → Period.
  9. Formatter reusable → DateTimeFormatter, bukan shared SimpleDateFormat.
  10. Code memerlukan "waktu sekarang" → inject Clock.

Checklist code review

  • Apakah tipe yang digunakan mewakili makna data dengan tepat?
  • Apakah timestamp absolut disimpan sebagai Instant atau ISO-8601 yang memiliki offset?
  • Apakah LocalDateTime dipakai tanpa asumsi timezone tersembunyi?
  • Apakah formatter immutable dan thread-safe?
  • Apakah locale untuk format backend ditentukan secara eksplisit?
  • Apakah timezone dikonversi hanya pada boundary yang tepat?
  • Apakah Clock dapat diganti pada unit test?
  • Apakah project dengan minSdk < 26 sudah mengaktifkan core library desugaring?
  • Apakah method java.time yang digunakan tersedia dalam versi desugaring project?
  • Apakah UI mengikuti locale serta preferensi 12/24 jam user?

Ringkasan

  • android.text.format.Time benar-benar deprecated dan digantikan oleh ZonedDateTime.
  • SimpleDateFormat, Date, dan Calendar tidak seluruhnya deprecated, tetapi merupakan legacy API yang lebih mudah disalahgunakan.
  • SimpleDateFormat tidak thread-safe; DateTimeFormatter immutable dan thread-safe.
  • Jangan memakai satu tipe untuk semua kebutuhan date-time.
  • Gunakan Instant untuk titik waktu absolut dan tipe Local* untuk nilai tanpa timezone.
  • Gunakan OffsetDateTime untuk fixed offset dan ZonedDateTime jika aturan wilayah penting.
  • Di Android sebelum API 26, aktifkan core library desugaring.
  • Inject Clock agar logic yang bergantung pada waktu dapat diuji secara deterministik.

Referensi resmi