Compare commits

...
9 Commits
Author SHA1 Message Date
AntsyLich 81e0754c0d Release v0.20.3 2026-08-04 16:58:34 +06:00
MajorTanyaandAntsyLich e287f08789 Fix MAL error when list entries have partial dates (#3573)
This has the side effect of forcing the date to something concrete.

For Year-only: 1st of January that year
Ex: 2025 -> 2025-01-01

For Year+Month: 1st of said month
Ex: 2024-11 -> 2024-11-01

MAL allows setting Day+Month and Day+Year, but both of those are sent
as `null` in the API, which means Mihon will not show a date at all.
This has the side effect of Mihon sending up the date as `null` the
next time info is sent to MAL, which will clear the date fields
completely.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2026-08-04 16:48:08 +06:00
MajorTanya a73271aefa Add category filters to Upcoming calendar (#3607) 2026-08-04 16:30:23 +06:00
AntsyLich f7a1ecd251 Fix reader loading indefinitely in some scenarios (#3686)
Init the reader from the view model instead of the activity so it isn't
cancelled by a configuration change mid init, which left the retained
view model half initialized and needsInit() returning false.

Assisted-by: Claude Code:claude-opus-5
2026-08-03 23:51:17 +06:00
Mend Renovate f80c91081f Update gradle/actions action to v6.3.0 (#3681) 2026-08-03 16:06:15 +06:00
AntsyLich 55be95dd5d Improve WebView spoofing (#3678)
- Spoof `Sec-CH-UA` client hints to match the user agent
- Fix `X-Requested-With` spoofing leaking to unrelated callers
- Update default user agent to Chrome 149
2026-08-02 20:05:21 +06:00
NGB-Was-Taken d7f3ceef5c Ensure R8 keeps methods called externally by Shizuku (#3676)
* Ensure R8 keeps methods called externally by Shizuku

* Changelog
2026-08-02 11:53:49 +00:00
MajorTanya 1d8a2b05dd Add category filters for Updates tab (#3589)
* Add category filters for Updates tab

Works just like Library Updates.

Provides a tab in the Updates tab filter dialog to allow for including
& excluding of updates belonging to a title from a given category.

Excludes overpower Includes, as with Library Updates.

A little helpful text informs users about this.

* Changelog

* [skip ci] Remove unused string

Leftover from before I realized the Default category will always be
available for in-/exclusion.

* Make :includeEmpty/:excludeEmpty true Boolean params

* "include" -> "included"

Consistency with #3607.

* Remove DISTINCT from getRecentUpdatesWithFilters

Apparently not needed anymore (though it was required at some point).

* [skip ci] Fix Changelog
2026-08-02 16:59:58 +06:00
MajorTanya 6d69903a56 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
2026-08-02 16:58:25 +06:00
84 changed files with 961 additions and 364 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[0.20.2](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -52,7 +52,7 @@ body:
label: Mihon version
description: You can find your Mihon version in **More → About**.
placeholder: |
Example: "0.20.2"
Example: "0.20.3"
validations:
required: true
@@ -95,7 +95,7 @@ body:
required: true
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.20.2](https://github.com/mihonapp/mihon/releases/latest)**.
- label: I have updated the app to version **[0.20.3](https://github.com/mihonapp/mihon/releases/latest)**.
required: true
- label: I have filled out all of the requested information in this form, including specific version numbers.
required: true
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Check code format
run: ./gradlew spotlessCheck
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Build
run: ./gradlew assembleRelease -Pinclude-telemetry -Penable-updater
@@ -89,7 +89,7 @@ jobs:
distribution: temurin
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-disabled: true
+19 -1
View File
@@ -12,6 +12,23 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
## [v0.20.3] - 2026-08-04
### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
- Add Category filtering for the Upcoming calendar ([@MajorTanya](https://github.com/MajorTanya)) ([#3607](https://github.com/mihonapp/mihon/pull/3607))
### Changed
- Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Improved
- Spoof `Sec-CH-UA` client hints in WebView to match the user agent ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Fixed
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3676](https://github.com/mihonapp/mihon/pull/3676))
- Fixed `X-Requested-With` spoofing leaking to unrelated callers ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
- Fixed reader loading indefinitely in some scenarios ([@AntsyLich](https://github.com/AntsyLich)) ([#3686](https://github.com/mihonapp/mihon/pull/3686))
- Fix MyAnimeList error for list entries with partial start/finish reading dates by setting those dates to either Jan 1st or 1st of the month ([@MajorTanya](https://github.com/MajorTanya)) ([#3573](https://github.com/mihonapp/mihon/pull/3573))
## [v0.20.2] - 2026-08-01
### Added
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
@@ -601,7 +618,8 @@ Same as v0.19.6
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.20.2...main
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.20.3...main
[v0.20.3]: https://github.com/mihonapp/mihon/compare/v0.20.2...v0.20.3
[v0.20.2]: https://github.com/mihonapp/mihon/compare/v0.20.1...v0.20.2
[v0.20.1]: https://github.com/mihonapp/mihon/compare/v0.20.0...v0.20.1
[v0.20.0]: https://github.com/mihonapp/mihon/compare/v0.19.9...v0.20.0
+4 -2
View File
@@ -32,8 +32,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 27
versionName = "0.20.2"
versionCode = 28
versionName = "0.20.3"
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getLatestCommitSha()}\"")
@@ -243,6 +243,8 @@ dependencies {
implementation(libs.sqldelight.async)
implementation(libs.kotlinx.datetime)
// AndroidX libraries
implementation(libs.androidx.annotation)
implementation(libs.androidx.appCompat)
+9
View File
@@ -86,3 +86,12 @@
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }
# KotlinX Datetime
-keep,allowoptimization class kotlinx.datetime.** { public protected *; }
# Methods called by Shizuku only
-keepclassmembers class mihon.app.shizuku.ShellInterface {
public <init>();
public void destroy();
}
@@ -10,6 +10,9 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.chapter.ChapterSanitizer
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
@@ -23,8 +26,8 @@ import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
import java.lang.Long.max
import java.time.ZonedDateTime
import java.util.TreeSet
import kotlin.time.Clock
class SyncChaptersWithSource(
private val downloadManager: DownloadManager,
@@ -57,8 +60,9 @@ class SyncChaptersWithSource(
throw NoChaptersException()
}
val now = ZonedDateTime.now()
val nowMillis = now.toInstant().toEpochMilli()
val timeZone = TimeZone.currentSystemDefault()
val now = Clock.System.now().toLocalDateTime(timeZone)
val nowMillis = now.toInstant(timeZone).toEpochMilliseconds()
val sourceChapters = rawSourceChapters
.distinctBy { it.url }
@@ -145,6 +149,7 @@ class SyncChaptersWithSource(
if (manualFetch || manga.fetchInterval == 0 || manga.nextUpdate < fetchWindow.first) {
updateManga.awaitUpdateFetchInterval(
manga,
timeZone,
now,
fetchWindow,
)
@@ -217,7 +222,7 @@ class SyncChaptersWithSource(
val chapterUpdates = updatedChapters.map { it.toChapterUpdate() }
updateChapter.awaitAll(chapterUpdates)
}
updateManga.awaitUpdateFetchInterval(manga, now, fetchWindow)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, fetchWindow)
// Set this manga as updated since chapters were changed
// Note that last_update actually represents last time the chapter list changed at all
@@ -1,11 +1,13 @@
package eu.kanade.domain.manga.interactor
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.time.Clock
class UpdateManga(
private val mangaRepository: MangaRepository,
@@ -22,25 +24,31 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime),
timeZone: TimeZone = TimeZone.currentSystemDefault(),
dateTime: LocalDateTime = Clock.System.now().toLocalDateTime(timeZone),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime.date, timeZone),
): Boolean {
return mangaRepository.update(
fetchInterval.toMangaUpdate(manga, dateTime, window),
fetchInterval.toMangaUpdate(manga, dateTime, timeZone, window),
)
}
suspend fun awaitUpdateLastUpdate(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Instant.now().toEpochMilli()))
return mangaRepository.update(MangaUpdate(id = mangaId, lastUpdate = Clock.System.now().toEpochMilliseconds()))
}
suspend fun awaitUpdateCoverLastModified(mangaId: Long): Boolean {
return mangaRepository.update(MangaUpdate(id = mangaId, coverLastModified = Instant.now().toEpochMilli()))
return mangaRepository.update(
MangaUpdate(
id = mangaId,
coverLastModified = Clock.System.now().toEpochMilliseconds(),
),
)
}
suspend fun awaitUpdateFavorite(mangaId: Long, favorite: Boolean): Boolean {
val dateAdded = when (favorite) {
true -> Instant.now().toEpochMilli()
true -> Clock.System.now().toEpochMilliseconds()
false -> 0
}
return mangaRepository.update(
@@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import kotlinx.datetime.TimeZone
import logcat.LogPriority
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withNonCancellableContext
@@ -18,7 +19,6 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.interactor.InsertTrack
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZoneOffset
class AddTracks(
private val insertTrack: InsertTrack,
@@ -62,8 +62,8 @@ class AddTracks(
firstReadChapterDate?.let {
val startDate = firstReadChapterDate.time.convertEpochMillisZone(
ZoneOffset.systemDefault(),
ZoneOffset.UTC,
TimeZone.currentSystemDefault(),
TimeZone.UTC,
)
track = track.copy(
startDate = startDate,
@@ -5,23 +5,23 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
@Composable
fun relativeDateText(
dateEpochMillis: Long,
): String {
return relativeDateText(
localDate = LocalDate.ofInstant(
Instant.ofEpochMilli(dateEpochMillis),
ZoneId.systemDefault(),
)
localDate = Instant.fromEpochMilliseconds(dateEpochMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
.takeIf { dateEpochMillis != 0L },
)
}
@@ -19,6 +19,7 @@ import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.LocalDate
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
@@ -27,7 +28,6 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import java.time.LocalDate
@Composable
fun HistoryScreen(
@@ -2,13 +2,16 @@ package eu.kanade.presentation.history
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import eu.kanade.tachiyomi.ui.history.HistoryViewModel
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.manga.model.MangaCover
import java.time.Instant
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import java.util.Date
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class HistoryviewModelStateProvider : PreviewParameterProvider<HistoryViewModel.State> {
@@ -18,9 +21,9 @@ class HistoryviewModelStateProvider : PreviewParameterProvider<HistoryViewModel.
listOf(HistoryUiModelExamples.headerToday)
.asSequence()
.plus(HistoryUiModelExamples.items().take(3))
.plus(HistoryUiModelExamples.header { it.minus(1, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.header { it.minus(1.days) })
.plus(HistoryUiModelExamples.items().take(1))
.plus(HistoryUiModelExamples.header { it.minus(2, ChronoUnit.DAYS) })
.plus(HistoryUiModelExamples.header { it.minus(2.days) })
.plus(HistoryUiModelExamples.items().take(7))
.toList(),
dialog = null,
@@ -72,10 +75,12 @@ class HistoryviewModelStateProvider : PreviewParameterProvider<HistoryViewModel.
private object HistoryUiModelExamples {
val headerToday = header()
val headerTomorrow =
HistoryUiModel.Header(LocalDate.now().plusDays(1))
HistoryUiModel.Header(Clock.System.now().plus(1.days).toLocalDateTime(TimeZone.currentSystemDefault()).date)
fun header(instantBuilder: (Instant) -> Instant = { it }) =
HistoryUiModel.Header(LocalDate.from(instantBuilder(Instant.now())))
HistoryUiModel.Header(
instantBuilder(Clock.System.now()).toLocalDateTime(TimeZone.currentSystemDefault()).date,
)
fun items() = sequence {
var count = 1
@@ -94,7 +99,7 @@ class HistoryviewModelStateProvider : PreviewParameterProvider<HistoryViewModel.
mangaId = Random.nextLong(),
title = "Test Title",
chapterNumber = Random.nextDouble(),
readAt = Date.from(Instant.now()),
readAt = Date.from(Clock.System.now().toJavaInstant()),
readDuration = Random.nextLong(),
coverData = MangaCover(
mangaId = Random.nextLong(),
@@ -75,7 +75,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.shouldExpandFAB
import tachiyomi.source.local.isLocal
import java.time.Instant
import kotlin.time.Instant
@Composable
fun MangaScreen(
@@ -20,15 +20,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Instant
@Composable
fun DeleteChaptersDialog(
@@ -72,8 +74,8 @@ fun SetIntervalDialog(
val nextUpdateDays = remember(nextUpdate) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
val now = Clock.System.now()
now.daysUntil(nextUpdate, TimeZone.currentSystemDefault()).coerceAtLeast(0)
} else {
null
}
@@ -91,6 +91,8 @@ import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.findChildOfType
@@ -105,9 +107,9 @@ import tachiyomi.presentation.core.util.clickableNoIndication
import tachiyomi.presentation.core.util.secondaryItemAlpha
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
import kotlin.time.Clock
import kotlin.time.Instant
@Composable
fun MangaInfoBox(
@@ -189,8 +191,8 @@ fun MangaActionRow(
// TODO: show something better when using custom interval
val nextUpdateDays = remember(nextUpdate) {
return@remember if (nextUpdate != null) {
val now = Instant.now()
now.until(nextUpdate, ChronoUnit.DAYS).toInt().coerceAtLeast(0)
val now = Clock.System.now()
now.daysUntil(nextUpdate, TimeZone.currentSystemDefault()).coerceAtLeast(0)
} else {
null
}
@@ -19,12 +19,15 @@ import eu.kanade.presentation.more.settings.screen.appearance.AppLanguageScreen
import eu.kanade.presentation.more.settings.widget.AppThemeModePreferenceWidget
import eu.kanade.presentation.more.settings.widget.AppThemePreferenceWidget
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.LocalDate
import kotlin.time.Clock
object SettingsAppearanceScreen : SearchableSettings {
@@ -99,7 +102,7 @@ object SettingsAppearanceScreen : SearchableSettings {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val now = remember { LocalDate.now() }
val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).toJavaLocalDateTime() }
val dateFormat by uiPreferences.dateFormat.collectAsState()
val formattedNow = remember(dateFormat) {
@@ -40,6 +40,8 @@ import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.updaterEnabled
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import tachiyomi.core.common.Constants
import tachiyomi.core.common.util.lang.withIOContext
@@ -59,9 +61,7 @@ import tachiyomi.presentation.core.icons.Reddit
import tachiyomi.presentation.core.icons.X
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
object AboutScreen : Screen() {
@@ -273,16 +273,14 @@ object AboutScreen : Screen() {
internal fun getFormattedBuildTime(): String {
return try {
LocalDateTime.ofInstant(
Instant.parse(BuildConfig.BUILD_TIME),
ZoneId.systemDefault(),
)
Instant.parse(BuildConfig.BUILD_TIME)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat.get(),
),
)
} catch (e: Exception) {
} catch (_: Exception) {
BuildConfig.BUILD_TIME
}
}
@@ -38,15 +38,15 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
class WorkerInfoScreen : Screen() {
@@ -165,10 +165,8 @@ class WorkerInfoScreen : Screen() {
}
appendLine("State: ${workInfo.state}")
if (workInfo.state == WorkInfo.State.ENQUEUED) {
val timestamp = LocalDateTime.ofInstant(
Instant.ofEpochMilli(workInfo.nextScheduleTimeMillis),
ZoneId.systemDefault(),
)
val timestamp = Instant.fromEpochMilliseconds(workInfo.nextScheduleTimeMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.toDateTimestampString(
UiPreferences.dateFormat(
Injekt.get<UiPreferences>().dateFormat.get(),
@@ -56,8 +56,10 @@ import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.util.lang.toJavaLocalDate
import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.datetime.toJavaLocalDate
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.format.DateTimeFormatter
@@ -110,11 +112,13 @@ fun TrackInfoDialogHome(
.takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring },
startDate = remember(item.track.startDate) { dateFormat.format(item.track.startDate.toLocalDate()) }
startDate = remember(item.track.startDate) {
dateFormat.format(item.track.startDate.toLocalDate().toJavaLocalDate())
}
.takeIf { supportsReadingDates && item.track.startDate != 0L },
onStartDateClick = { onStartDateEdit(item) } // TODO
.takeIf { supportsReadingDates },
endDate = dateFormat.format(item.track.finishDate.toLocalDate())
endDate = dateFormat.format(item.track.finishDate.toJavaLocalDate())
.takeIf { supportsReadingDates && item.track.finishDate != 0L },
onEndDateClick = { onEndDateEdit(item) }
.takeIf { supportsReadingDates },
@@ -6,11 +6,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.Locale
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaInstant
internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composable () -> Unit> {
private val fullPageWithSecondSelected = @Composable {
@@ -93,7 +94,7 @@ internal class TrackerSearchPreviewProvider : PreviewParameterProvider<@Composab
it.finished_reading_date = 0L
it.tracking_url = "https://example.com/tracker-example"
it.cover_url = "https://example.com/cover.png"
it.start_date = formatter.format(Date.from(Instant.now().minus((1L..365).random(), ChronoUnit.DAYS)))
it.start_date = formatter.format(Date.from(Clock.System.now().minus((1L..365).random().days).toJavaInstant()))
it.summary = lorem((0..40).random()).joinToString()
it.publishing_status = if (Random.nextBoolean()) "Finished" else ""
it.publishing_type = if (Random.nextBoolean()) "Oneshot" else ""
@@ -14,12 +14,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.tachiyomi.ui.updates.UpdatesSettingsViewModel
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.updates.service.UpdatesPreferences
import tachiyomi.i18n.MR
@@ -27,6 +34,7 @@ import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
@Composable
@@ -38,14 +46,18 @@ fun UpdatesFilterDialog(
onDismissRequest = onDismissRequest,
tabTitles = listOf(
stringResource(MR.strings.action_filter),
stringResource(MR.strings.categories),
),
) {
) { page ->
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
FilterSheet(viewModel = viewModel)
when (page) {
0 -> FilterSheet(viewModel = viewModel)
1 -> CategoryFilterSheet(viewModel = viewModel)
}
}
}
}
@@ -108,3 +120,55 @@ private fun ColumnScope.FilterSheet(
)
}
}
@Composable
private fun ColumnScope.CategoryFilterSheet(
viewModel: UpdatesSettingsViewModel,
) {
Text(
stringResource(MR.strings.pref_filter_update_categories_details),
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.extraSmall))
val allCategories by viewModel.getCategories.subscribe().collectAsState(initial = emptyList())
if (allCategories.isEmpty()) {
// since it includes the system category, this should only happen when loading is required
LoadingScreen(modifier = Modifier.padding(16.dp))
return
}
val excluded by viewModel.updatesPreferences.filterExcludedCategories.collectAsState()
val included by viewModel.updatesPreferences.filterIncludedCategories.collectAsState()
val selected = remember {
allCategories.map { category ->
when (category.id) {
in included -> TriState.ENABLED_IS
in excluded -> TriState.ENABLED_NOT
else -> TriState.DISABLED
}
}.toMutableStateList()
}
Column {
allCategories.fastForEachIndexed { idx, category ->
val state = selected[idx]
TriStateItem(
label = category.visualName,
state = state,
onClick = {
selected[idx] = state.next()
viewModel.cycleCategory(category)
},
)
}
}
}
@@ -32,6 +32,7 @@ import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.updates.UpdatesViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh
@@ -40,7 +41,6 @@ import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.active
import java.time.LocalDate
import kotlin.time.Duration.Companion.seconds
@Composable
@@ -7,7 +7,7 @@ import androidx.compose.runtime.ReadOnlyComposable
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.Instant
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@@ -29,7 +29,7 @@ fun Duration.toDurationString(context: Context, fallback: String): String {
@Composable
@ReadOnlyComposable
fun relativeTimeSpanString(epochMillis: Long): String {
val now = Instant.now().toEpochMilli()
val now = Clock.System.now().toEpochMilliseconds()
return when {
epochMillis <= 0L -> stringResource(MR.strings.relative_time_span_never)
now - epochMillis < 1.minutes.inWholeMilliseconds -> stringResource(
@@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import kotlinx.coroutines.launch
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
@@ -72,6 +73,7 @@ fun WebViewScreenContent(
onNavigateUp: () -> Unit,
initialTitle: String?,
url: String,
defaultUserAgentProvider: () -> String,
onShare: (String) -> Unit,
onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit,
@@ -340,9 +342,7 @@ fun WebViewScreenContent(
WebView.setWebContentsDebuggingEnabled(true)
}
headers["user-agent"]?.let {
webView.settings.userAgentString = it
}
webView.setUserAgent(headers["user-agent"] ?: defaultUserAgentProvider())
},
onDispose = { webView ->
val window = windowStack.items.find { it.webView == webView }
+1 -2
View File
@@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Looper
import android.webkit.WebView
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
@@ -231,7 +230,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
override fun getPackageName(): String {
try {
// Override the value passed as X-Requested-With in WebView requests
val stackTrace = Looper.getMainLooper().thread.stackTrace
val stackTrace = Thread.currentThread().stackTrace
val isChromiumCall = stackTrace.any { trace ->
trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") &&
trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>")
@@ -18,7 +18,7 @@ import tachiyomi.core.common.storage.displayablePath
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
class BackupNotifier(private val context: Context) {
@@ -149,10 +149,8 @@ class BackupNotifier(private val context: Context) {
val timeString = context.stringResource(
MR.strings.restore_duration,
TimeUnit.MILLISECONDS.toMinutes(time),
TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds(
TimeUnit.MILLISECONDS.toMinutes(time),
),
time.milliseconds.inWholeMinutes,
time.milliseconds.inWholeSeconds - (time.milliseconds.inWholeMinutes * 60),
)
with(completeNotificationBuilder) {
@@ -33,9 +33,9 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
import java.util.Locale
import kotlin.time.Clock
class BackupCreator(
private val context: Context,
@@ -108,7 +108,7 @@ class BackupCreator(
BackupFileValidator(context).validate(fileUri)
if (isAutoBackup) {
backupPreferences.lastAutoBackupTimestamp.set(Instant.now().toEpochMilli())
backupPreferences.lastAutoBackupTimestamp.set(Clock.System.now().toEpochMilliseconds())
}
return fileUri.toString()
@@ -9,6 +9,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.data.Database
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.data.UpdateStrategyColumnAdapter
@@ -23,9 +25,9 @@ import tachiyomi.domain.track.interactor.InsertTrack
import tachiyomi.domain.track.model.Track
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZonedDateTime
import java.util.Date
import kotlin.math.max
import kotlin.time.Clock
class MangaRestorer(
private val database: Database = Injekt.get(),
@@ -38,13 +40,9 @@ class MangaRestorer(
fetchInterval: FetchInterval = Injekt.get(),
) {
private var now = ZonedDateTime.now()
private var currentFetchWindow = fetchInterval.getWindow(now)
init {
now = ZonedDateTime.now()
currentFetchWindow = fetchInterval.getWindow(now)
}
private val timeZone = TimeZone.currentSystemDefault()
private val now = Clock.System.now().toLocalDateTime(timeZone)
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
val urlsBySource = database.mangasQueries
@@ -286,7 +284,7 @@ class MangaRestorer(
restoreTracking(manga, tracks)
restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, currentFetchWindow)
return manga
}
@@ -35,6 +35,8 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.source.interactor.UpdateMangaFromRemote
@@ -64,14 +66,13 @@ import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.time.Instant
import java.time.ZonedDateTime
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.time.Clock
@OptIn(ExperimentalAtomicApi::class)
class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
@@ -108,7 +109,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
setForegroundSafely()
libraryPreferences.lastUpdatedTimestamp.set(Instant.now().toEpochMilli())
libraryPreferences.lastUpdatedTimestamp.set(Clock.System.now().toEpochMilliseconds())
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
addMangaToQueue(categoryId)
@@ -167,7 +168,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val restrictions = libraryPreferences.autoUpdateMangaRestrictions.get()
val skippedUpdates = mutableListOf<Pair<Manga, String?>>()
val (_, fetchWindowUpperBound) = fetchInterval.getWindow(ZonedDateTime.now())
val timeZone = TimeZone.currentSystemDefault()
val (_, fetchWindowUpperBound) = fetchInterval.getWindow(
Clock.System.now().toLocalDateTime(timeZone).date,
timeZone,
)
mangaToUpdate = listToUpdate
.filter {
@@ -234,7 +239,8 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
val timeZone = TimeZone.currentSystemDefault()
val fetchWindow = fetchInterval.getWindow(Clock.System.now().toLocalDateTime(timeZone).date, timeZone)
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
@@ -24,7 +24,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.time.Instant
import kotlin.time.Clock
class ImageSaver(
val context: Context,
@@ -85,7 +85,7 @@ class ImageSaver(
MediaStore.MediaColumns.RELATIVE_PATH to relativePath,
MediaStore.MediaColumns.DISPLAY_NAME to if (isMimeTypeSupported) image.name else filename,
MediaStore.MediaColumns.MIME_TYPE to type.mime,
MediaStore.MediaColumns.DATE_MODIFIED to Instant.now().epochSecond,
MediaStore.MediaColumns.DATE_MODIFIED to Clock.System.now().epochSeconds,
)
val picture = findUriOrDefault(relativePath, filename) {
@@ -15,6 +15,9 @@ import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@@ -25,10 +28,8 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@@ -326,11 +327,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
}
val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateValue), ZoneId.systemDefault())
val dateTime = Instant.fromEpochMilliseconds(dateValue).toLocalDateTime(TimeZone.currentSystemDefault())
return buildJsonObject {
put("year", dateTime.year)
put("month", dateTime.monthValue)
put("day", dateTime.dayOfMonth)
put("month", dateTime.month.number)
put("day", dateTime.day)
}
}
@@ -1,8 +1,9 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.serialization.Serializable
import java.time.LocalDate
import java.time.ZoneId
@Serializable
data class ALFuzzyDate(
@@ -11,10 +12,9 @@ data class ALFuzzyDate(
val day: Int?,
) {
fun toEpochMilli(): Long = try {
LocalDate.of(year!!, month!!, day!!)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
LocalDate(year!!, month!!, day!!)
.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
} catch (_: Exception) {
0L
}
@@ -245,7 +245,13 @@ class MyAnimeListApi(
}
private fun parseDate(isoDate: String): Long {
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
val pattern = when (isoDate.length) {
10 -> "yyyy-MM-dd"
7 -> "yyyy-MM"
4 -> "yyyy"
else -> throw IllegalArgumentException("Unsupported date format: \"$isoDate\"")
}
return SimpleDateFormat(pattern, Locale.US).parse(isoDate)?.time ?: 0L
}
private fun convertToIsoDate(epochTime: Long): String? {
@@ -17,6 +17,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.upcoming.service.UpcomingPreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
@@ -47,6 +48,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
LibraryPreferences(get())
}
addSingletonFactory {
UpcomingPreferences(get())
}
addSingletonFactory {
UpdatesPreferences(get())
}
@@ -49,7 +49,7 @@ import tachiyomi.domain.source.interactor.GetRemoteManga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import kotlin.time.Clock
import eu.kanade.tachiyomi.source.model.Filter as SourceModelFilter
class BrowseSourceViewModel(
@@ -226,7 +226,7 @@ class BrowseSourceViewModel(
favorite = !manga.favorite,
dateAdded = when (manga.favorite) {
true -> 0
false -> Instant.now().toEpochMilli()
false -> Clock.System.now().toEpochMilliseconds()
},
)
@@ -56,7 +56,6 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import eu.kanade.tachiyomi.util.lang.toLocalDate
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
@@ -66,6 +65,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import mihon.core.viewmodel.StateViewModel
import tachiyomi.core.common.i18n.stringResource
@@ -85,9 +86,8 @@ import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import kotlin.time.Clock
import kotlin.time.Instant
data class TrackInfoDialogHomeScreen(
private val mangaId: Long,
@@ -513,20 +513,20 @@ private data class TrackDateSelectorScreen(
@Transient
private val selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val targetDate = Instant.ofEpochMilli(utcTimeMillis).toLocalDate(ZoneOffset.UTC)
val targetDate = Instant.fromEpochMilliseconds(utcTimeMillis).toLocalDateTime(TimeZone.UTC)
// Disallow future dates
if (targetDate > LocalDate.now(ZoneOffset.UTC)) return false
if (targetDate > Clock.System.now().toLocalDateTime(TimeZone.UTC)) return false
return when {
// Disallow setting start date after finish date
start && track.finishDate > 0 -> {
val finishDate = Instant.ofEpochMilli(track.finishDate).toLocalDate(ZoneOffset.UTC)
val finishDate = Instant.fromEpochMilliseconds(track.finishDate).toLocalDateTime(TimeZone.UTC)
targetDate <= finishDate
}
// Disallow setting finish date before start date
!start && track.startDate > 0 -> {
val startDate = Instant.ofEpochMilli(track.startDate).toLocalDate(ZoneOffset.UTC)
val startDate = Instant.fromEpochMilliseconds(track.startDate).toLocalDateTime(TimeZone.UTC)
startDate <= targetDate
}
else -> {
@@ -537,17 +537,17 @@ private data class TrackDateSelectorScreen(
override fun isSelectableYear(year: Int): Boolean {
// Disallow future years
if (year > LocalDate.now(ZoneOffset.UTC).year) return false
if (year > Clock.System.now().toLocalDateTime(TimeZone.UTC).year) return false
return when {
// Disallow setting start year after finish year
start && track.finishDate > 0 -> {
val finishDate = Instant.ofEpochMilli(track.finishDate).toLocalDate(ZoneOffset.UTC)
val finishDate = Instant.fromEpochMilliseconds(track.finishDate).toLocalDateTime(TimeZone.UTC)
year <= finishDate.year
}
// Disallow setting finish year before start year
!start && track.startDate > 0 -> {
val startDate = Instant.ofEpochMilli(track.startDate).toLocalDate(ZoneOffset.UTC)
val startDate = Instant.fromEpochMilliseconds(track.startDate).toLocalDateTime(TimeZone.UTC)
startDate.year <= year
}
else -> {
@@ -618,14 +618,14 @@ private data class TrackDateSelectorScreen(
get() {
val millis = (if (start) track.startDate else track.finishDate)
.takeIf { it != 0L }
?: Instant.now().toEpochMilli()
return millis.convertEpochMillisZone(ZoneOffset.systemDefault(), ZoneOffset.UTC)
?: Clock.System.now().toEpochMilliseconds()
return millis.convertEpochMillisZone(TimeZone.currentSystemDefault(), TimeZone.UTC)
}
// In UTC
fun setDate(millis: Long) {
// Convert to local time
val localMillis = millis.convertEpochMillisZone(ZoneOffset.UTC, ZoneOffset.systemDefault())
val localMillis = millis.convertEpochMillisZone(TimeZone.UTC, TimeZone.currentSystemDefault())
viewModelScope.launchNonCancellable {
if (start) {
tracker.setRemoteStartDate(track.toDbTrack(), localMillis)
@@ -98,7 +98,6 @@ import tachiyomi.core.common.Constants
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.util.collectAsState
@@ -171,26 +170,17 @@ class ReaderActivity : BaseActivity() {
setContentView(binding.root)
binding.setComposeOverlay()
if (viewModel.needsInit()) {
val manga = intent.extras?.getLong("manga", -1) ?: -1L
val chapter = intent.extras?.getLong("chapter", -1) ?: -1L
if (manga == -1L || chapter == -1L) {
finish()
return
}
NotificationReceiver.dismissNotification(this, manga.hashCode(), Notifications.ID_NEW_CHAPTERS)
lifecycleScope.launch {
val initResult = viewModel.init(manga, chapter)
if (!initResult.getOrDefault(false)) {
val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err")
withUIContext {
setInitialChapterError(exception)
}
}
}
if (!viewModel.hasValidArgs) {
finish()
return
}
NotificationReceiver.dismissNotification(
this,
viewModel.mangaId.hashCode(),
Notifications.ID_NEW_CHAPTERS,
)
config = ReaderConfig()
setMenuVisibility(viewModel.state.value.menuVisible)
@@ -200,6 +190,13 @@ class ReaderActivity : BaseActivity() {
.onEach { if (!it) finish() }
.launchIn(lifecycleScope)
viewModel.state
.map { it.initError }
.distinctUntilChanged()
.filterNotNull()
.onEach(::setInitialChapterError)
.launchIn(lifecycleScope)
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import tachiyomi.core.common.preference.toggle
@@ -76,8 +77,9 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.util.Date
import kotlin.getValue
import kotlin.time.Clock
/**
* Presenter used by the activity to perform background operations.
@@ -106,6 +108,14 @@ class ReaderViewModel @JvmOverloads constructor(
private val mutableState = MutableStateFlow(State())
val state = mutableState.asStateFlow()
/**
* Ids of the manga and chapter the reader was launched with, taken from the activity intent.
*/
val mangaId = savedState.get<Long>("manga") ?: -1L
private val initialChapterId = savedState.get<Long>("chapter") ?: -1L
val hasValidArgs = mangaId != -1L && initialChapterId != -1L
private val eventChannel = Channel<Event>()
val eventFlow = eventChannel.receiveAsFlow()
@@ -243,6 +253,10 @@ class ReaderViewModel @JvmOverloads constructor(
chapterId = currentChapter.chapter.id!!
}
.launchIn(viewModelScope)
if (hasValidArgs) {
viewModelScope.launch { init() }
}
}
override fun onCleared() {
@@ -264,41 +278,28 @@ class ReaderViewModel @JvmOverloads constructor(
}
/**
* Whether this presenter is initialized yet.
* Initializes this presenter with the [mangaId] and [initialChapterId] the reader was launched
* with. This method will fetch the manga from the database and initialize the initial chapter.
* Failures are reported through [State.initError].
*/
fun needsInit(): Boolean {
return manga == null
}
/**
* Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
* fetch the manga from the database and initialize the initial chapter.
*/
suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
if (!needsInit()) return Result.success(true)
return withIOContext {
private suspend fun init() {
withIOContext {
try {
val manga = getManga.await(mangaId)
if (manga != null) {
sourceManager.isInitialized.first { it }
mutableState.update { it.copy(manga = manga) }
if (chapterId == -1L) chapterId = initialChapterId
val manga = getManga.await(mangaId) ?: error("Requested manga of id $mangaId not found")
sourceManager.isInitialized.first { it }
mutableState.update { it.copy(manga = manga) }
if (chapterId == -1L) chapterId = initialChapterId
val context = Injekt.get<Application>()
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
val context = Injekt.get<Application>()
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
Result.success(true)
} else {
// Unlikely but okay
Result.success(false)
}
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
Result.failure(e)
mutableState.update { it.copy(initError = e) }
}
}
}
@@ -582,7 +583,7 @@ class ReaderViewModel @JvmOverloads constructor(
}
fun restartReadTimer() {
chapterReadStartTime = Instant.now().toEpochMilli()
chapterReadStartTime = Clock.System.now().toEpochMilliseconds()
}
/**
@@ -947,6 +948,7 @@ class ReaderViewModel @JvmOverloads constructor(
@Immutable
data class State(
val manga: Manga? = null,
val initError: Throwable? = null,
val viewerChapters: ViewerChapters? = null,
val bookmarked: Boolean = false,
val isLoadingAdjacentChapter: Boolean = false,
@@ -4,14 +4,32 @@ import androidx.lifecycle.ViewModel
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class UpdatesSettingsViewModel(
val updatesPreferences: UpdatesPreferences = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
) : ViewModel() {
val includedCategories = updatesPreferences.filterIncludedCategories
val excludedCategories = updatesPreferences.filterExcludedCategories
fun cycleCategory(category: Category) {
when (category.id) {
in includedCategories.get() -> {
includedCategories.getAndSet { it - category.id }
excludedCategories.getAndSet { it + category.id }
}
in excludedCategories.get() -> excludedCategories.getAndSet { it - category.id }
else -> includedCategories.getAndSet { it + category.id }
}
}
fun toggleFilter(preference: (UpdatesPreferences) -> Preference<TriState>) {
preference(updatesPreferences).getAndSet {
it.next()
@@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import logcat.LogPriority
import mihon.core.viewmodel.StateViewModel
import tachiyomi.core.common.preference.TriState
@@ -49,7 +52,7 @@ import tachiyomi.domain.updates.model.UpdatesWithRelations
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.ZonedDateTime
import kotlin.time.Clock
class UpdatesViewModel(
private val sourceManager: SourceManager = Injekt.get(),
@@ -77,7 +80,7 @@ class UpdatesViewModel(
init {
viewModelScope.launchIO {
// Set date limit for recent chapters
val limit = ZonedDateTime.now().minusMonths(3).toInstant()
val limit = Clock.System.now().minus(3, DateTimeUnit.MONTH, TimeZone.currentSystemDefault())
combine(
// needed for SQL filters (unread, started, bookmarked, etc)
@@ -90,6 +93,8 @@ class UpdatesViewModel(
started = it.filterStarted.toBooleanOrNull(),
bookmarked = it.filterBookmarked.toBooleanOrNull(),
hideExcludedScanlators = it.filterExcludedScanlators,
includedCategories = it.filterIncludedCategories,
excludedCategories = it.filterExcludedCategories,
).distinctUntilChanged()
},
downloadCache.changes,
@@ -420,13 +425,18 @@ class UpdatesViewModel(
updatesPreferences.filterStarted.changes(),
updatesPreferences.filterBookmarked.changes(),
updatesPreferences.filterExcludedScanlators.changes(),
) { downloaded, unread, started, bookmarked, excludedScanlators ->
updatesPreferences.filterIncludedCategories.changes(),
updatesPreferences.filterExcludedCategories.changes(),
) {
@Suppress("UNCHECKED_CAST")
ItemPreferences(
filterDownloaded = downloaded,
filterUnread = unread,
filterStarted = started,
filterBookmarked = bookmarked,
filterExcludedScanlators = excludedScanlators,
filterDownloaded = it[0] as TriState,
filterUnread = it[1] as TriState,
filterStarted = it[2] as TriState,
filterBookmarked = it[3] as TriState,
filterExcludedScanlators = it[4] as Boolean,
filterIncludedCategories = it[5] as List<Long>,
filterExcludedCategories = it[6] as List<Long>,
)
}
}
@@ -442,6 +452,8 @@ class UpdatesViewModel(
val filterStarted: TriState,
val filterBookmarked: TriState,
val filterExcludedScanlators: Boolean,
val filterIncludedCategories: List<Long>,
val filterExcludedCategories: List<Long>,
)
@Immutable
@@ -72,6 +72,7 @@ class WebViewActivity : BaseActivity() {
initialTitle = intent.extras?.getString(TITLE_KEY),
url = url,
headers = headers,
defaultUserAgentProvider = network::defaultUserAgentProvider,
onUrlChange = { assistUrl = it },
onShare = this::shareWebpage,
onOpenInBrowser = this::openInBrowser,
@@ -36,6 +36,7 @@ class WebViewScreen(
initialTitle = initialTitle,
url = url,
headers = viewModel.headers,
defaultUserAgentProvider = viewModel::defaultUserAgentProvider,
onUrlChange = { assistUrl = it },
onShare = { viewModel.shareWebpage(context, it) },
onOpenInBrowser = { viewModel.openInBrowser(context, it) },
@@ -67,4 +67,6 @@ class WebViewViewModel(
logcat { "Cleared $cleared cookies for: $url" }
}
}
fun defaultUserAgentProvider() = network.defaultUserAgentProvider()
}
@@ -10,12 +10,14 @@ import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.datetime.TimeZone
import kotlinx.datetime.offsetAt
import kotlinx.datetime.toLocalDateTime
import tachiyomi.core.common.util.lang.withNonCancellableContext
import tachiyomi.core.common.util.lang.withUIContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.OffsetDateTime
import java.time.ZoneId
import kotlin.time.Clock
class CrashLogUtil(
private val context: Context,
@@ -35,12 +37,14 @@ class CrashLogUtil(
val uri = file.getUriCompat(context)
context.startActivity(uri.toShareIntent(context, "text/plain"))
} catch (e: Throwable) {
} catch (_: Throwable) {
withUIContext { context.toast("Failed to get logs") }
}
}
fun getDebugInfo(): String {
val now = Clock.System.now()
val tz = TimeZone.currentSystemDefault()
return """
App ID: ${BuildConfig.APPLICATION_ID}
App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE}, ${BuildConfig.BUILD_TIME})
@@ -51,7 +55,7 @@ class CrashLogUtil(
Device name: ${Build.DEVICE} (${Build.PRODUCT})
Device model: ${Build.MODEL}
WebView: ${WebViewUtil.getVersion(context)}
Current time: ${OffsetDateTime.now(ZoneId.systemDefault())}
Current time: ${now.toLocalDateTime(tz)}${tz.offsetAt(now)}
""".trimIndent()
}
@@ -9,12 +9,12 @@ import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.InputStream
import java.time.Instant
import kotlin.time.Clock
fun Manga.removeCovers(coverCache: CoverCache = Injekt.get()): Manga {
if (isLocal()) return this
return if (coverCache.deleteFromCache(this, true) > 0) {
copy(coverLastModified = Instant.now().toEpochMilli())
copy(coverLastModified = Clock.System.now().toEpochMilliseconds())
} else {
this
}
@@ -1,23 +1,29 @@
package eu.kanade.tachiyomi.util.lang
import android.content.Context
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import java.text.DateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
import java.util.Date
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Instant
fun LocalDateTime.toDateTimestampString(dateTimeFormatter: DateTimeFormatter): String {
val date = dateTimeFormatter.format(this)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(this)
val javaLocalDateTime = this.toJavaLocalDateTime()
val date = dateTimeFormatter.format(javaLocalDateTime)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(javaLocalDateTime)
return "$date $time"
}
@@ -26,21 +32,21 @@ fun Date.toTimestampString(): String {
}
fun Long.convertEpochMillisZone(
from: ZoneId,
to: ZoneId,
from: TimeZone,
to: TimeZone,
): Long {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(this), from)
.atZone(to)
.toInstant()
.toEpochMilli()
return Instant.fromEpochMilliseconds(this)
.toLocalDateTime(from)
.toInstant(to)
.toEpochMilliseconds()
}
fun Long.toLocalDate(): LocalDate {
return LocalDate.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault())
return Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date
}
fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate {
return LocalDate.ofInstant(this, zoneId)
fun Long.toJavaLocalDate(): java.time.LocalDate {
return this.toLocalDate().toJavaLocalDate()
}
fun LocalDate.toRelativeString(
@@ -49,23 +55,25 @@ fun LocalDate.toRelativeString(
dateFormat: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT),
): String {
if (!relative) {
return dateFormat.format(this)
return dateFormat.format(this.toJavaLocalDate())
}
val now = LocalDate.now()
val difference = ChronoUnit.DAYS.between(this, now)
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val difference = (today - this).days
return when {
difference < -7 -> dateFormat.format(this)
difference < -7 -> dateFormat.format(this.toJavaLocalDate())
difference < 0 -> context.pluralStringResource(
MR.plurals.upcoming_relative_time,
difference.toInt().absoluteValue,
difference.toInt().absoluteValue,
difference.absoluteValue,
difference.absoluteValue,
)
difference < 1 -> context.stringResource(MR.strings.relative_time_today)
difference < 7 -> context.pluralStringResource(
MR.plurals.relative_time,
difference.toInt(),
difference.toInt(),
difference,
difference,
)
else -> dateFormat.format(this)
else -> dateFormat.format(this.toJavaLocalDate())
}
}
@@ -20,7 +20,7 @@ import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack
import java.time.Instant
import kotlin.time.Clock
class MigrateMangaUseCase(
private val sourcePreferences: SourcePreferences,
@@ -124,7 +124,7 @@ class MigrateMangaUseCase(
favorite = true,
chapterFlags = current.chapterFlags,
viewerFlags = current.viewerFlags,
dateAdded = if (replace) current.dateAdded else Instant.now().toEpochMilli(),
dateAdded = if (replace) current.dateAdded else Clock.System.now().toEpochMilliseconds(),
notes = if (MigrationFlag.NOTES in flags) current.notes else null,
)
@@ -20,7 +20,7 @@ import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.isLocal
import java.time.Instant
import kotlin.time.Clock
class UpdateMangaFromRemote(
private val sourceManager: SourceManager,
@@ -107,14 +107,14 @@ class UpdateMangaFromRemote(
// Never refresh covers if the url is empty to avoid "losing" existing covers
remoteManga.thumbnail_url.isNullOrEmpty() -> null
!manualFetch && localManga.thumbnailUrl == remoteManga.thumbnail_url -> null
localManga.isLocal() -> Instant.now().toEpochMilli()
localManga.isLocal() -> Clock.System.now().toEpochMilliseconds()
localManga.hasCustomCover(coverCache) -> {
coverCache.deleteFromCache(localManga, false)
null
}
else -> {
coverCache.deleteFromCache(localManga, false)
Instant.now().toEpochMilli()
Clock.System.now().toEpochMilliseconds()
}
}
@@ -1,6 +1,9 @@
package mihon.feature.library
import eu.kanade.tachiyomi.ui.library.LibraryItem
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import mihon.domain.library.model.search.AndNode
import mihon.domain.library.model.search.ComparisonField
import mihon.domain.library.model.search.ComparisonQueryNode
@@ -12,10 +15,8 @@ import mihon.domain.library.model.search.NotNode
import mihon.domain.library.model.search.OrNode
import mihon.domain.library.model.search.QueryNode
import tachiyomi.source.local.LocalSource
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlin.math.abs
import kotlin.time.Instant
fun QueryNode.matches(item: LibraryItem): Boolean {
return when (this) {
@@ -109,7 +110,7 @@ private fun ComparisonQueryNode.matches(item: LibraryItem): Boolean {
fun compareDates(timestamp: Long, value: String): Boolean? {
val inputDate = runCatching { LocalDate.parse(value) }.getOrNull() ?: return null
val mangaDate = Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate()
val mangaDate = Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).date
return queryComparator.apply(mangaDate, inputDate)
}
@@ -1,13 +1,38 @@
package mihon.feature.upcoming
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.category.visualName
import eu.kanade.presentation.components.TabbedDialog
import eu.kanade.presentation.components.TabbedDialogPaddings
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import tachiyomi.core.common.preference.TriState
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.TriStateItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.util.collectAsState
class UpcomingScreen : Screen() {
@@ -18,10 +43,93 @@ class UpcomingScreen : Screen() {
val viewModel = viewModel<UpcomingViewModel>()
val state by viewModel.state.collectAsState()
when (state.dialog) {
is UpcomingViewModel.Dialog.FilterSheet -> {
UpcomingFilterDialog(
viewModel = viewModel,
)
}
null -> {}
}
UpcomingScreenContent(
state = state,
setSelectedYearMonth = viewModel::setSelectedYearMonth,
onClickUpcoming = { navigator.push(MangaScreen(it.id)) },
hasActiveFilters = state.hasActiveFilters,
onClickFilter = viewModel::showFilterDialog,
)
}
}
@Composable
private fun ColumnScope.CategoryFilterSheet(
viewModel: UpcomingViewModel,
) {
Text(
stringResource(MR.strings.pref_filter_upcoming_categories_details),
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = SettingsItemsPaddings.Horizontal,
vertical = SettingsItemsPaddings.Vertical,
),
)
HorizontalDivider(modifier = Modifier.padding(MaterialTheme.padding.extraSmall))
val allCategories by viewModel.getCategories.subscribe().collectAsState(initial = emptyList())
if (allCategories.isEmpty()) {
LoadingScreen(modifier = Modifier.padding(16.dp))
return
}
val excluded by viewModel.upcomingPreferences.filterExcludedCategories.collectAsState()
val included by viewModel.upcomingPreferences.filterIncludedCategories.collectAsState()
val selected = remember {
allCategories.map { category ->
when (category.id) {
in included -> TriState.ENABLED_IS
in excluded -> TriState.ENABLED_NOT
else -> TriState.DISABLED
}
}.toMutableStateList()
}
Column {
allCategories.fastForEachIndexed { idx, category ->
val state = selected[idx]
TriStateItem(
label = category.visualName,
state = state,
onClick = {
selected[idx] = state.next()
viewModel.cycleCategory(category)
},
)
}
}
}
@Composable
fun UpcomingFilterDialog(
viewModel: UpcomingViewModel,
) {
TabbedDialog(
onDismissRequest = viewModel::resetDialog,
tabTitles = listOf(
stringResource(MR.strings.categories),
),
) {
Column(
modifier = Modifier
.padding(vertical = TabbedDialogPaddings.Vertical)
.verticalScroll(rememberScrollState()),
) {
CategoryFilterSheet(viewModel = viewModel)
}
}
}
@@ -9,9 +9,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,9 +23,12 @@ import androidx.compose.ui.text.font.FontWeight
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.relativeDateText
import eu.kanade.presentation.util.isTabletUi
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import mihon.feature.upcoming.components.UpcomingItem
import mihon.feature.upcoming.components.calendar.Calendar
import tachiyomi.core.common.Constants
@@ -36,14 +39,15 @@ import tachiyomi.presentation.core.components.TwoPanelBox
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import java.time.LocalDate
import java.time.YearMonth
import tachiyomi.presentation.core.theme.active
@Composable
fun UpcomingScreenContent(
state: UpcomingViewModel.State,
setSelectedYearMonth: (YearMonth) -> Unit,
onClickUpcoming: (manga: Manga) -> Unit,
onClickFilter: () -> Unit,
hasActiveFilters: Boolean,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
@@ -56,7 +60,12 @@ fun UpcomingScreenContent(
}
}
Scaffold(
topBar = { UpcomingToolbar() },
topBar = {
UpcomingToolbar(
hasFilters = hasActiveFilters,
onClickFilter = onClickFilter,
)
},
modifier = modifier,
) { paddingValues ->
if (isTabletUi()) {
@@ -86,7 +95,10 @@ fun UpcomingScreenContent(
}
@Composable
private fun UpcomingToolbar() {
private fun UpcomingToolbar(
hasFilters: Boolean,
onClickFilter: () -> Unit,
) {
val navigator = LocalNavigator.currentOrThrow
val uriHandler = LocalUriHandler.current
@@ -94,12 +106,21 @@ private fun UpcomingToolbar() {
title = stringResource(MR.strings.label_upcoming),
navigateUp = navigator::pop,
actions = {
IconButton(onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.HelpOutline,
contentDescription = stringResource(MR.strings.upcoming_guide),
)
}
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(MR.strings.action_filter),
icon = Icons.Outlined.FilterList,
iconTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current,
onClick = onClickFilter,
),
AppBar.Action(
title = stringResource(MR.strings.upcoming_guide),
icon = Icons.AutoMirrored.Outlined.HelpOutline,
onClick = { uriHandler.openUri(Constants.URL_HELP_UPCOMING) },
),
),
)
},
)
}
@@ -171,6 +192,7 @@ private fun UpcomingScreenSmallImpl(
onClick = { onClickUpcoming(item.manga) },
)
}
is UpcomingUIModel.Header -> {
DateHeading(
date = item.date,
@@ -222,6 +244,7 @@ private fun UpcomingScreenLargeImpl(
onClick = { onClickUpcoming(item.manga) },
)
}
is UpcomingUIModel.Header -> {
DateHeading(
date = item.date,
@@ -1,7 +1,7 @@
package mihon.feature.upcoming
import kotlinx.datetime.LocalDate
import tachiyomi.domain.manga.model.Manga
import java.time.LocalDate
sealed interface UpcomingUIModel {
data class Header(val date: LocalDate, val mangaCount: Int) : UpcomingUIModel
@@ -1,37 +1,63 @@
package mihon.feature.upcoming
import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMapIndexedNotNull
import androidx.lifecycle.viewModelScope
import eu.kanade.core.util.insertSeparatorsReversed
import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth
import mihon.core.viewmodel.StateViewModel
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.core.common.preference.getAndSet
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.upcoming.service.UpcomingPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.LocalDate
import java.time.YearMonth
import kotlin.time.Clock
class UpcomingViewModel(
private val getUpcomingManga: GetUpcomingManga = Injekt.get(),
val getCategories: GetCategories = Injekt.get(),
val upcomingPreferences: UpcomingPreferences = Injekt.get(),
) : StateViewModel<UpcomingViewModel.State>(State()) {
val excludedCategories = upcomingPreferences.filterExcludedCategories
val includedCategories = upcomingPreferences.filterIncludedCategories
init {
viewModelScope.launch {
getUpcomingManga.subscribe().collectLatest {
mutableState.update { state ->
val upcomingItems = it.toUpcomingUIModels()
state.copy(
items = upcomingItems,
events = upcomingItems.toEvents(),
headerIndexes = upcomingItems.getHeaderIndexes(),
viewModelScope.launchIO {
getUpcomingItemPreferenceFlow()
.distinctUntilChanged()
.flatMapLatest {
getUpcomingManga.subscribe(
excludedCategories = it.filterExcludedCategories,
includedCategories = it.filterIncludedCategories,
)
.distinctUntilChanged()
}
.collectLatest {
mutableState.update { state ->
val upcomingItems = it.toUpcomingUIModels()
state.copy(
items = upcomingItems,
events = upcomingItems.toEvents(),
headerIndexes = upcomingItems.getHeaderIndexes(),
)
}
}
}
}
}
@@ -41,8 +67,14 @@ class UpcomingViewModel(
.insertSeparatorsReversed { before, after ->
if (after != null) mangaCount++
val beforeDate = before?.manga?.expectedNextUpdate?.toLocalDate()
val afterDate = after?.manga?.expectedNextUpdate?.toLocalDate()
val beforeDate = before?.manga
?.expectedNextUpdate
?.toLocalDateTime(TimeZone.currentSystemDefault())
?.date
val afterDate = after?.manga
?.expectedNextUpdate
?.toLocalDateTime(TimeZone.currentSystemDefault())
?.date
if (beforeDate != afterDate && afterDate != null) {
UpcomingUIModel.Header(afterDate, mangaCount).also { mangaCount = 0 }
@@ -72,10 +104,57 @@ class UpcomingViewModel(
mutableState.update { it.copy(selectedYearMonth = yearMonth) }
}
private fun getUpcomingItemPreferenceFlow(): Flow<ItemPreferences> {
return combine(
upcomingPreferences.filterExcludedCategories.changes(),
upcomingPreferences.filterIncludedCategories.changes(),
) { excluded, included ->
ItemPreferences(
filterExcludedCategories = excluded,
filterIncludedCategories = included,
)
}
}
fun resetDialog() {
mutableState.update { it.copy(dialog = null) }
}
fun showFilterDialog() {
mutableState.update { it.copy(dialog = Dialog.FilterSheet) }
}
fun cycleCategory(category: Category) {
when (category.id) {
in includedCategories.get() -> {
includedCategories.getAndSet { it - category.id }
excludedCategories.getAndSet { it + category.id }
}
in excludedCategories.get() -> excludedCategories.getAndSet { it - category.id }
else -> includedCategories.getAndSet { it + category.id }
}
}
@Immutable
private data class ItemPreferences(
val filterExcludedCategories: List<Long>,
val filterIncludedCategories: List<Long>,
)
data class State(
val selectedYearMonth: YearMonth = YearMonth.now(),
val selectedYearMonth: YearMonth = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
.yearMonth,
val items: List<UpcomingUIModel> = listOf(),
val events: Map<LocalDate, Int> = mapOf(),
val headerIndexes: Map<LocalDate, Int> = mapOf(),
val hasActiveFilters: Boolean = false,
val dialog: Dialog? = null,
)
sealed interface Dialog {
data object FilterSheet : Dialog
}
}
@@ -19,12 +19,15 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import io.woong.compose.grid.SimpleGridCells
import io.woong.compose.grid.VerticalGrid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minusMonth
import kotlinx.datetime.plusMonth
import kotlinx.datetime.toJavaDayOfWeek
import mihon.core.designsystem.utils.isExpandedWidthWindow
import mihon.core.designsystem.utils.isMediumWidthWindow
import tachiyomi.presentation.core.components.material.padding
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle
import java.time.temporal.WeekFields
import java.util.Locale
@@ -47,8 +50,8 @@ fun Calendar(
) {
CalenderHeader(
yearMonth = selectedYearMonth,
onPreviousClick = { setSelectedYearMonth(selectedYearMonth.minusMonths(1L)) },
onNextClick = { setSelectedYearMonth(selectedYearMonth.plusMonths(1L)) },
onPreviousClick = { setSelectedYearMonth(selectedYearMonth.minusMonth()) },
onNextClick = { setSelectedYearMonth(selectedYearMonth.plusMonth()) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.padding.small)
@@ -74,8 +77,8 @@ private fun CalendarGrid(
.map { DayOfWeek.of((localeFirstDayOfWeek - 1 + it) % DAYS_OF_WEEK + 1) }
}
val emptyFieldCount = weekDays.indexOf(selectedYearMonth.atDay(1).dayOfWeek)
val daysInMonth = selectedYearMonth.lengthOfMonth()
val emptyFieldCount = weekDays.indexOf(selectedYearMonth.firstDay.dayOfWeek.toJavaDayOfWeek())
val daysInMonth = selectedYearMonth.numberOfDays
VerticalGrid(
columns = SimpleGridCells.Fixed(DAYS_OF_WEEK),
@@ -98,7 +101,7 @@ private fun CalendarGrid(
}
repeat(emptyFieldCount) { Box { } }
repeat(daysInMonth) { dayIndex ->
val localDate = selectedYearMonth.atDay(dayIndex + 1)
val localDate = LocalDate(selectedYearMonth.year, selectedYearMonth.month, dayIndex + 1)
CalendarDay(
date = localDate,
onDayClick = { onClickDay(localDate) },
@@ -19,8 +19,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import tachiyomi.presentation.core.components.material.DISABLED_ALPHA
import java.time.LocalDate
import kotlin.time.Clock
private const val MAX_EVENTS = 3
@@ -31,7 +34,7 @@ fun CalendarDay(
onDayClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val today = remember { LocalDate.now() }
val today = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date }
Box(
modifier = modifier
@@ -54,10 +57,10 @@ fun CalendarDay(
contentAlignment = Alignment.Center,
) {
Text(
text = date.dayOfMonth.toString(),
text = date.day.toString(),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = if (date.isBefore(today)) {
color = if (date < today) {
MaterialTheme.colorScheme.onBackground.copy(alpha = DISABLED_ALPHA)
} else {
MaterialTheme.colorScheme.onBackground
@@ -24,11 +24,16 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaYearMonth
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.time.Clock
@Composable
fun CalenderHeader(
@@ -88,14 +93,14 @@ private fun AnimatedContentTransitionScope<YearMonth>.getAnimation(): ContentTra
@ReadOnlyComposable
private fun getTitleText(monthYear: YearMonth): String {
val formatter = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.getDefault())
return formatter.format(monthYear)
return formatter.format(monthYear.toJavaYearMonth())
}
@Preview
@Composable
private fun CalenderHeaderPreview() {
CalenderHeader(
yearMonth = YearMonth.now(),
yearMonth = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.yearMonth,
onNextClick = {},
onPreviousClick = {},
)
+1
View File
@@ -41,6 +41,7 @@ dependencies {
api(libs.kotlinx.serialization.jsonOkio)
api(libs.androidx.preference)
implementation(libs.androidx.webkit)
implementation(libs.jsoup)
@@ -8,7 +8,8 @@ import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
class NetworkHelper(
private val context: Context,
@@ -20,9 +21,9 @@ class NetworkHelper(
private val clientBuilder: OkHttpClient.Builder = run {
val builder = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.connectTimeout(30.seconds)
.readTimeout(30.seconds)
.callTimeout(2.minutes)
.cache(
Cache(
directory = File(context.cacheDir, "network_cache"),
@@ -14,6 +14,6 @@ class NetworkPreferences(
val defaultUserAgent: Preference<String> = preferenceStore.getString(
"default_user_agent",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36",
)
}
@@ -9,9 +9,9 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES
import kotlin.time.Duration.Companion.minutes
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10.minutes).build()
private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
@@ -8,6 +8,7 @@ import android.widget.Toast
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Headers
import okhttp3.Interceptor
@@ -84,7 +85,7 @@ abstract class WebViewInterceptor(
return WebView(context).apply {
setDefaultSettings()
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
settings.userAgentString = request.header("User-Agent") ?: defaultUserAgentProvider()
setUserAgent(request.header("User-Agent") ?: defaultUserAgentProvider())
}
}
}
@@ -6,6 +6,9 @@ import android.content.pm.PackageManager
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.webkit.UserAgentMetadata
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
@@ -99,6 +102,53 @@ fun WebView.setDefaultSettings() {
CookieManager.getInstance().acceptThirdPartyCookies(this)
}
/**
* Sets the user agent along with the matching user agent metadata, which Chromium uses to populate
* the `Sec-CH-UA` client hints. Without this the hints keep advertising the real WebView brand and
* version, contradicting the spoofed user agent.
*/
fun WebView.setUserAgent(userAgent: String) {
settings.userAgentString = userAgent
if (!WebViewFeature.isFeatureSupported(WebViewFeature.USER_AGENT_METADATA)) return
val versionMatch = CHROME_VERSION_REGEX.find(userAgent) ?: return
val majorVersion = versionMatch.groupValues[1]
val fullVersion = majorVersion + versionMatch.groupValues[2].ifEmpty { ".0.0.0" }
try {
val metadata = WebSettingsCompat.getUserAgentMetadata(settings)
val brandVersionList = metadata.brandVersionList.map { brandVersion ->
val brand = when (brandVersion.brand) {
WEBVIEW_BRAND -> CHROME_BRAND
CHROMIUM_BRAND -> CHROMIUM_BRAND
else -> return@map brandVersion
}
UserAgentMetadata.BrandVersion.Builder()
.setBrand(brand)
.setMajorVersion(majorVersion)
.setFullVersion(fullVersion)
.build()
}
WebSettingsCompat.setUserAgentMetadata(
settings,
UserAgentMetadata.Builder(metadata)
.setBrandVersionList(brandVersionList)
.setFullVersion(fullVersion)
.build(),
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to set user agent metadata" }
}
}
private const val WEBVIEW_BRAND = "Android WebView"
private const val CHROMIUM_BRAND = "Chromium"
private const val CHROME_BRAND = "Google Chrome"
private val CHROME_VERSION_REGEX = """Chrome/(\d+)(\.[\d.]+)?""".toRegex()
private fun WebView.getWebViewMajorVersion(): Int {
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
+2
View File
@@ -38,5 +38,7 @@ dependencies {
implementation(libs.injekt)
implementation(libs.kotlinx.datetime)
api(libs.bundles.sqldelight)
}
@@ -4,6 +4,9 @@ import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOne
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.Database
@@ -18,8 +21,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.model.MangaWithChapterCount
import tachiyomi.domain.manga.repository.MangaRepository
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Clock
class MangaRepositoryImpl(
private val database: Database,
@@ -85,10 +87,24 @@ class MangaRepositoryImpl(
.awaitAsList()
}
override suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>> {
val epochMillis = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000
override suspend fun getUpcomingManga(
statuses: Set<Long>,
excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>> {
val timeZone = TimeZone.currentSystemDefault()
val epochMillis =
Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds()
return database.mangasQueries
.getUpcomingManga(epochMillis, statuses, MangaMapper::mapManga)
.getUpcomingManga(
startOfDay = epochMillis,
statuses = statuses,
includedEmpty = includedCategories.isEmpty(),
includedCategories = includedCategories,
excludedEmpty = excludedCategories.isEmpty(),
excludedCategories = excludedCategories,
mapper = MangaMapper::mapManga,
)
.subscribeToList()
}
@@ -35,6 +35,8 @@ class UpdatesRepositoryImpl(
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> {
return database.updatesViewQueries
.getRecentUpdatesWithFilters(
@@ -44,6 +46,10 @@ class UpdatesRepositoryImpl(
started = started?.toLong(),
bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators.toLong(),
includedEmpty = includedCategories.isEmpty(),
excludedEmpty = excludedCategories.isEmpty(),
includedCategories = includedCategories,
excludedCategories = excludedCategories,
mapper = ::mapUpdatesWithRelations,
)
.subscribeToList()
@@ -164,6 +164,33 @@ FROM mangas
WHERE next_update >= :startOfDay
AND favorite = 1
AND status IN :statuses
AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangas._id
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id)
)
)
AND (
-- excludedEmpty being true expresses "don't care" state and bypasses the membership filter
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangas._id
AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id)
)
)
)
ORDER BY next_update ASC;
resetViewerFlags:
@@ -46,6 +46,33 @@ AND (:bookmarked IS NULL OR bookmark = :bookmarked)
AND (
(excludedScanlator IS NULL OR :hideExcludedScanlators = 0)
)
AND (
-- includedEmpty being true expresses "don't care" state and bypasses the membership filter
:includedEmpty
OR EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories
)
OR (0 IN :includedCategories AND NOT EXISTS (
SELECT 1 FROM mangas_categories mc WHERE mc.manga_id = mangaId)
)
)
AND (
-- excludedEmpty being true expresses "don't care" state and bypasses the membership filter
:excludedEmpty
OR (
NOT EXISTS (
SELECT 1 FROM mangas_categories
WHERE mangas_categories.manga_id = mangaId
AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories
)
AND (
0 NOT IN :excludedCategories
OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangaId)
)
)
)
LIMIT :limit;
getUpdatesByReadStatus:
+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)
@@ -14,7 +14,14 @@ class GetUpcomingManga(
SManga.PUBLISHING_FINISHED.toLong(),
)
suspend fun subscribe(): Flow<List<Manga>> {
return mangaRepository.getUpcomingManga(includedStatuses)
suspend fun subscribe(
excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>> {
return mangaRepository.getUpcomingManga(
includedStatuses,
excludedCategories = excludedCategories,
includedCategories = includedCategories,
)
}
}
@@ -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
@@ -28,7 +28,11 @@ interface MangaRepository {
suspend fun getDuplicateLibraryManga(id: Long, title: String): List<MangaWithChapterCount>
suspend fun getUpcomingManga(statuses: Set<Long>): Flow<List<Manga>>
suspend fun getUpcomingManga(
statuses: Set<Long>,
excludedCategories: List<Long>,
includedCategories: List<Long>,
): Flow<List<Manga>>
suspend fun resetViewerFlags(): Boolean
@@ -0,0 +1,20 @@
package tachiyomi.domain.upcoming.service
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getLongArray
class UpcomingPreferences(
preferenceStore: PreferenceStore,
) {
val filterIncludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_upcoming_included_categories",
emptyList(),
)
val filterExcludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_upcoming_excluded_categories",
emptyList(),
)
}
@@ -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,
@@ -19,14 +19,18 @@ class GetUpdates(
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>> {
return repository.subscribeAll(
instant.toEpochMilli(),
instant.toEpochMilliseconds(),
limit = 500,
unread = unread,
started = started,
bookmarked = bookmarked,
hideExcludedScanlators = hideExcludedScanlators,
includedCategories = includedCategories,
excludedCategories = excludedCategories,
)
}
@@ -14,6 +14,8 @@ interface UpdatesRepository {
started: Boolean?,
bookmarked: Boolean?,
hideExcludedScanlators: Boolean,
includedCategories: List<Long>,
excludedCategories: List<Long>,
): Flow<List<UpdatesWithRelations>>
fun subscribeWithRead(read: Boolean, after: Long, limit: Long): Flow<List<UpdatesWithRelations>>
@@ -4,6 +4,7 @@ import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getLongArray
class UpdatesPreferences(
preferenceStore: PreferenceStore,
@@ -33,4 +34,14 @@ class UpdatesPreferences(
"pref_filter_updates_hide_excluded_scanlators",
false,
)
val filterIncludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_updates_included_categories",
emptyList(),
)
val filterExcludedCategories: Preference<List<Long>> = preferenceStore.getLongArray(
"pref_filter_updates_excluded_categories",
emptyList(),
)
}
@@ -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)
}
@@ -1,10 +1,9 @@
package mihon.gradle
import org.gradle.api.Project
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Instant
// Git is needed in your system PATH for these commands to work.
// If it's not installed, you can return a random value as a workaround
@@ -18,19 +17,18 @@ fun Project.getLatestCommitSha(): String {
// return "1"
}
private val BUILD_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
/**
* @param useLatestCommitTime If `true`, the build time is based on the timestamp of the last Git commit;
* otherwise, the current time is used. Both are in UTC.
* @return A formatted string representing the build time. The format used is defined by [BUILD_TIME_FORMATTER].
* @return An ISO 8601 formatted string representing the build time.
*/
fun Project.getBuildTime(useLatestCommitTime: Boolean): String {
return if (useLatestCommitTime) {
val epoch = exec("git log -1 --format=%ct").toLong()
Instant.ofEpochSecond(epoch).atOffset(ZoneOffset.UTC).format(BUILD_TIME_FORMATTER)
Instant.fromEpochSeconds(epoch).toString()
} else {
LocalDateTime.now(ZoneOffset.UTC).format(BUILD_TIME_FORMATTER)
val now = Clock.System.now()
(now - now.nanosecondsOfSecond.nanoseconds).toString()
}
}
+4
View File
@@ -24,6 +24,7 @@ androidx-test-espresso = "3.7.0"
androidx-test-junit = "1.3.0"
androidx-test-uiautomator = "2.4.0"
androidx-viewPager = "1.1.0"
androidx-webkit = "1.16.0"
androidx-work = "2.11.2"
archive = "1.1.6"
coil = "3.5.0"
@@ -45,6 +46,7 @@ junit = "6.1.2"
kotest-assertions = "6.2.3"
kotlin-gradle = "2.4.10"
kotlinx-coroutines = "1.11.0"
kotlinx-datetime = "0.8.0"
kotlinx-serialization = "1.11.0"
ktlint = "1.8.0"
leakCanary = "2.14"
@@ -112,6 +114,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-test-uiautomator" }
androidx-viewPager = { module = "androidx.viewpager:viewpager", version.ref = "androidx-viewPager" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
archive = { module = "me.zhanghai.android.libarchive:library", version.ref = "archive" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
@@ -142,6 +145,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-serialization-jsonOkio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization" }
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" }
@@ -861,11 +861,13 @@
<string name="relative_time_span_never">Never</string>
<string name="action_view_upcoming">View Upcoming Updates</string>
<string name="action_filter_excluded_scanlators">Filter excluded scanlators</string>
<string name="pref_filter_update_categories_details">Updates in excluded categories will not be shown even if they are also in any included categories.</string>
<!-- Upcoming -->
<string name="upcoming_guide">Upcoming Guide</string>
<string name="upcoming_calendar_next">Next Month</string>
<string name="upcoming_calendar_prev">Previous Month</string>
<string name="pref_filter_upcoming_categories_details">Titles in excluded categories are not shown, even if they are also in any of the included categories.</string>
<!-- History -->
<string name="recent_manga_time">Ch. %1$s - %2$s</string>
+2
View File
@@ -18,6 +18,8 @@ dependencies {
implementation(libs.androidx.glance.appWidget)
implementation(libs.material)
implementation(libs.kotlinx.datetime)
implementation(libs.coil.core)
api(libs.injekt)
@@ -34,6 +34,9 @@ import coil3.transform.RoundedCornersTransformation
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.updates.interactor.GetUpdates
@@ -46,8 +49,8 @@ import tachiyomi.presentation.widget.util.appWidgetBackgroundRadius
import tachiyomi.presentation.widget.util.calculateRowAndColumnCount
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.ZonedDateTime
import kotlin.time.Clock
import kotlin.time.Instant
abstract class BaseUpdatesGridGlanceWidget(
private val context: Context = Injekt.get<Application>(),
@@ -90,7 +93,7 @@ abstract class BaseUpdatesGridGlanceWidget(
val flow = remember {
getUpdates
.subscribe(false, DateLimit.toEpochMilli())
.subscribe(false, DateLimit.toEpochMilliseconds())
.map { rawData ->
rawData.prepareData(rowCount, columnCount)
}
@@ -153,6 +156,6 @@ abstract class BaseUpdatesGridGlanceWidget(
companion object {
val DateLimit: Instant
get() = ZonedDateTime.now().minusMonths(3).toInstant()
get() = Clock.System.now().minus(3, DateTimeUnit.MONTH, TimeZone.currentSystemDefault())
}
}
@@ -21,7 +21,7 @@ class WidgetManager(
fun Context.init(scope: LifecycleCoroutineScope) {
combine(
getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit.toEpochMilli()),
getUpdates.subscribe(read = false, after = BaseUpdatesGridGlanceWidget.DateLimit.toEpochMilliseconds()),
securityPreferences.useAuthenticator.changes(),
transform = { a, b -> a to b },
)