Replace java.time APIs with kotlin(x).(date)time (#3001)

* Replace java.time APIs with kotlin(x).(date)time

kotlinx-datetime explicitly says in its README that it does not cover
i18n, so we have to stick with using their toJavaX converters wherever
we localise dates and times.

Yes, some of these replacements are... questionable. But I wanted to
try and maximise the replacement for now.

* Bump kotlinx-datetime to 0.8.0

* More kotlin.time/kotlinx-datetime replacements

* Remove redundant init block & make attributes val

* Replace new use of java.time.Instant
This commit is contained in:
MajorTanya
2026-08-02 12:58:25 +02:00
committed by GitHub
parent 0648e2eaaf
commit 6d69903a56
55 changed files with 365 additions and 268 deletions
+2
View File
@@ -22,6 +22,8 @@ dependencies {
implementation(libs.bundles.kotlinx.coroutines)
implementation(libs.bundles.serialization)
implementation(libs.kotlinx.datetime)
implementation(libs.unifile)
api(libs.sqldelight.androidxPaging)
@@ -1,14 +1,20 @@
package tachiyomi.domain.manga.interactor
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
class FetchInterval(
private val getChaptersByMangaId: GetChaptersByMangaId,
@@ -16,40 +22,42 @@ class FetchInterval(
suspend fun toMangaUpdate(
manga: Manga,
dateTime: ZonedDateTime,
dateTime: LocalDateTime,
timeZone: TimeZone,
window: Pair<Long, Long>,
): MangaUpdate {
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
chapters = getChaptersByMangaId.await(manga.id, applyScanlatorFilter = true),
zone = dateTime.zone,
zone = timeZone,
)
val currentWindow = if (window.first == 0L && window.second == 0L) {
getWindow(ZonedDateTime.now())
getWindow(Clock.System.now().toLocalDateTime(timeZone).date, timeZone)
} else {
window
}
val nextUpdate = calculateNextUpdate(manga, interval, dateTime, currentWindow)
val nextUpdate = calculateNextUpdate(manga, interval, dateTime, timeZone, currentWindow)
return MangaUpdate(id = manga.id, nextUpdate = nextUpdate, fetchInterval = interval)
}
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(GRACE_PERIOD)
val upperBound = today.plusDays(GRACE_PERIOD)
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
fun getWindow(localDateTime: LocalDate, timeZone: TimeZone): Pair<Long, Long> {
val today = localDateTime.atStartOfDayIn(timeZone)
val lowerBound = today - GRACE_PERIOD.days
val upperBound = today + GRACE_PERIOD.days
return Pair(lowerBound.toEpochMilliseconds(), upperBound.toEpochMilliseconds())
}
internal fun calculateInterval(chapters: List<Chapter>, zone: ZoneId): Int {
internal fun calculateInterval(chapters: List<Chapter>, zone: TimeZone): Int {
val chapterWindow = if (chapters.size <= 8) 3 else 10
val uploadDates = chapters.asSequence()
.filter { it.dateUpload > 0L }
.sortedByDescending { it.dateUpload }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zone)
.toLocalDate()
.atStartOfDay()
Instant.fromEpochMilliseconds(it.dateUpload)
.toLocalDateTime(zone)
.date
.atStartOfDayIn(zone)
}
.distinct()
.take(chapterWindow)
@@ -58,9 +66,10 @@ class FetchInterval(
val fetchDates = chapters.asSequence()
.sortedByDescending { it.dateFetch }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zone)
.toLocalDate()
.atStartOfDay()
Instant.fromEpochMilliseconds(it.dateFetch)
.toLocalDateTime(zone)
.date
.atStartOfDayIn(zone)
}
.distinct()
.take(chapterWindow)
@@ -69,13 +78,13 @@ class FetchInterval(
val interval = when {
// Enough upload date from source
uploadDates.size >= 3 -> {
val ranges = uploadDates.windowed(2).map { x -> x[1].until(x[0], ChronoUnit.DAYS) }.sorted()
ranges[(ranges.size - 1) / 2].toInt()
val ranges = uploadDates.windowed(2).map { x -> x[1].daysUntil(x[0], zone) }.sorted()
ranges[(ranges.size - 1) / 2]
}
// Enough fetch date from client
fetchDates.size >= 3 -> {
val ranges = fetchDates.windowed(2).map { x -> x[1].until(x[0], ChronoUnit.DAYS) }.sorted()
ranges[(ranges.size - 1) / 2].toInt()
val ranges = fetchDates.windowed(2).map { x -> x[1].daysUntil(x[0], zone) }.sorted()
ranges[(ranges.size - 1) / 2]
}
// Default to 7 days
else -> 7
@@ -87,34 +96,34 @@ class FetchInterval(
private fun calculateNextUpdate(
manga: Manga,
interval: Int,
dateTime: ZonedDateTime,
dateTime: LocalDateTime,
timeZone: TimeZone,
window: Pair<Long, Long>,
): Long {
if (manga.nextUpdate in window.first.rangeTo(window.second + 1)) {
return manga.nextUpdate
}
val latestDate = ZonedDateTime.ofInstant(
if (manga.lastUpdate > 0) Instant.ofEpochMilli(manga.lastUpdate) else Instant.now(),
dateTime.zone,
)
.toLocalDate()
.atStartOfDay()
val timeSinceLatest = ChronoUnit.DAYS.between(latestDate, dateTime).toInt()
val cycle = timeSinceLatest.floorDiv(
val instant = if (manga.lastUpdate > 0) Instant.fromEpochMilliseconds(manga.lastUpdate) else Clock.System.now()
val latestDate = instant.toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone)
val daysSinceLatest = (dateTime.toInstant(timeZone) - latestDate).inWholeDays
val cycle = daysSinceLatest.floorDiv(
interval.absoluteValue.takeIf { interval < 0 }
?: increaseInterval(interval, timeSinceLatest, increaseWhenOver = 10),
?: increaseInterval(interval, daysSinceLatest, increaseWhenOver = 10),
)
return latestDate.plusDays((cycle + 1) * interval.absoluteValue.toLong()).toEpochSecond(dateTime.offset) * 1000
val offsetDays = ((cycle + 1) * interval.absoluteValue.toLong()).days
return latestDate.plus(offsetDays).toEpochMilliseconds()
}
private fun increaseInterval(delta: Int, timeSinceLatest: Int, increaseWhenOver: Int): Int {
private fun increaseInterval(delta: Int, daysSinceLatest: Long, increaseWhenOver: Int): Int {
if (delta >= MAX_INTERVAL) return MAX_INTERVAL
// double delta again if missed more than 9 check in new delta
val cycle = timeSinceLatest.floorDiv(delta) + 1
val cycle = daysSinceLatest.floorDiv(delta) + 1
return if (cycle > increaseWhenOver) {
increaseInterval(delta * 2, timeSinceLatest, increaseWhenOver)
increaseInterval(delta * 2, daysSinceLatest, increaseWhenOver)
} else {
delta
}
@@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonObject
import mihon.core.common.extensions.EMPTY
import tachiyomi.core.common.preference.TriState
import java.io.ObjectStreamException
import java.time.Instant
import kotlin.time.Instant
import java.io.Serializable as JavaSerializable
@SuppressLint("UnsafeOptInUsageError")
@@ -47,7 +47,7 @@ data class Manga(
val expectedNextUpdate: Instant?
get() = nextUpdate
.takeIf { status != SManga.COMPLETED.toLong() }
?.let { Instant.ofEpochMilli(it) }
?.let { Instant.fromEpochMilliseconds(it) }
val sorting: Long
get() = chapterFlags and CHAPTER_SORTING_MASK
@@ -3,7 +3,7 @@ package tachiyomi.domain.updates.interactor
import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.updates.model.UpdatesWithRelations
import tachiyomi.domain.updates.repository.UpdatesRepository
import java.time.Instant
import kotlin.time.Instant
class GetUpdates(
private val repository: UpdatesRepository,
@@ -21,7 +21,7 @@ class GetUpdates(
hideExcludedScanlators: Boolean,
): Flow<List<UpdatesWithRelations>> {
return repository.subscribeAll(
instant.toEpochMilli(),
instant.toEpochMilliseconds(),
limit = 500,
unread = unread,
started = started,
@@ -2,27 +2,27 @@ package tachiyomi.domain.manga.interactor
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.chapter.model.Chapter
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT)
class FetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private val testZoneId = ZoneOffset.UTC
private val testTime = LocalDateTime.parse("2020-01-01T00:00:00")
private val testTimeZone = TimeZone.UTC
private var chapter = Chapter.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
dateUpload = testTime.toEpochSecond() * 1000,
dateFetch = testTime.toInstant(testTimeZone).toEpochMilliseconds(),
dateUpload = testTime.toInstant(testTimeZone).toEpochMilliseconds(),
)
private val fetchInterval = FetchInterval(mockk())
@@ -32,12 +32,12 @@ class FetchIntervalTest {
val chaptersWithUploadDate = (1..50).map {
chapterWithTime(chapter, 1.days)
}
fetchInterval.calculateInterval(chaptersWithUploadDate, testZoneId) shouldBe 7
fetchInterval.calculateInterval(chaptersWithUploadDate, testTimeZone) shouldBe 7
val chaptersWithoutUploadDate = chaptersWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testZoneId) shouldBe 7
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testTimeZone) shouldBe 7
}
@Test
@@ -51,7 +51,7 @@ class FetchIntervalTest {
val chapters = oldChapters + newChapters
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 1
}
@Test
@@ -66,7 +66,7 @@ class FetchIntervalTest {
val chapters = oldChapters + newChapters
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 7
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 7
}
@Test
@@ -74,7 +74,7 @@ class FetchIntervalTest {
val chapters = (1..10).map {
chapterWithTime(chapter, 10.hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 7
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 7
}
@Test
@@ -84,7 +84,7 @@ class FetchIntervalTest {
} + (1..5).map {
chapterWithTime(chapter, 2.days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 7
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 7
}
@Test
@@ -92,7 +92,7 @@ class FetchIntervalTest {
val chapters = (1..20).map {
chapterWithTime(chapter, it.days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 1
}
@Test
@@ -100,7 +100,7 @@ class FetchIntervalTest {
val chapters = (1..20).map {
chapterWithTime(chapter, (15 * it).hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 1
}
@Test
@@ -108,7 +108,7 @@ class FetchIntervalTest {
val chapters = (1..20).map {
chapterWithTime(chapter, (2 * it).days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 2
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 2
}
@Test
@@ -116,12 +116,12 @@ class FetchIntervalTest {
val chaptersWithUploadDate = (1..5).map {
chapterWithTime(chapter, (25 * it).hours)
}
fetchInterval.calculateInterval(chaptersWithUploadDate, testZoneId) shouldBe 1
fetchInterval.calculateInterval(chaptersWithUploadDate, testTimeZone) shouldBe 1
val chaptersWithoutUploadDate = chaptersWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testZoneId) shouldBe 1
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testTimeZone) shouldBe 1
}
@Test
@@ -129,11 +129,11 @@ class FetchIntervalTest {
val chapters = (1..20).map {
chapterWithTime(chapter, (43 * it).hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 2
fetchInterval.calculateInterval(chapters, testTimeZone) shouldBe 2
}
private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter {
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
val newTime = testTime.toInstant(testTimeZone).plus(duration).toEpochMilliseconds()
return chapter.copy(dateFetch = newTime, dateUpload = newTime)
}