chore: merge upstream.

Signed-off-by: KaiserBh <kaiserbh@proton.me>
This commit is contained in:
KaiserBh 2023-10-11 21:44:54 +11:00
commit ef6da09b7e
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
312 changed files with 4467 additions and 3171 deletions

View File

@ -3,5 +3,5 @@ indent_size=4
insert_final_newline=true
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ij_kotlin_name_count_to_use_star_import=2147483647
ij_kotlin_name_count_to_use_star_import_for_members=2147483647

View File

@ -5,7 +5,7 @@ I acknowledge that:
- I have updated:
- To the latest version of the app (stable is v0.14.6)
- All extensions
- I have gone through the FAQ (https://tachiyomi.org/help/faq/) and troubleshooting guide (https://tachiyomi.org/help/guides/troubleshooting/)
- I have gone through the FAQ (https://tachiyomi.org/docs/faq/general) and troubleshooting guide (https://tachiyomi.org/docs/guides/troubleshooting/)
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
- I will fill out the title and the information in this template

View File

@ -4,8 +4,8 @@ contact_links:
url: https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose
about: Issues and requests for extensions and sources should be opened in the tachiyomi-extensions repository instead
- name: 📦 Tachiyomi extensions
url: https://tachiyomi.org/extensions
url: https://tachiyomi.org/extensions/
about: List of all available extensions with download links
- name: 🖥️ Tachiyomi website
url: https://tachiyomi.org/help/
url: https://tachiyomi.org/
about: Guides, troubleshooting, and answers to common questions

View File

@ -96,7 +96,7 @@ body:
required: true
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
required: true
- label: I have gone through the [FAQ](https://tachiyomi.org/help/faq/) and [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
- label: I have gone through the [FAQ](https://tachiyomi.org/docs/faq/general) and [troubleshooting guide](https://tachiyomi.org/docs/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.14.6](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
required: true

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Clone repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
@ -36,4 +36,4 @@ jobs:
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: lintKotlin assembleStandardRelease testReleaseUnitTest
arguments: ktlintCheck assembleStandardRelease testReleaseUnitTest

View File

@ -17,7 +17,7 @@ jobs:
steps:
- name: Clone repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
@ -31,7 +31,7 @@ jobs:
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: lintKotlin assembleStandardRelease testReleaseUnitTest
arguments: ktlintCheck assembleStandardRelease testReleaseUnitTest
# Sign APK and create release for tags
@ -104,3 +104,13 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-website:
needs: [build]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
steps:
- name: Trigger Netlify build hook
run: curl -s -X POST -d {} "https://api.netlify.com/build_hooks/${TOKEN}"
env:
TOKEN: ${{ secrets.NETLIFY_HOOK_RELEASE }}

View File

@ -39,7 +39,7 @@ jobs:
"regex": ".*(?:fail(?:ed|ure|s)?|can\\s*(?:no|')?t|(?:not|un).*able|(?<!n[o']?t )blocked by|error) (?:to )?(?:get past|by ?pass|penetrate)?.*cloud ?fl?are.*",
"ignoreCase": true,
"labels": ["Cloudflare protected"],
"message": "Refer to the **Solving Cloudflare issues** section at https://tachiyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues. If it doesn't work, migrate to other sources or wait until they lower their protection."
"message": "Refer to the **Solving Cloudflare issues** section at https://tachiyomi.org/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
}
]
auto-close-ignore-label: do-not-autoclose

3
.gitignore vendored
View File

@ -2,7 +2,8 @@
/local.properties
/.idea/workspace.xml
.DS_Store
.idea/
.idea/*
!.idea/icon.png
*iml
*.iml

BIN
.idea/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -30,7 +30,7 @@ Before you start, please note that the ability to use following technologies is
# Translations
Translations are done externally via Weblate. See [our website](https://tachiyomi.org/help/contribution/#translation) for more details.
Translations are done externally via Weblate. See [our website](https://tachiyomi.org/docs/contribute#translation) for more details.
# Forks

View File

@ -29,7 +29,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
<details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/tachiyomiorg/tachiyomi/releases) and the already opened [issues](https://github.com/tachiyomiorg/tachiyomi/issues).**
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/docs/faq/general), the [changelog](https://tachiyomi.org/changelogs/) and the already opened [issues](https://github.com/tachiyomiorg/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details>

View File

@ -1,5 +1,4 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.LintTask
plugins {
id("com.android.application")
@ -23,7 +22,7 @@ android {
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
versionCode = 105
versionCode = 107
versionName = "0.14.6"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
@ -104,15 +103,17 @@ android {
}
packaging {
resources.excludes.addAll(listOf(
"META-INF/DEPENDENCIES",
"LICENSE.txt",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/README.md",
"META-INF/NOTICE",
"META-INF/*.kotlin_module",
))
resources.excludes.addAll(
listOf(
"META-INF/DEPENDENCIES",
"LICENSE.txt",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/README.md",
"META-INF/NOTICE",
"META-INF/*.kotlin_module",
),
)
}
dependenciesInfo {
@ -239,7 +240,6 @@ dependencies {
implementation(libs.aboutLibraries.compose)
implementation(libs.bundles.voyager)
implementation(libs.compose.materialmotion)
implementation(libs.compose.simpleicons)
implementation(libs.swipe)
// Logging
@ -267,7 +267,9 @@ androidComponents {
beforeVariants { variantBuilder ->
// Disables standardBenchmark
if (variantBuilder.buildType == "benchmark") {
variantBuilder.enable = variantBuilder.productFlavors.containsAll(listOf("default" to "dev"))
variantBuilder.enable = variantBuilder.productFlavors.containsAll(
listOf("default" to "dev"),
)
}
}
onVariants(selector().withFlavor("default" to "standard")) {
@ -278,10 +280,6 @@ androidComponents {
}
tasks {
withType<LintTask>().configureEach {
exclude { it.file.path.contains("generated[\\\\/]".toRegex()) }
}
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
withType<KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
@ -306,12 +304,12 @@ tasks {
kotlinOptions.freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
project.buildDir.absolutePath + "/compose_metrics",
)
kotlinOptions.freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
project.buildDir.absolutePath + "/compose_metrics",
)
}
}

View File

@ -155,20 +155,6 @@
android:name=".data.notification.NotificationReceiver"
android:exported="false" />
<receiver
android:name="tachiyomi.presentation.widget.UpdatesGridGlanceReceiver"
android:enabled="@bool/glance_appwidget_available"
android:exported="false"
android:label="@string/label_recent_updates">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/updates_grid_glance_widget_info" />
</receiver>
<service
android:name=".data.download.DownloadService"
android:exported="false" />

View File

@ -1,7 +1,6 @@
package eu.kanade.domain
import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
@ -16,7 +15,9 @@ import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.interactor.ToggleLanguage
import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl
@ -50,6 +51,7 @@ import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.history.interactor.RemoveHistory
import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.interactor.GetLibraryManga
@ -57,7 +59,6 @@ import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMangaWithChapters
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.interactor.ResetViewerFlags
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.release.interactor.GetApplicationRelease
@ -102,7 +103,7 @@ class DomainModule : InjektModule {
addFactory { GetNextChapters(get(), get(), get()) }
addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { SetFetchInterval(get()) }
addFactory { FetchInterval(get()) }
addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) }
addFactory { SetMangaViewerFlags(get()) }
addFactory { NetworkToLocalManga(get()) }
@ -114,11 +115,13 @@ class DomainModule : InjektModule {
addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) }
addFactory { TrackChapter(get(), get(), get(), get()) }
addFactory { AddTracks(get(), get(), get()) }
addFactory { RefreshTracks(get(), get(), get(), get()) }
addFactory { DeleteTrack(get()) }
addFactory { GetTracksPerManga(get()) }
addFactory { GetTracks(get()) }
addFactory { InsertTrack(get()) }
addFactory { SyncChapterProgressWithTrack(get(), get(), get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
@ -127,7 +130,6 @@ class DomainModule : InjektModule {
addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) }
addFactory { SyncChapterProgressWithTrack(get(), get(), get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) }

View File

@ -3,7 +3,7 @@ package eu.kanade.domain.manga.interactor
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateManga(
private val mangaRepository: MangaRepository,
private val setFetchInterval: SetFetchInterval,
private val fetchInterval: FetchInterval,
) {
suspend fun await(mangaUpdate: MangaUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setFetchInterval.getWindow(dateTime),
window: Pair<Long, Long> = fetchInterval.getWindow(dateTime),
): Boolean {
return setFetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
return fetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
?.let { mangaRepository.update(it) }
?: false
}

View File

@ -0,0 +1,45 @@
package eu.kanade.domain.track.interactor
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.source.Source
import logcat.LogPriority
import tachiyomi.core.util.lang.withNonCancellableContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack
class AddTracks(
private val getTracks: GetTracks,
private val insertTrack: InsertTrack,
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
) {
suspend fun bindEnhancedTracks(manga: Manga, source: Source) = withNonCancellableContext {
getTracks.await(manga.id)
.filterIsInstance<EnhancedTracker>()
.filter { it.accept(source) }
.forEach { service ->
try {
service.match(manga)?.let { track ->
track.manga_id = manga.id
(service as Tracker).bind(track)
insertTrack.await(track.toDomainTrack()!!)
syncChapterProgressWithTrack.await(
manga.id,
track.toDomainTrack()!!,
service,
)
}
} catch (e: Exception) {
logcat(
LogPriority.WARN,
e,
) { "Could not match manga: ${manga.title} with service $service" }
}
}
}
}

View File

@ -1,10 +1,9 @@
package eu.kanade.domain.track.interactor
import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
@ -13,7 +12,7 @@ import tachiyomi.domain.track.interactor.InsertTrack
class RefreshTracks(
private val getTracks: GetTracks,
private val trackManager: TrackManager,
private val trackerManager: TrackerManager,
private val insertTrack: InsertTrack,
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
) {
@ -23,18 +22,17 @@ class RefreshTracks(
*
* @return Failed updates.
*/
suspend fun await(mangaId: Long): List<Pair<TrackService?, Throwable>> {
suspend fun await(mangaId: Long): List<Pair<Tracker?, Throwable>> {
return supervisorScope {
return@supervisorScope getTracks.await(mangaId)
.map { track ->
.map { it to trackerManager.get(it.syncId) }
.filter { (_, service) -> service?.isLoggedIn == true }
.map { (track, service) ->
async {
val service = trackManager.getService(track.syncId)
return@async try {
if (service?.isLoggedIn == true) {
val updatedTrack = service.refresh(track.toDbTrack())
insertTrack.await(updatedTrack.toDomainTrack()!!)
syncChapterProgressWithTrack.await(mangaId, track, service)
}
val updatedTrack = service!!.refresh(track.toDbTrack())
insertTrack.await(updatedTrack.toDomainTrack()!!)
syncChapterProgressWithTrack.await(mangaId, track, service)
null
} catch (e: Throwable) {
service to e

View File

@ -1,8 +1,8 @@
package eu.kanade.domain.chapter.interactor
package eu.kanade.domain.track.interactor
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import logcat.LogPriority
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChapterByMangaId
@ -20,9 +20,9 @@ class SyncChapterProgressWithTrack(
suspend fun await(
mangaId: Long,
remoteTrack: Track,
service: TrackService,
tracker: Tracker,
) {
if (service !is EnhancedTrackService) {
if (tracker !is EnhancedTracker) {
return
}
@ -39,7 +39,7 @@ class SyncChapterProgressWithTrack(
val updatedTrack = remoteTrack.copy(lastChapterRead = localLastRead.toDouble())
try {
service.update(updatedTrack.toDbTrack())
tracker.update(updatedTrack.toDbTrack())
updateChapter.awaitAll(chapterUpdates)
insertTrack.await(updatedTrack)
} catch (e: Throwable) {

View File

@ -4,30 +4,29 @@ import android.content.Context
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.service.DelayedTrackingUpdateJob
import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import logcat.LogPriority
import tachiyomi.core.util.lang.launchNonCancellable
import tachiyomi.core.util.lang.withNonCancellableContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack
class TrackChapter(
private val getTracks: GetTracks,
private val trackManager: TrackManager,
private val trackerManager: TrackerManager,
private val insertTrack: InsertTrack,
private val delayedTrackingStore: DelayedTrackingStore,
) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) = coroutineScope {
launchNonCancellable {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) {
withNonCancellableContext {
val tracks = getTracks.await(mangaId)
if (tracks.isEmpty()) return@launchNonCancellable
if (tracks.isEmpty()) return@withNonCancellableContext
tracks.mapNotNull { track ->
val service = trackManager.getService(track.syncId)
val service = trackerManager.get(track.syncId)
if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) {
return@mapNotNull null
}

View File

@ -10,7 +10,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.store.DelayedTrackingStore
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.util.system.workManager
import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext
@ -33,7 +33,7 @@ class DelayedTrackingUpdateJob(context: Context, workerParams: WorkerParameters)
val getTracks = Injekt.get<GetTracks>()
val insertTrack = Injekt.get<InsertTrack>()
val trackManager = Injekt.get<TrackManager>()
val trackerManager = Injekt.get<TrackerManager>()
val delayedTrackingStore = Injekt.get<DelayedTrackingStore>()
withIOContext {
@ -47,7 +47,7 @@ class DelayedTrackingUpdateJob(context: Context, workerParams: WorkerParameters)
}
.forEach { track ->
try {
val service = trackManager.getService(track.syncId)
val service = trackerManager.get(track.syncId)
if (service != null && service.isLoggedIn) {
logcat(LogPriority.DEBUG) { "Updating delayed track item: ${track.id}, last chapter read: ${track.lastChapterRead}" }
service.update(track.toDbTrack(), true)

View File

@ -1,33 +1,34 @@
package eu.kanade.domain.track.service
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
class TrackPreferences(
private val preferenceStore: PreferenceStore,
) {
fun trackUsername(sync: TrackService) = preferenceStore.getString(trackUsername(sync.id), "")
fun trackUsername(sync: Tracker) = preferenceStore.getString(trackUsername(sync.id), "")
fun trackPassword(sync: TrackService) = preferenceStore.getString(trackPassword(sync.id), "")
fun trackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "")
fun setTrackCredentials(sync: TrackService, username: String, password: String) {
fun setCredentials(sync: Tracker, username: String, password: String) {
trackUsername(sync).set(username)
trackPassword(sync).set(password)
}
fun trackToken(sync: TrackService) = preferenceStore.getString(trackToken(sync.id), "")
fun trackToken(sync: Tracker) = preferenceStore.getString(trackToken(sync.id), "")
fun anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
companion object {
fun trackUsername(syncId: Long) = "pref_mangasync_username_$syncId"
fun trackUsername(syncId: Long) = Preference.privateKey("pref_mangasync_username_$syncId")
private fun trackPassword(syncId: Long) = "pref_mangasync_password_$syncId"
private fun trackPassword(syncId: Long) = Preference.privateKey("pref_mangasync_password_$syncId")
private fun trackToken(syncId: Long) = "track_token_$syncId"
private fun trackToken(syncId: Long) = Preference.privateKey("track_token_$syncId")
}
}

View File

@ -28,6 +28,8 @@ class UiPreferences(
fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false)
fun relativeTime() = preferenceStore.getBoolean("relative_time_v2", true)
fun dateFormat() = preferenceStore.getString("app_date_format", "")
fun tabletUiMode() = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)

View File

@ -7,13 +7,22 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.category.components.CategoryListItem
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.category.CategoryScreenState
import tachiyomi.domain.category.model.Category
@ -27,6 +36,7 @@ import tachiyomi.presentation.core.util.plus
fun CategoryScreen(
state: CategoryScreenState.Success,
onClickCreate: () -> Unit,
onClickSortAlphabetically: () -> Unit,
onClickRename: (Category) -> Unit,
onClickDelete: (Category) -> Unit,
onClickMoveUp: (Category) -> Unit,
@ -36,9 +46,32 @@ fun CategoryScreen(
val lazyListState = rememberLazyListState()
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(R.string.action_edit_categories),
navigateUp = navigateUp,
TopAppBar(
title = {
Text(
text = stringResource(R.string.action_edit_categories),
modifier = Modifier.padding(start = 8.dp),
)
},
navigationIcon = {
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.Outlined.ArrowBack,
contentDescription = stringResource(R.string.abc_action_bar_up_description),
)
}
},
actions = {
AppBarActions(
listOf(
AppBar.Action(
title = stringResource(R.string.action_sort),
icon = Icons.Outlined.SortByAlpha,
onClick = onClickSortAlphabetically,
),
),
)
},
scrollBehavior = scrollBehavior,
)
},

View File

@ -162,7 +162,7 @@ fun CategoryDeleteDialog(
TextButton(onClick = {
onDelete()
onDismissRequest()
},) {
}) {
Text(text = stringResource(R.string.action_ok))
}
},
@ -180,6 +180,35 @@ fun CategoryDeleteDialog(
)
}
@Composable
fun CategorySortAlphabeticallyDialog(
onDismissRequest: () -> Unit,
onSort: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onSort()
onDismissRequest()
}) {
Text(text = stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
},
title = {
Text(text = stringResource(R.string.action_sort_category))
},
text = {
Text(text = stringResource(R.string.sort_category_confirmation))
},
)
}
@Composable
fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>,
@ -217,7 +246,7 @@ fun ChangeCategoryDialog(
tachiyomi.presentation.core.components.material.TextButton(onClick = {
onDismissRequest()
onEditCategories()
},) {
}) {
Text(text = stringResource(R.string.action_edit))
}
Spacer(modifier = Modifier.weight(1f))

View File

@ -13,13 +13,18 @@ import java.util.Date
fun RelativeDateHeader(
modifier: Modifier = Modifier,
date: Date,
relativeTime: Boolean,
dateFormat: DateFormat,
) {
val context = LocalContext.current
ListGroupHeader(
modifier = modifier,
text = remember {
date.toRelativeString(context, dateFormat)
date.toRelativeString(
context,
relativeTime,
dateFormat,
)
},
)
}

View File

@ -27,7 +27,6 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Date
@Composable
@ -98,7 +97,8 @@ private fun HistoryScreenContent(
onClickDelete: (HistoryWithRelations) -> Unit,
preferences: UiPreferences = Injekt.get(),
) {
val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) }
val relativeTime = remember { preferences.relativeTime().get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) }
FastScrollLazyColumn(
contentPadding = contentPadding,
@ -118,6 +118,7 @@ private fun HistoryScreenContent(
RelativeDateHeader(
modifier = Modifier.animateItemPlacement(),
date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat,
)
}

View File

@ -61,7 +61,7 @@ fun HistoryDeleteDialog(
TextButton(onClick = {
onDelete(removeEverything)
onDismissRequest()
},) {
}) {
Text(text = stringResource(R.string.action_remove))
}
},
@ -90,7 +90,7 @@ fun HistoryDeleteAllDialog(
TextButton(onClick = {
onDelete()
onDismissRequest()
},) {
}) {
Text(text = stringResource(R.string.action_ok))
}
},

View File

@ -108,13 +108,13 @@ private fun ColumnScope.FilterPage(
onClick = { screenModel.toggleFilter(LibraryPreferences::filterCompleted) },
)
val trackServices = remember { screenModel.trackServices }
when (trackServices.size) {
val trackers = remember { screenModel.trackers }
when (trackers.size) {
0 -> {
// No trackers
}
1 -> {
val service = trackServices[0]
val service = trackers[0]
val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
TriStateItem(
label = stringResource(R.string.action_filter_tracked),
@ -124,7 +124,7 @@ private fun ColumnScope.FilterPage(
}
else -> {
HeadingItem(R.string.action_filter_tracked)
trackServices.map { service ->
trackers.map { service ->
val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState()
TriStateItem(
label = service.name,

View File

@ -85,6 +85,7 @@ fun MangaScreen(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: Int?,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -140,6 +141,7 @@ fun MangaScreen(
MangaScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
fetchInterval = fetchInterval,
chapterSwipeStartAction = chapterSwipeStartAction,
@ -176,6 +178,7 @@ fun MangaScreen(
MangaScreenLargeImpl(
state = state,
snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
dateFormat = dateFormat,
@ -215,6 +218,7 @@ fun MangaScreen(
private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -264,8 +268,14 @@ private fun MangaScreenSmallImpl(
val chapters = remember(state) { state.processedChapters }
val isAnySelected by remember {
derivedStateOf {
chapters.fastAny { it.selected }
}
}
val internalOnBackPressed = {
if (chapters.fastAny { it.selected }) {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
@ -275,19 +285,22 @@ private fun MangaScreenSmallImpl(
Scaffold(
topBar = {
val firstVisibleItemIndex by remember {
derivedStateOf { chapterListState.firstVisibleItemIndex }
val selectedChapterCount: Int = remember(chapters) {
chapters.count { it.selected }
}
val firstVisibleItemScrollOffset by remember {
derivedStateOf { chapterListState.firstVisibleItemScrollOffset }
val isFirstItemVisible by remember {
derivedStateOf { chapterListState.firstVisibleItemIndex == 0 }
}
val isFirstItemScrolled by remember {
derivedStateOf { chapterListState.firstVisibleItemScrollOffset > 0 }
}
val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f,
label = "titleAlpha",
if (!isFirstItemVisible) 1f else 0f,
label = "Top Bar Title",
)
val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
label = "bgAlpha",
if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
label = "Top Bar Background",
)
MangaToolbar(
title = state.manga.title,
@ -301,14 +314,17 @@ private fun MangaScreenSmallImpl(
onClickEditCategory = onEditCategoryClicked,
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
actionModeCounter = chapters.count { it.selected },
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
)
},
bottomBar = {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
SharedMangaBottomActionMenu(
selected = chapters.filter { it.selected },
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -319,19 +335,20 @@ private fun MangaScreenSmallImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.chapters.fastAny { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start))
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
@ -345,7 +362,7 @@ private fun MangaScreenSmallImpl(
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(),
) {
val layoutDirection = LocalLayoutDirection.current
@ -417,10 +434,13 @@ private fun MangaScreenSmallImpl(
key = MangaScreenItem.CHAPTER_HEADER,
contentType = MangaScreenItem.CHAPTER_HEADER,
) {
val missingChapterCount = remember(chapters) {
chapters.map { it.chapter.chapterNumber }.missingChaptersCount()
}
ChapterHeader(
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
chapterCount = chapters.size,
missingChapterCount = chapters.map { it.chapter.chapterNumber }.missingChaptersCount(),
missingChapterCount = missingChapterCount,
onClick = onFilterClicked,
)
}
@ -428,6 +448,7 @@ private fun MangaScreenSmallImpl(
sharedChapterItems(
manga = state.manga,
chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
@ -446,6 +467,7 @@ private fun MangaScreenSmallImpl(
fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -496,12 +518,18 @@ fun MangaScreenLargeImpl(
val chapters = remember(state) { state.processedChapters }
val isAnySelected by remember {
derivedStateOf {
chapters.fastAny { it.selected }
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
var topBarHeight by remember { mutableIntStateOf(0) }
PullRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
@ -511,7 +539,7 @@ fun MangaScreenLargeImpl(
val chapterListState = rememberLazyListState()
val internalOnBackPressed = {
if (chapters.fastAny { it.selected }) {
if (isAnySelected) {
onAllChapterSelected(false)
} else {
onBackClicked()
@ -521,10 +549,13 @@ fun MangaScreenLargeImpl(
Scaffold(
topBar = {
val selectedChapterCount = remember(chapters) {
chapters.count { it.selected }
}
MangaToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = it.height },
title = state.manga.title,
titleAlphaProvider = { if (chapters.fastAny { it.selected }) 1f else 0f },
titleAlphaProvider = { if (isAnySelected) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.manga.chaptersFiltered(),
onBackClicked = internalOnBackPressed,
@ -534,7 +565,7 @@ fun MangaScreenLargeImpl(
onClickEditCategory = onEditCategoryClicked,
onClickRefresh = onRefresh,
onClickMigrate = onMigrateClicked,
actionModeCounter = chapters.count { it.selected },
actionModeCounter = selectedChapterCount,
onSelectAll = { onAllChapterSelected(true) },
onInvertSelection = { onInvertSelection() },
)
@ -544,8 +575,11 @@ fun MangaScreenLargeImpl(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
val selectedChapters = remember(chapters) {
chapters.filter { it.selected }
}
SharedMangaBottomActionMenu(
selected = chapters.filter { it.selected },
selected = selectedChapters,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
@ -557,19 +591,20 @@ fun MangaScreenLargeImpl(
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
val isFABVisible = remember(chapters) {
chapters.fastAny { !it.chapter.read } && !isAnySelected
}
AnimatedVisibility(
visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected },
visible = isFABVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
ExtendedFloatingActionButton(
text = {
val id = if (state.chapters.fastAny { it.chapter.read }) {
R.string.action_resume
} else {
R.string.action_start
val isReading = remember(state.chapters) {
state.chapters.fastAny { it.chapter.read }
}
Text(text = stringResource(id))
Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start))
},
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
@ -640,10 +675,13 @@ fun MangaScreenLargeImpl(
key = MangaScreenItem.CHAPTER_HEADER,
contentType = MangaScreenItem.CHAPTER_HEADER,
) {
val missingChapterCount = remember(chapters) {
chapters.map { it.chapter.chapterNumber }.missingChaptersCount()
}
ChapterHeader(
enabled = chapters.fastAll { !it.selected },
enabled = !isAnySelected,
chapterCount = chapters.size,
missingChapterCount = chapters.map { it.chapter.chapterNumber }.missingChaptersCount(),
missingChapterCount = missingChapterCount,
onClick = onFilterButtonClicked,
)
}
@ -651,6 +689,7 @@ fun MangaScreenLargeImpl(
sharedChapterItems(
manga = state.manga,
chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
@ -712,6 +751,7 @@ private fun SharedMangaBottomActionMenu(
private fun LazyListScope.sharedChapterItems(
manga: Manga,
chapters: List<ChapterItem>,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
@ -740,7 +780,11 @@ private fun LazyListScope.sharedChapterItems(
date = chapterItem.chapter.dateUpload
.takeIf { it > 0L }
?.let {
Date(it).toRelativeString(context, dateFormat)
Date(it).toRelativeString(
context,
dateRelativeTime,
dateFormat,
)
},
readProgress = chapterItem.chapter.lastPageRead
.takeIf { !chapterItem.chapter.read && it > 0L }

View File

@ -143,7 +143,7 @@ fun MangaBottomActionMenu(
if (onMarkPreviousAsReadClicked != null) {
Button(
title = stringResource(R.string.action_mark_previous_as_read),
icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp),
icon = ImageVector.vectorResource(R.drawable.ic_done_prev_24dp),
toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) },
onClick = onMarkPreviousAsReadClicked,

View File

@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import tachiyomi.domain.manga.interactor.MAX_FETCH_INTERVAL
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.presentation.core.components.WheelTextPicker
@Composable
@ -67,7 +67,7 @@ fun SetIntervalDialog(
contentAlignment = Alignment.Center,
) {
val size = DpSize(width = maxWidth / 2, height = 128.dp)
val items = (0..MAX_FETCH_INTERVAL).map {
val items = (0..FetchInterval.MAX_INTERVAL).map {
if (it == 0) {
stringResource(R.string.label_default)
} else {
@ -91,7 +91,7 @@ fun SetIntervalDialog(
TextButton(onClick = {
onValueChanged(selectedInterval)
onDismissRequest()
},) {
}) {
Text(text = stringResource(R.string.action_ok))
}
},

View File

@ -286,7 +286,7 @@ fun ExpandableMangaDescription(
) {
tags.forEach {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -302,7 +302,7 @@ fun ExpandableMangaDescription(
) {
items(items = tags) {
TagsChip(
modifier = Modifier.padding(vertical = 4.dp),
modifier = DefaultTagChipModifier,
text = it,
onClick = {
tagSelected = it
@ -654,6 +654,8 @@ private fun MangaSummary(
}
}
private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@Composable
private fun TagsChip(
text: String,

View File

@ -62,7 +62,7 @@ fun MoreScreen(
WarningBanner(
textRes = R.string.fdroid_warning,
modifier = Modifier.clickable {
uriHandler.openUri("https://tachiyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version")
uriHandler.openUri("https://tachiyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds")
},
)
}
@ -108,11 +108,11 @@ fun MoreScreen(
stringResource(R.string.paused)
} else {
"${stringResource(R.string.paused)}${
pluralStringResource(
id = R.plurals.download_queue_summary,
count = pending,
pending,
)
pluralStringResource(
id = R.plurals.download_queue_summary,
count = pending,
pending,
)
}"
}
}

View File

@ -4,9 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.more.settings.Preference.PreferenceItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import tachiyomi.core.preference.Preference as PreferenceData
sealed class Preference {
@ -133,10 +132,10 @@ sealed class Preference {
) : PreferenceItem<String>()
/**
* A [PreferenceItem] for individual tracking service.
* A [PreferenceItem] for individual tracker.
*/
data class TrackingPreference(
val service: TrackService,
data class TrackerPreference(
val tracker: Tracker,
override val title: String,
val login: () -> Unit,
val logout: () -> Unit,

View File

@ -156,13 +156,13 @@ internal fun PreferenceItem(
},
)
}
is Preference.PreferenceItem.TrackingPreference -> {
is Preference.PreferenceItem.TrackerPreference -> {
val uName by Injekt.get<PreferenceStore>()
.getString(TrackPreferences.trackUsername(item.service.id))
.getString(TrackPreferences.trackUsername(item.tracker.id))
.collectAsState()
item.service.run {
item.tracker.run {
TrackingPreferenceWidget(
service = this,
tracker = this,
checked = uName.isNotEmpty(),
onClick = { if (isLoggedIn) item.logout() else item.login() },
)

View File

@ -34,7 +34,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_360
@ -328,7 +328,7 @@ object SettingsAdvancedScreen : SearchableSettings {
private fun getLibraryGroup(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val trackManager = remember { Injekt.get<TrackManager>() }
val trackerManager = remember { Injekt.get<TrackerManager>() }
return Preference.PreferenceGroup(
title = stringResource(R.string.label_library),
@ -340,7 +340,7 @@ object SettingsAdvancedScreen : SearchableSettings {
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_refresh_library_tracking),
subtitle = stringResource(R.string.pref_refresh_library_tracking_summary),
enabled = trackManager.hasLoggedServices(),
enabled = trackerManager.hasLoggedIn(),
onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.TRACKING) },
),
Preference.PreferenceItem.TextPreference(

View File

@ -123,6 +123,11 @@ object SettingsAppearanceScreen : SearchableSettings {
var currentLanguage by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") }
val now = remember { Date().time }
val dateFormat by uiPreferences.dateFormat().collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
}
LaunchedEffect(currentLanguage) {
val locale = if (currentLanguage.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
@ -162,6 +167,15 @@ object SettingsAppearanceScreen : SearchableSettings {
"${it.ifEmpty { stringResource(R.string.label_default) }} ($formattedDate)"
},
),
Preference.PreferenceItem.SwitchPreference(
pref = uiPreferences.relativeTime(),
title = stringResource(R.string.pref_relative_format),
subtitle = stringResource(
R.string.pref_relative_format_summary,
stringResource(R.string.relative_time_today),
formattedNow,
),
),
),
)
}

View File

@ -211,7 +211,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
showCreateDialog = false
flag = it
try {
chooseBackupDir.launch(Backup.getBackupFilename())
chooseBackupDir.launch(Backup.getFilename())
} catch (e: ActivityNotFoundException) {
flag = 0
context.toast(R.string.file_picker_error)
@ -250,6 +250,8 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
BackupConst.BACKUP_CHAPTER to R.string.chapters,
BackupConst.BACKUP_TRACK to R.string.track,
BackupConst.BACKUP_HISTORY to R.string.history,
BackupConst.BACKUP_APP_PREFS to R.string.app_settings,
BackupConst.BACKUP_SOURCE_PREFS to R.string.source_settings,
)
}
val flags = remember { choices.keys.toMutableStateList() }

View File

@ -23,7 +23,7 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.widget.TriStateListDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.ui.category.CategoryScreen
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -199,7 +199,7 @@ object SettingsLibraryScreen : SearchableSettings {
),
Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.autoUpdateTrackers(),
enabled = Injekt.get<TrackManager>().hasLoggedServices(),
enabled = Injekt.get<TrackerManager>().hasLoggedIn(),
title = stringResource(R.string.pref_library_update_refresh_trackers),
subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary),
),

View File

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -305,12 +304,6 @@ object SettingsReaderScreen : SearchableSettings {
subtitle = stringResource(R.string.pref_dual_page_invert_summary),
enabled = dualPageSplit,
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.longStripSplitWebtoon(),
title = stringResource(R.string.pref_long_strip_split),
subtitle = stringResource(R.string.split_tall_images_summary),
enabled = !isReleaseBuildType, // TODO: Show in release build when the feature is stable
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonDoubleTapZoomEnabled(),
title = stringResource(R.string.pref_double_tap_zoom),
@ -349,11 +342,6 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPreferences.readWithLongTap(),
title = stringResource(R.string.pref_read_with_long_tap),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.folderPerManga(),
title = stringResource(R.string.pref_create_folder_per_manga),
subtitle = stringResource(R.string.pref_create_folder_per_manga_summary),
),
),
)
}

View File

@ -44,9 +44,9 @@ import androidx.compose.ui.unit.dp
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
@ -70,7 +70,7 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable
override fun RowScope.AppBarAction() {
val uriHandler = LocalUriHandler.current
IconButton(onClick = { uriHandler.openUri("https://tachiyomi.org/help/guides/tracking/") }) {
IconButton(onClick = { uriHandler.openUri("https://tachiyomi.org/docs/guides/tracking") }) {
Icon(
imageVector = Icons.Outlined.HelpOutline,
contentDescription = stringResource(R.string.tracking_guide),
@ -82,7 +82,7 @@ object SettingsTrackingScreen : SearchableSettings {
override fun getPreferences(): List<Preference> {
val context = LocalContext.current
val trackPreferences = remember { Injekt.get<TrackPreferences>() }
val trackManager = remember { Injekt.get<TrackManager>() }
val trackerManager = remember { Injekt.get<TrackerManager>() }
val sourceManager = remember { Injekt.get<SourceManager>() }
var dialog by remember { mutableStateOf<Any?>(null) }
@ -90,24 +90,24 @@ object SettingsTrackingScreen : SearchableSettings {
when (this) {
is LoginDialog -> {
TrackingLoginDialog(
service = service,
tracker = tracker,
uNameStringRes = uNameStringRes,
onDismissRequest = { dialog = null },
)
}
is LogoutDialog -> {
TrackingLogoutDialog(
service = service,
tracker = tracker,
onDismissRequest = { dialog = null },
)
}
}
}
val enhancedTrackers = trackManager.services
.filter { it is EnhancedTrackService }
val enhancedTrackers = trackerManager.trackers
.filter { it is EnhancedTracker }
.partition { service ->
val acceptedSources = (service as EnhancedTrackService).getAcceptedSources()
val acceptedSources = (service as EnhancedTracker).getAcceptedSources()
sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedSources }
}
var enhancedTrackerInfo = stringResource(R.string.enhanced_tracking_info)
@ -127,41 +127,41 @@ object SettingsTrackingScreen : SearchableSettings {
Preference.PreferenceGroup(
title = stringResource(R.string.services),
preferenceItems = listOf(
Preference.PreferenceItem.TrackingPreference(
title = trackManager.myAnimeList.name,
service = trackManager.myAnimeList,
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.myAnimeList.name,
tracker = trackerManager.myAnimeList,
login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.myAnimeList) },
logout = { dialog = LogoutDialog(trackerManager.myAnimeList) },
),
Preference.PreferenceItem.TrackingPreference(
title = trackManager.aniList.name,
service = trackManager.aniList,
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.aniList.name,
tracker = trackerManager.aniList,
login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.aniList) },
logout = { dialog = LogoutDialog(trackerManager.aniList) },
),
Preference.PreferenceItem.TrackingPreference(
title = trackManager.kitsu.name,
service = trackManager.kitsu,
login = { dialog = LoginDialog(trackManager.kitsu, R.string.email) },
logout = { dialog = LogoutDialog(trackManager.kitsu) },
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.kitsu.name,
tracker = trackerManager.kitsu,
login = { dialog = LoginDialog(trackerManager.kitsu, R.string.email) },
logout = { dialog = LogoutDialog(trackerManager.kitsu) },
),
Preference.PreferenceItem.TrackingPreference(
title = trackManager.mangaUpdates.name,
service = trackManager.mangaUpdates,
login = { dialog = LoginDialog(trackManager.mangaUpdates, R.string.username) },
logout = { dialog = LogoutDialog(trackManager.mangaUpdates) },
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.mangaUpdates.name,
tracker = trackerManager.mangaUpdates,
login = { dialog = LoginDialog(trackerManager.mangaUpdates, R.string.username) },
logout = { dialog = LogoutDialog(trackerManager.mangaUpdates) },
),
Preference.PreferenceItem.TrackingPreference(
title = trackManager.shikimori.name,
service = trackManager.shikimori,
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.shikimori.name,
tracker = trackerManager.shikimori,
login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.shikimori) },
logout = { dialog = LogoutDialog(trackerManager.shikimori) },
),
Preference.PreferenceItem.TrackingPreference(
title = trackManager.bangumi.name,
service = trackManager.bangumi,
Preference.PreferenceItem.TrackerPreference(
title = trackerManager.bangumi.name,
tracker = trackerManager.bangumi,
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.bangumi) },
logout = { dialog = LogoutDialog(trackerManager.bangumi) },
),
Preference.PreferenceItem.InfoPreference(stringResource(R.string.tracking_info)),
),
@ -170,10 +170,10 @@ object SettingsTrackingScreen : SearchableSettings {
title = stringResource(R.string.enhanced_services),
preferenceItems = enhancedTrackers.first
.map { service ->
Preference.PreferenceItem.TrackingPreference(
Preference.PreferenceItem.TrackerPreference(
title = service.name,
service = service,
login = { (service as EnhancedTrackService).loginNoop() },
tracker = service,
login = { (service as EnhancedTracker).loginNoop() },
logout = service::logout,
)
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo)),
@ -183,15 +183,15 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable
private fun TrackingLoginDialog(
service: TrackService,
tracker: Tracker,
@StringRes uNameStringRes: Int,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) }
var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) }
var username by remember { mutableStateOf(TextFieldValue(tracker.getUsername())) }
var password by remember { mutableStateOf(TextFieldValue(tracker.getPassword())) }
var processing by remember { mutableStateOf(false) }
var inputError by remember { mutableStateOf(false) }
@ -200,7 +200,7 @@ object SettingsTrackingScreen : SearchableSettings {
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.login_title, service.name),
text = stringResource(R.string.login_title, tracker.name),
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDismissRequest) {
@ -264,7 +264,7 @@ object SettingsTrackingScreen : SearchableSettings {
processing = true
val result = checkLogin(
context = context,
service = service,
tracker = tracker,
username = username.text,
password = password.text,
)
@ -283,16 +283,16 @@ object SettingsTrackingScreen : SearchableSettings {
private suspend fun checkLogin(
context: Context,
service: TrackService,
tracker: Tracker,
username: String,
password: String,
): Boolean {
return try {
service.login(username, password)
tracker.login(username, password)
withUIContext { context.toast(R.string.login_success) }
true
} catch (e: Throwable) {
service.logout()
tracker.logout()
withUIContext { context.toast(e.message.toString()) }
false
}
@ -300,7 +300,7 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable
private fun TrackingLogoutDialog(
service: TrackService,
tracker: Tracker,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
@ -308,7 +308,7 @@ object SettingsTrackingScreen : SearchableSettings {
onDismissRequest = onDismissRequest,
title = {
Text(
text = stringResource(R.string.logout_title, service.name),
text = stringResource(R.string.logout_title, tracker.name),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
@ -324,7 +324,7 @@ object SettingsTrackingScreen : SearchableSettings {
Button(
modifier = Modifier.weight(1f),
onClick = {
service.logout()
tracker.logout()
onDismissRequest()
context.toast(R.string.logout_success)
},
@ -342,10 +342,10 @@ object SettingsTrackingScreen : SearchableSettings {
}
private data class LoginDialog(
val service: TrackService,
val tracker: Tracker,
@StringRes val uNameStringRes: Int,
)
private data class LogoutDialog(
val service: TrackService,
val tracker: Tracker,
)

View File

@ -23,12 +23,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import compose.icons.SimpleIcons
import compose.icons.simpleicons.Discord
import compose.icons.simpleicons.Facebook
import compose.icons.simpleicons.Github
import compose.icons.simpleicons.Reddit
import compose.icons.simpleicons.Twitter
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.LogoHeader
@ -53,6 +47,12 @@ import tachiyomi.domain.release.interactor.GetApplicationRelease
import tachiyomi.presentation.core.components.LinkIcon
import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
import tachiyomi.presentation.core.icons.Facebook
import tachiyomi.presentation.core.icons.Github
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.text.DateFormat
@ -149,7 +149,7 @@ object AboutScreen : Screen() {
item {
TextPreferenceWidget(
title = stringResource(R.string.help_translate),
onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/help/contribution/#translation") },
onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/docs/contribute#translation") },
)
}
@ -163,7 +163,7 @@ object AboutScreen : Screen() {
item {
TextPreferenceWidget(
title = stringResource(R.string.privacy_policy),
onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/privacy") },
onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/privacy/") },
)
}
@ -181,27 +181,27 @@ object AboutScreen : Screen() {
)
LinkIcon(
label = "Discord",
icon = SimpleIcons.Discord,
icon = CustomIcons.Discord,
url = "https://discord.gg/tachiyomi",
)
LinkIcon(
label = "Twitter",
icon = SimpleIcons.Twitter,
url = "https://twitter.com/tachiyomiorg",
label = "X",
icon = CustomIcons.X,
url = "https://x.com/tachiyomiorg",
)
LinkIcon(
label = "Facebook",
icon = SimpleIcons.Facebook,
icon = CustomIcons.Facebook,
url = "https://facebook.com/tachiyomiorg",
)
LinkIcon(
label = "Reddit",
icon = SimpleIcons.Reddit,
icon = CustomIcons.Reddit,
url = "https://www.reddit.com/r/Tachiyomi",
)
LinkIcon(
label = "GitHub",
icon = SimpleIcons.Github,
icon = CustomIcons.Github,
url = "https://github.com/tachiyomiorg",
)
}

View File

@ -41,9 +41,9 @@ class OpenSourceLicensesScreen : Screen() {
),
onLibraryClick = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
name = it.name,
website = it.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
name = it.library.name,
website = it.library.website,
license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
)
navigator.push(libraryLicenseScreen)
},

View File

@ -20,12 +20,12 @@ import androidx.compose.ui.unit.dp
import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
@Composable
fun TrackingPreferenceWidget(
modifier: Modifier = Modifier,
service: TrackService,
tracker: Tracker,
checked: Boolean,
onClick: (() -> Unit)? = null,
) {
@ -38,9 +38,9 @@ fun TrackingPreferenceWidget(
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(service)
TrackLogoIcon(tracker)
Text(
text = service.name,
text = tracker.name,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),

View File

@ -1,25 +1,25 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.manga.model.orientationType
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.IconToggleButton
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
@ -32,22 +32,20 @@ fun OrientationModeSelectDialog(
val manga by screenModel.mangaFlow.collectAsState()
val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) }
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.rotation_type) {
orientationTypeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == orientationType,
onClick = {
screenModel.onChangeOrientation(it)
AdaptiveSheet(onDismissRequest = onDismissRequest) {
Box(modifier = Modifier.padding(vertical = 16.dp)) {
SettingsIconGrid(R.string.rotation_type) {
items(orientationTypeOptions) { (stringRes, mode) ->
IconToggleButton(
checked = mode == orientationType,
onCheckedChange = {
screenModel.onChangeOrientation(mode)
onChange(stringRes)
onDismissRequest()
},
label = { Text(stringResource(stringRes)) },
modifier = Modifier.fillMaxWidth(),
imageVector = ImageVector.vectorResource(mode.iconRes),
title = stringResource(stringRes),
)
}
}

View File

@ -6,12 +6,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalTextApi::class)
@Composable
fun PageIndicatorText(
currentPage: Int,

View File

@ -1,24 +1,25 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.foundation.lazy.grid.items
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.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.vectorResource
import eu.kanade.domain.manga.model.readingModeType
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.IconToggleButton
import tachiyomi.presentation.core.components.material.padding
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
@ -32,22 +33,20 @@ fun ReadingModeSelectDialog(
val manga by screenModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) }
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.pref_category_reading_mode) {
readingModeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == readingMode,
onClick = {
screenModel.onChangeReadingMode(it)
AdaptiveSheet(onDismissRequest = onDismissRequest) {
Box(modifier = Modifier.padding(vertical = MaterialTheme.padding.medium)) {
SettingsIconGrid(R.string.pref_category_reading_mode) {
items(readingModeOptions) { (stringRes, mode) ->
IconToggleButton(
checked = mode == readingMode,
onCheckedChange = {
screenModel.onChangeReadingMode(mode)
onChange(stringRes)
onDismissRequest()
},
label = { Text(stringResource(stringRes)) },
modifier = Modifier.fillMaxWidth(),
imageVector = ImageVector.vectorResource(mode.iconRes),
title = stringResource(stringRes),
)
}
}

View File

@ -16,7 +16,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.SettingsChipRow
@ -185,13 +184,6 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM
)
}
if (!isReleaseBuildType) {
CheckboxItem(
label = stringResource(R.string.pref_long_strip_split),
pref = screenModel.preferences.longStripSplitWebtoon(),
)
}
CheckboxItem(
label = stringResource(R.string.pref_double_tap_zoom),
pref = screenModel.preferences.webtoonDoubleTapZoomEnabled(),

View File

@ -49,7 +49,7 @@ import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.util.system.copyToClipboard
import java.text.DateFormat
@ -80,12 +80,12 @@ fun TrackInfoDialogHome(
) {
trackItems.forEach { item ->
if (item.track != null) {
val supportsScoring = item.service.getScoreList().isNotEmpty()
val supportsReadingDates = item.service.supportsReadingDates
val supportsScoring = item.tracker.getScoreList().isNotEmpty()
val supportsReadingDates = item.tracker.supportsReadingDates
TrackInfoItem(
title = item.track.title,
service = item.service,
status = item.service.getStatus(item.track.status.toInt()),
tracker = item.tracker,
status = item.tracker.getStatus(item.track.status.toInt()),
onStatusClick = { onStatusClick(item) },
chapters = "${item.track.lastChapterRead.toInt()}".let {
val totalChapters = item.track.totalChapters
@ -97,7 +97,7 @@ fun TrackInfoDialogHome(
}
},
onChaptersClick = { onChapterClick(item) },
score = item.service.displayScore(item.track.toDbTrack())
score = item.tracker.displayScore(item.track.toDbTrack())
.takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring },
@ -115,7 +115,7 @@ fun TrackInfoDialogHome(
)
} else {
TrackInfoItemEmpty(
service = item.service,
tracker = item.tracker,
onNewSearch = { onNewSearch(item) },
)
}
@ -126,7 +126,7 @@ fun TrackInfoDialogHome(
@Composable
private fun TrackInfoItem(
title: String,
service: TrackService,
tracker: Tracker,
@StringRes status: Int?,
onStatusClick: () -> Unit,
chapters: String,
@ -147,7 +147,7 @@ private fun TrackInfoItem(
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(
service = service,
tracker = tracker,
onClick = onOpenInBrowser,
)
Box(
@ -260,13 +260,13 @@ private fun TrackDetailsItem(
@Composable
private fun TrackInfoItemEmpty(
service: TrackService,
tracker: Tracker,
onNewSearch: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(service)
TrackLogoIcon(tracker)
TextButton(
onClick = onNewSearch,
modifier = Modifier

View File

@ -70,7 +70,7 @@ import tachiyomi.presentation.core.util.runOnEnterKeyPressed
import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable
fun TrackServiceSearch(
fun TrackerSearch(
query: TextFieldValue,
onQueryChange: (TextFieldValue) -> Unit,
onDispatchQuery: () -> Unit,
@ -223,6 +223,7 @@ private fun SearchResultItem(
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)

View File

@ -12,12 +12,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import tachiyomi.presentation.core.util.clickableNoIndication
@Composable
fun TrackLogoIcon(
service: TrackService,
tracker: Tracker,
onClick: (() -> Unit)? = null,
) {
val modifier = if (onClick != null) {
@ -29,13 +29,13 @@ fun TrackLogoIcon(
Box(
modifier = modifier
.size(48.dp)
.background(color = Color(service.getLogoColor()), shape = MaterialTheme.shapes.medium)
.background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium)
.padding(4.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(service.getLogo()),
contentDescription = service.name,
painter = painterResource(tracker.getLogo()),
contentDescription = tracker.name,
)
}
}

View File

@ -21,7 +21,7 @@ fun UpdatesDeleteConfirmationDialog(
TextButton(onClick = {
onConfirm()
onDismissRequest()
},) {
}) {
Text(text = stringResource(R.string.action_ok))
}
},

View File

@ -43,6 +43,7 @@ fun UpdateScreen(
state: UpdatesScreenModel.State,
snackbarHostState: SnackbarHostState,
lastUpdated: Long,
relativeTime: Boolean,
onClickCover: (UpdatesItem) -> Unit,
onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
@ -113,7 +114,7 @@ fun UpdateScreen(
}
updatesUiItems(
uiModels = state.getUiModel(context),
uiModels = state.getUiModel(context, relativeTime),
selectionMode = state.selectionMode,
onUpdateSelected = onUpdateSelected,
onClickCover = onClickCover,

View File

@ -175,7 +175,7 @@ fun WebViewScreenContent(
WarningBanner(
textRes = R.string.information_cloudflare_help,
modifier = Modifier.clickable {
uriHandler.openUri("https://tachiyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues")
uriHandler.openUri("https://tachiyomi.org/docs/guides/troubleshooting/#cloudflare")
},
)
}

View File

@ -19,8 +19,8 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.saver.ImageSaver
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.JavaScriptEngine
import eu.kanade.tachiyomi.network.NetworkHelper
@ -134,7 +134,7 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { DownloadCache(app) }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { TrackerManager() }
addSingletonFactory { DelayedTrackingStore(app) }
addSingletonFactory { ImageSaver(app) }

View File

@ -9,15 +9,15 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.BackupCreateJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.workManager
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.TriState
import tachiyomi.core.preference.getAndSet
@ -47,7 +47,7 @@ object Migrations {
libraryPreferences: LibraryPreferences,
readerPreferences: ReaderPreferences,
backupPreferences: BackupPreferences,
trackManager: TrackManager,
trackerManager: TrackerManager,
): Boolean {
val lastVersionCode = preferenceStore.getInt("last_version_code", 0)
val oldVersion = lastVersionCode.get()
@ -135,8 +135,8 @@ object Migrations {
// Force MAL log out due to login flow change
// v52: switched from scraping to WebView
// v53: switched from WebView to OAuth
if (trackManager.myAnimeList.isLoggedIn) {
trackManager.myAnimeList.logout()
if (trackerManager.myAnimeList.isLoggedIn) {
trackerManager.myAnimeList.logout()
context.toast(R.string.myanimelist_relogin)
}
}
@ -342,7 +342,7 @@ object Migrations {
"pref_filter_library_started",
"pref_filter_library_bookmarked",
"pref_filter_library_completed",
) + trackManager.services.map { "pref_filter_library_tracked_${it.id}" }
) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" }
prefKeys.forEach { key ->
val pref = preferenceStore.getInt(key, 0)
@ -362,19 +362,31 @@ object Migrations {
if (oldVersion < 100) {
BackupCreateJob.setupTask(context)
}
if (oldVersion < 102) {
// This was accidentally visible from the reader settings sheet, but should always
// be disabled in release builds.
if (isReleaseBuildType) {
readerPreferences.longStripSplitWebtoon().set(false)
}
}
if (oldVersion < 105) {
val pref = libraryPreferences.autoUpdateDeviceRestrictions()
if (pref.isSet() && "battery_not_low" in pref.get()) {
pref.getAndSet { it - "battery_not_low" }
}
}
if (oldVersion < 106) {
val pref = preferenceStore.getInt("relative_time", 7)
if (pref.get() == 0) {
uiPreferences.relativeTime().set(false)
}
}
if (oldVersion < 107) {
preferenceStore.getAll()
.filter { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") }
.forEach { (key, value) ->
if (value is String) {
preferenceStore
.getString(Preference.privateKey(key))
.set(value)
preferenceStore.getString(key).delete()
}
}
}
return true
}

View File

@ -4,11 +4,21 @@ package eu.kanade.tachiyomi.data.backup
internal object BackupConst {
const val BACKUP_CATEGORY = 0x1
const val BACKUP_CATEGORY_MASK = 0x1
const val BACKUP_CHAPTER = 0x2
const val BACKUP_CHAPTER_MASK = 0x2
const val BACKUP_HISTORY = 0x4
const val BACKUP_HISTORY_MASK = 0x4
const val BACKUP_TRACK = 0x8
const val BACKUP_TRACK_MASK = 0x8
const val BACKUP_ALL = 0xF
const val BACKUP_APP_PREFS = 0x10
const val BACKUP_APP_PREFS_MASK = 0x10
const val BACKUP_SOURCE_PREFS = 0x20
const val BACKUP_SOURCE_PREFS_MASK = 0x20
const val BACKUP_ALL = 0x3F
}

View File

@ -49,7 +49,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
}
return try {
val location = BackupManager(context).createBackup(uri, flags, isAutoBackup)
val location = BackupCreator(context).createBackup(uri, flags, isAutoBackup)
if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri()))
Result.success()
} catch (e: Exception) {

View File

@ -0,0 +1,268 @@
package eu.kanade.tachiyomi.data.backup
import android.Manifest
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.preferenceKey
import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.util.system.hasPermission
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
import okio.gzip
import okio.sink
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.util.system.logcat
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.FileOutputStream
class BackupCreator(
private val context: Context,
) {
private val handler: DatabaseHandler = Injekt.get()
private val sourceManager: SourceManager = Injekt.get()
private val backupPreferences: BackupPreferences = Injekt.get()
private val getCategories: GetCategories = Injekt.get()
private val getFavorites: GetFavorites = Injekt.get()
private val getHistory: GetHistory = Injekt.get()
private val preferenceStore: PreferenceStore = Injekt.get()
internal val parser = ProtoBuf
/**
* Create backup file.
*
* @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job
*/
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
throw IllegalStateException(context.getString(R.string.missing_storage_permission))
}
val databaseManga = getFavorites.await()
val backup = Backup(
backupMangas(databaseManga, flags),
backupCategories(flags),
emptyList(),
prepExtensionInfoForSync(databaseManga),
backupAppPreferences(flags),
backupSourcePreferences(flags),
)
var file: UniFile? = null
try {
file = (
if (isAutoBackup) {
// Get dir of file and create
var dir = UniFile.fromUri(context, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = backupPreferences.numberOfBackups().get()
dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
dir.createFile(Backup.getFilename())
} else {
UniFile.fromUri(context, uri)
}
)
?: throw Exception(context.getString(R.string.create_backup_file_error))
if (!file.isFile) {
throw IllegalStateException("Failed to get handle on a backup file")
}
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.getString(R.string.empty_backup_error))
}
file.openOutputStream().also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}.sink().gzip().buffer().use { it.write(byteArray) }
val fileUri = file.uri
// Make sure it's a valid backup file
BackupFileValidator().validate(context, fileUri)
return fileUri.toString()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
file?.delete()
throw e
}
}
private fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map(BackupSource::copyFrom)
.toList()
}
/**
* Backup the categories of library
*
* @return list of [BackupCategory] to be backed up
*/
private suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
getCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else {
emptyList()
}
}
private suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupManga(it, flags)
}
}
/**
* Convert a manga to Json
*
* @param manga manga that gets converted
* @param options options for the backup
* @return [BackupManga] containing manga in a serializable form
*/
private suspend fun backupManga(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = BackupManga.copyFrom(manga)
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) }
if (chapters.isNotEmpty()) {
mangaObject.chapters = chapters
}
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
}
private fun backupAppPreferences(flags: Int): List<BackupPreference> {
if (flags and BACKUP_APP_PREFS_MASK != BACKUP_APP_PREFS) return emptyList()
return preferenceStore.getAll().toBackupPreferences()
}
private fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> {
if (flags and BACKUP_SOURCE_PREFS_MASK != BACKUP_SOURCE_PREFS) return emptyList()
return sourceManager.getOnlineSources()
.filterIsInstance<ConfigurableSource>()
.map {
BackupSourcePreferences(
it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(),
)
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> {
return this.filterKeys { !Preference.isPrivate(it) }
.mapNotNull { (key, value) ->
when (value) {
is Int -> BackupPreference(key, IntPreferenceValue(value))
is Long -> BackupPreference(key, LongPreferenceValue(value))
is Float -> BackupPreference(key, FloatPreferenceValue(value))
is String -> BackupPreference(key, StringPreferenceValue(value))
is Boolean -> BackupPreference(key, BooleanPreferenceValue(value))
is Set<*> -> (value as? Set<String>)?.let {
BackupPreference(key, StringSetPreferenceValue(it))
}
else -> null
}
}
}
}

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.util.BackupUtil
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
@ -11,7 +11,7 @@ import uy.kohesive.injekt.api.get
class BackupFileValidator(
private val sourceManager: SourceManager = Injekt.get(),
private val trackManager: TrackManager = Injekt.get(),
private val trackerManager: TrackerManager = Injekt.get(),
) {
/**
@ -50,7 +50,7 @@ class BackupFileValidator(
.map { it.syncId }
.distinct()
val missingTrackers = trackers
.mapNotNull { trackManager.getService(it.toLong()) }
.mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn }
.map { it.name }
.sorted()
@ -58,5 +58,8 @@ class BackupFileValidator(
return Results(missingSources, missingTrackers)
}
data class Results(val missingSources: List<String>, val missingTrackers: List<String>)
data class Results(
val missingSources: List<String>,
val missingTrackers: List<String>,
)
}

View File

@ -1,590 +0,0 @@
package eu.kanade.tachiyomi.data.backup
import android.Manifest
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.domain.chapter.model.copyFrom
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSerializer
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.source.model.copyFrom
import eu.kanade.tachiyomi.util.system.hasPermission
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.buffer
import okio.gzip
import okio.sink
import tachiyomi.core.util.system.logcat
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.Manga_sync
import tachiyomi.data.Mangas
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.FileOutputStream
import java.util.Date
import kotlin.math.max
class BackupManager(
private val context: Context,
) {
private val handler: DatabaseHandler = Injekt.get()
private val sourceManager: SourceManager = Injekt.get()
private val backupPreferences: BackupPreferences = Injekt.get()
private val libraryPreferences: LibraryPreferences = Injekt.get()
private val getCategories: GetCategories = Injekt.get()
private val getFavorites: GetFavorites = Injekt.get()
private val getHistory: GetHistory = Injekt.get()
internal val parser = ProtoBuf
/**
* Create backup file from database
*
* @param uri path of Uri
* @param isAutoBackup backup called from scheduled backup job
*/
suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String {
if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
throw IllegalStateException(context.getString(R.string.missing_storage_permission))
}
val databaseManga = getFavorites.await()
val backup = Backup(
backupMangas(databaseManga, flags),
backupCategories(flags),
emptyList(),
prepExtensionInfoForSync(databaseManga),
)
var file: UniFile? = null
try {
file = (
if (isAutoBackup) {
// Get dir of file and create
var dir = UniFile.fromUri(context, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = backupPreferences.numberOfBackups().get()
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.proto.gz""")
dir.listFiles { _, filename -> backupRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
dir.createFile(Backup.getBackupFilename())
} else {
UniFile.fromUri(context, uri)
}
)
?: throw Exception(context.getString(R.string.create_backup_file_error))
if (!file.isFile) {
throw IllegalStateException("Failed to get handle on a backup file")
}
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.getString(R.string.empty_backup_error))
}
file.openOutputStream().also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}.sink().gzip().buffer().use { it.write(byteArray) }
val fileUri = file.uri
// Make sure it's a valid backup file
BackupFileValidator().validate(context, fileUri)
return fileUri.toString()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
file?.delete()
throw e
}
}
fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map(BackupSource::copyFrom)
.toList()
}
/**
* Backup the categories of library
*
* @return list of [BackupCategory] to be backed up
*/
suspend fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
getCategories.await()
.filterNot(Category::isSystemCategory)
.map(backupCategoryMapper)
} else {
emptyList()
}
}
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangas.map {
backupManga(it, flags)
}
}
/**
* Convert a manga to Json
*
* @param manga manga that gets converted
* @param options options for the backup
* @return [BackupManga] containing manga in a serializable form
*/
private suspend fun backupManga(manga: Manga, options: Int): BackupManga {
// Entry for this manga
val mangaObject = BackupManga.copyFrom(manga)
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) }
if (chapters.isNotEmpty()) {
mangaObject.chapters = chapters
}
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
mangaObject.categories = categoriesForManga.map { it.order }
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
}
if (history.isNotEmpty()) {
mangaObject.history = history
}
}
}
return mangaObject
}
internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga {
var updatedManga = manga.copy(id = dbManga._id)
updatedManga = updatedManga.copyFrom(dbManga)
updateManga(updatedManga)
return updatedManga
}
/**
* Fetches manga information
*
* @param manga manga that needs updating
* @return Updated manga info.
*/
internal suspend fun restoreNewManga(manga: Manga): Manga {
return manga.copy(
initialized = manga.description != null,
id = insertManga(manga),
)
}
/**
* Restore the categories from Json
*
* @param backupCategories list containing categories
*/
internal suspend fun restoreCategories(backupCategories: List<BackupCategory>) {
// Get categories from file and from db
val dbCategories = getCategories.await()
val categories = backupCategories.map {
var category = it.getCategory()
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
category = category.copy(id = dbCategory.id)
found = true
break
}
}
if (!found) {
// Let the db assign the id
val id = handler.awaitOneExecutable {
categoriesQueries.insert(category.name, category.order, category.flags)
categoriesQueries.selectLastInsertedRowId()
}
category = category.copy(id = id)
}
category
}
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
.distinctBy { it.flags }
.size > 1,
)
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
internal suspend fun restoreCategories(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
val dbCategories = getCategories.await()
val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>()
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder.toLong()
}?.let { backupCategory ->
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id))
}
}
}
// Update database
if (mangaCategoriesToUpdate.isNotEmpty()) {
handler.await(true) {
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
mangas_categoriesQueries.insert(mangaId, categoryId)
}
}
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
internal suspend fun restoreHistory(history: List<BackupHistory>) {
// List containing history to be updated
val toUpdate = mutableListOf<HistoryUpdate>()
for ((url, lastRead, readDuration) in history) {
var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) }
// Check if history already in database and update
if (dbHistory != null) {
dbHistory = dbHistory.copy(
last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)),
time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read,
)
toUpdate.add(
HistoryUpdate(
chapterId = dbHistory.chapter_id,
readAt = dbHistory.last_read!!,
sessionReadDuration = dbHistory.time_read,
),
)
} else {
// If not in database create
handler
.awaitOneOrNull { chaptersQueries.getChapterByUrl(url) }
?.let {
toUpdate.add(
HistoryUpdate(
chapterId = it._id,
readAt = Date(lastRead),
sessionReadDuration = readDuration,
),
)
}
}
}
handler.await(true) {
toUpdate.forEach { payload ->
historyQueries.upsert(
payload.chapterId,
payload.readAt,
payload.sessionReadDuration,
)
}
}
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
internal suspend fun restoreTracking(manga: Manga, tracks: List<tachiyomi.domain.track.model.Track>) {
// Get tracks from database
val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<tachiyomi.domain.track.model.Track>()
tracks
// Fix foreign keys with the current manga id
.map { it.copy(mangaId = manga.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead))
isInDatabase = true
toUpdate.add(temp)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
handler.await(true) {
toUpdate.forEach { track ->
manga_syncQueries.update(
track.manga_id,
track.sync_id,
track.remote_id,
track.library_id,
track.title,
track.last_chapter_read,
track.total_chapters,
track.status,
track.score,
track.remote_url,
track.start_date,
track.finish_date,
track._id,
)
}
}
}
if (toInsert.isNotEmpty()) {
handler.await(true) {
toInsert.forEach { track ->
manga_syncQueries.insert(
track.mangaId,
track.syncId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
)
}
}
}
}
internal suspend fun restoreChapters(manga: Manga, chapters: List<tachiyomi.domain.chapter.model.Chapter>) {
val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) }
val processed = chapters.map { chapter ->
var updatedChapter = chapter
val dbChapter = dbChapters.find { it.url == updatedChapter.url }
if (dbChapter != null) {
updatedChapter = updatedChapter.copy(id = dbChapter._id)
updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read != chapter.read) {
updatedChapter = updatedChapter.copy(read = chapter.read, lastPageRead = chapter.lastPageRead)
} else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
}
if (!updatedChapter.bookmark && dbChapter.bookmark) {
updatedChapter = updatedChapter.copy(bookmark = true)
}
}
updatedChapter.copy(mangaId = manga.id)
}
val newChapters = processed.groupBy { it.id > 0 }
newChapters[true]?.let { updateKnownChapters(it) }
newChapters[false]?.let { insertChapters(it) }
}
/**
* Returns manga
*
* @return [Manga], null if not found
*/
internal suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? {
return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) }
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
private suspend fun insertManga(manga: Manga): Long {
return handler.awaitOneExecutable(true) {
mangasQueries.insert(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = manga.initialized,
viewerFlags = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
)
mangasQueries.selectLastInsertedRowId()
}
}
suspend fun updateManga(manga: Manga): Long {
handler.await(true) {
mangasQueries.update(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre?.joinToString(separator = ", "),
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = null,
calculateInterval = null,
initialized = manga.initialized,
viewer = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
mangaId = manga.id,
updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
)
}
return manga.id
}
/**
* Inserts list of chapters
*/
private suspend fun insertChapters(chapters: List<tachiyomi.domain.chapter.model.Chapter>) {
handler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
)
}
}
}
/**
* Updates a list of chapters with known database ids
*/
private suspend fun updateKnownChapters(chapters: List<tachiyomi.domain.chapter.model.Chapter>) {
handler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.update(
mangaId = null,
url = null,
name = null,
scanlator = null,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterNumber = null,
sourceOrder = null,
dateFetch = null,
dateUpload = null,
chapterId = chapter.id,
)
}
}
}
}

View File

@ -26,7 +26,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure()
val sync = inputData.getBoolean(SYNC, false)
val sync = inputData.getBoolean(SYNC_KEY, false)
try {
setForeground(getForegroundInfo())
@ -67,7 +67,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
fun start(context: Context, uri: Uri, sync: Boolean = false) {
val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(),
SYNC to sync,
SYNC_KEY to sync,
)
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG)
@ -85,5 +85,4 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String
private const val SYNC = "sync" // Boolean
private const val SYNC_KEY = "sync" // Boolean

View File

@ -2,19 +2,38 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import eu.kanade.domain.chapter.model.copyFrom
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue
import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue
import eu.kanade.tachiyomi.source.model.copyFrom
import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.util.BackupUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import tachiyomi.core.preference.AndroidPreferenceStore
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.data.DatabaseHandler
import tachiyomi.data.Manga_sync
import tachiyomi.data.Mangas
import tachiyomi.data.UpdateStrategyColumnAdapter
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track
import uy.kohesive.injekt.Injekt
@ -24,19 +43,23 @@ import java.text.SimpleDateFormat
import java.time.ZonedDateTime
import java.util.Date
import java.util.Locale
import kotlin.math.max
class BackupRestorer(
private val context: Context,
private val notifier: BackupNotifier,
) {
private val handler: DatabaseHandler = Injekt.get()
private val updateManga: UpdateManga = Injekt.get()
private val chapterRepository: ChapterRepository = Injekt.get()
private val setFetchInterval: SetFetchInterval = Injekt.get()
private val getCategories: GetCategories = Injekt.get()
private val fetchInterval: FetchInterval = Injekt.get()
private val preferenceStore: PreferenceStore = Injekt.get()
private val libraryPreferences: LibraryPreferences = Injekt.get()
private var now = ZonedDateTime.now()
private var currentFetchWindow = setFetchInterval.getWindow(now)
private var backupManager = BackupManager(context)
private var currentFetchWindow = fetchInterval.getWindow(now)
private var restoreAmount = 0
private var restoreProgress = 0
@ -92,7 +115,7 @@ class BackupRestorer(
private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean {
val backup = BackupUtil.decodeBackup(context, uri)
restoreAmount = backup.backupManga.size + 1 // +1 for categories
restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs
// Restore categories
if (backup.backupCategories.isNotEmpty()) {
@ -103,9 +126,12 @@ class BackupRestorer(
val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources
sourceMapping = backupMaps.associate { it.sourceId to it.name }
now = ZonedDateTime.now()
currentFetchWindow = setFetchInterval.getWindow(now)
currentFetchWindow = fetchInterval.getWindow(now)
return coroutineScope {
restoreAppPreferences(backup.backupPreferences)
restoreSourcePreferences(backup.backupSourcePreferences)
// Restore individual manga
backup.backupManga.forEach {
if (!isActive) {
@ -115,12 +141,44 @@ class BackupRestorer(
restoreManga(it, backup.backupCategories, sync)
}
// TODO: optionally trigger online library + tracker update
true
}
}
private suspend fun restoreCategories(backupCategories: List<BackupCategory>) {
backupManager.restoreCategories(backupCategories)
// Get categories from file and from db
val dbCategories = getCategories.await()
val categories = backupCategories.map {
var category = it.getCategory()
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.name == dbCategory.name) {
category = category.copy(id = dbCategory.id)
found = true
break
}
}
if (!found) {
// Let the db assign the id
val id = handler.awaitOneExecutable {
categoriesQueries.insert(category.name, category.order, category.flags)
categoriesQueries.selectLastInsertedRowId()
}
category = category.copy(id = id)
}
category
}
libraryPreferences.categorizedDisplaySettings().set(
(dbCategories + categories)
.distinctBy { it.flags }
.size > 1,
)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup))
@ -135,14 +193,14 @@ class BackupRestorer(
val tracks = backupManga.getTrackingImpl()
try {
val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source)
val dbManga = getMangaFromDatabase(manga.url, manga.source)
val restoredManga = if (dbManga == null) {
// Manga not in database
restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories)
} else {
// Manga in database
// Copy information from manga already in database
val updatedManga = backupManager.restoreExistingManga(manga, dbManga)
val updatedManga = restoreExistingManga(manga, dbManga)
// Fetch rest of manga information
restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories)
}
@ -160,6 +218,50 @@ class BackupRestorer(
}
}
/**
* Returns manga
*
* @return [Manga], null if not found
*/
private suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? {
return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) }
}
private suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga {
var updatedManga = manga.copy(id = dbManga._id)
updatedManga = updatedManga.copyFrom(dbManga)
updateManga(updatedManga)
return updatedManga
}
private suspend fun updateManga(manga: Manga): Long {
handler.await(true) {
mangasQueries.update(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre?.joinToString(separator = ", "),
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = null,
calculateInterval = null,
initialized = manga.initialized,
viewer = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
mangaId = manga.id,
updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
)
}
return manga.id
}
/**
* Fetches manga information
*
@ -175,12 +277,131 @@ class BackupRestorer(
tracks: List<Track>,
backupCategories: List<BackupCategory>,
): Manga {
val fetchedManga = backupManager.restoreNewManga(manga)
backupManager.restoreChapters(fetchedManga, chapters)
val fetchedManga = restoreNewManga(manga)
restoreChapters(fetchedManga, chapters)
restoreExtras(fetchedManga, categories, history, tracks, backupCategories)
return fetchedManga
}
private suspend fun restoreChapters(manga: Manga, chapters: List<Chapter>) {
val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) }
val processed = chapters.map { chapter ->
var updatedChapter = chapter
val dbChapter = dbChapters.find { it.url == updatedChapter.url }
if (dbChapter != null) {
updatedChapter = updatedChapter.copy(id = dbChapter._id)
updatedChapter = updatedChapter.copyFrom(dbChapter)
if (dbChapter.read && !updatedChapter.read) {
updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read)
} else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) {
updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read)
}
if (!updatedChapter.bookmark && dbChapter.bookmark) {
updatedChapter = updatedChapter.copy(bookmark = true)
}
}
updatedChapter.copy(mangaId = manga.id)
}
val newChapters = processed.groupBy { it.id > 0 }
newChapters[true]?.let { updateKnownChapters(it) }
newChapters[false]?.let { insertChapters(it) }
}
/**
* Inserts list of chapters
*/
private suspend fun insertChapters(chapters: List<Chapter>) {
handler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.insert(
chapter.mangaId,
chapter.url,
chapter.name,
chapter.scanlator,
chapter.read,
chapter.bookmark,
chapter.lastPageRead,
chapter.chapterNumber,
chapter.sourceOrder,
chapter.dateFetch,
chapter.dateUpload,
)
}
}
}
/**
* Updates a list of chapters with known database ids
*/
private suspend fun updateKnownChapters(chapters: List<Chapter>) {
handler.await(true) {
chapters.forEach { chapter ->
chaptersQueries.update(
mangaId = null,
url = null,
name = null,
scanlator = null,
read = chapter.read,
bookmark = chapter.bookmark,
lastPageRead = chapter.lastPageRead,
chapterNumber = null,
sourceOrder = null,
dateFetch = null,
dateUpload = null,
chapterId = chapter.id,
)
}
}
}
/**
* Fetches manga information
*
* @param manga manga that needs updating
* @return Updated manga info.
*/
private suspend fun restoreNewManga(manga: Manga): Manga {
return manga.copy(
initialized = manga.description != null,
id = insertManga(manga),
)
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
private suspend fun insertManga(manga: Manga): Long {
return handler.awaitOneExecutable(true) {
mangasQueries.insert(
source = manga.source,
url = manga.url,
artist = manga.artist,
author = manga.author,
description = manga.description,
genre = manga.genre,
title = manga.title,
status = manga.status,
thumbnailUrl = manga.thumbnailUrl,
favorite = manga.favorite,
lastUpdate = manga.lastUpdate,
nextUpdate = 0L,
calculateInterval = 0L,
initialized = manga.initialized,
viewerFlags = manga.viewerFlags,
chapterFlags = manga.chapterFlags,
coverLastModified = manga.coverLastModified,
dateAdded = manga.dateAdded,
updateStrategy = manga.updateStrategy,
)
mangasQueries.selectLastInsertedRowId()
}
}
private suspend fun restoreNewManga(
backupManga: Manga,
chapters: List<Chapter>,
@ -189,24 +410,240 @@ class BackupRestorer(
tracks: List<Track>,
backupCategories: List<BackupCategory>,
): Manga {
backupManager.restoreChapters(backupManga, chapters)
restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories)
return backupManga
}
private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
backupManager.restoreCategories(manga, categories, backupCategories)
backupManager.restoreHistory(history)
backupManager.restoreTracking(manga, tracks)
restoreCategories(manga, categories, backupCategories)
restoreHistory(history)
restoreTracking(manga, tracks)
}
/**
* Called to update dialog in [BackupConst]
* Restores the categories a manga is in.
*
* @param progress restore progress
* @param amount total restoreAmount of manga
* @param title title of restored manga
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
private suspend fun restoreCategories(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
val dbCategories = getCategories.await()
val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>()
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder.toLong()
}?.let { backupCategory ->
dbCategories.firstOrNull { dbCategory ->
dbCategory.name == backupCategory.name
}?.let { dbCategory ->
mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id))
}
}
}
// Update database
if (mangaCategoriesToUpdate.isNotEmpty()) {
handler.await(true) {
mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
mangas_categoriesQueries.insert(mangaId, categoryId)
}
}
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
private suspend fun restoreHistory(history: List<BackupHistory>) {
// List containing history to be updated
val toUpdate = mutableListOf<HistoryUpdate>()
for ((url, lastRead, readDuration) in history) {
var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) }
// Check if history already in database and update
if (dbHistory != null) {
dbHistory = dbHistory.copy(
last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)),
time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read,
)
toUpdate.add(
HistoryUpdate(
chapterId = dbHistory.chapter_id,
readAt = dbHistory.last_read!!,
sessionReadDuration = dbHistory.time_read,
),
)
} else {
// If not in database create
handler
.awaitOneOrNull { chaptersQueries.getChapterByUrl(url) }
?.let {
toUpdate.add(
HistoryUpdate(
chapterId = it._id,
readAt = Date(lastRead),
sessionReadDuration = readDuration,
),
)
}
}
}
handler.await(true) {
toUpdate.forEach { payload ->
historyQueries.upsert(
payload.chapterId,
payload.readAt,
payload.sessionReadDuration,
)
}
}
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
private suspend fun restoreTracking(manga: Manga, tracks: List<Track>) {
// Get tracks from database
val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) }
val toUpdate = mutableListOf<Manga_sync>()
val toInsert = mutableListOf<Track>()
tracks
// Fix foreign keys with the current manga id
.map { it.copy(mangaId = manga.id) }
.forEach { track ->
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.syncId == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
var temp = dbTrack
if (track.remoteId != dbTrack.remote_id) {
temp = temp.copy(remote_id = track.remoteId)
}
if (track.libraryId != dbTrack.library_id) {
temp = temp.copy(library_id = track.libraryId)
}
temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead))
isInDatabase = true
toUpdate.add(temp)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
toInsert.add(track.copy(id = 0))
}
}
// Update database
if (toUpdate.isNotEmpty()) {
handler.await(true) {
toUpdate.forEach { track ->
manga_syncQueries.update(
track.manga_id,
track.sync_id,
track.remote_id,
track.library_id,
track.title,
track.last_chapter_read,
track.total_chapters,
track.status,
track.score,
track.remote_url,
track.start_date,
track.finish_date,
track._id,
)
}
}
}
if (toInsert.isNotEmpty()) {
handler.await(true) {
toInsert.forEach { track ->
manga_syncQueries.insert(
track.mangaId,
track.syncId,
track.remoteId,
track.libraryId,
track.title,
track.lastChapterRead,
track.totalChapters,
track.status,
track.score,
track.remoteUrl,
track.startDate,
track.finishDate,
)
}
}
}
}
private fun restoreAppPreferences(preferences: List<BackupPreference>) {
restorePreferences(preferences, preferenceStore)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.app_settings), context.getString(R.string.restoring_backup))
}
private fun restoreSourcePreferences(preferences: List<BackupSourcePreferences>) {
preferences.forEach {
val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey))
restorePreferences(it.prefs, sourcePrefs)
}
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.source_settings), context.getString(R.string.restoring_backup))
}
private fun restorePreferences(
toRestore: List<BackupPreference>,
preferenceStore: PreferenceStore,
) {
val prefs = preferenceStore.getAll()
toRestore.forEach { (key, value) ->
when (value) {
is IntPreferenceValue -> {
if (prefs[key] is Int?) {
preferenceStore.getInt(key).set(value.value)
}
}
is LongPreferenceValue -> {
if (prefs[key] is Long?) {
preferenceStore.getLong(key).set(value.value)
}
}
is FloatPreferenceValue -> {
if (prefs[key] is Float?) {
preferenceStore.getFloat(key).set(value.value)
}
}
is StringPreferenceValue -> {
if (prefs[key] is String?) {
preferenceStore.getString(key).set(value.value)
}
}
is BooleanPreferenceValue -> {
if (prefs[key] is Boolean?) {
preferenceStore.getBoolean(key).set(value.value)
}
}
is StringSetPreferenceValue -> {
if (prefs[key] is Set<*>?) {
preferenceStore.getStringSet(key).set(value.value)
}
}
}
}
}
private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) {
notifier.showRestoreProgress(title, contentTitle, progress, amount)
}

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.BuildConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import java.text.SimpleDateFormat
@ -10,15 +11,18 @@ import java.util.Locale
data class Backup(
@ProtoNumber(1) val backupManga: List<BackupManga>,
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var backupBrokenSources: List<BrokenBackupSource> = emptyList(),
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
) {
companion object {
fun getBackupFilename(): String {
val filenameRegex = """${BuildConfig.APPLICATION_ID}_\d+-\d+-\d+_\d+-\d+.tachibk""".toRegex()
fun getFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.proto.gz"
return "${BuildConfig.APPLICATION_ID}_$date.tachibk"
}
}
}

View File

@ -9,7 +9,6 @@ class BackupCategory(
@ProtoNumber(1) var name: String,
@ProtoNumber(2) var order: Long = 0,
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Long = 0,
) {
fun getCategory(): Category {

View File

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupPreference(
@ProtoNumber(1) val key: String,
@ProtoNumber(2) val value: PreferenceValue,
)
@Serializable
data class BackupSourcePreferences(
@ProtoNumber(1) val sourceKey: String,
@ProtoNumber(2) val prefs: List<BackupPreference>,
)
@Serializable
sealed class PreferenceValue
@Serializable
data class IntPreferenceValue(val value: Int) : PreferenceValue()
@Serializable
data class LongPreferenceValue(val value: Long) : PreferenceValue()
@Serializable
data class FloatPreferenceValue(val value: Float) : PreferenceValue()
@Serializable
data class StringPreferenceValue(val value: String) : PreferenceValue()
@Serializable
data class BooleanPreferenceValue(val value: Boolean) : PreferenceValue()
@Serializable
data class StringSetPreferenceValue(val value: Set<String>) : PreferenceValue()

View File

@ -8,9 +8,11 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response
import okio.buffer
import okio.sink
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import uy.kohesive.injekt.injectLazy
import java.io.File
@ -97,6 +99,7 @@ class ChapterCache(private val context: Context) {
editor.commit()
editor.abortUnlessCommitted()
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
// Ignore.
} finally {
editor?.abortUnlessCommitted()
@ -174,7 +177,7 @@ class ChapterCache(private val context: Context) {
* @return status of deletion for the file.
*/
private fun removeFileFromCache(file: String): Boolean {
// Make sure we don't delete the journal file (keeps track of cache).
// Make sure we don't delete the journal file (keeps track of cache)
if (file == "journal" || file.startsWith("journal.")) {
return false
}
@ -182,9 +185,10 @@ class ChapterCache(private val context: Context) {
return try {
// Remove the extension from the file to get the key of the cache
val key = file.substringBeforeLast(".")
// Remove file from cache.
// Remove file from cache
diskCache.remove(key)
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
false
}
}

View File

@ -43,7 +43,6 @@ import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext
@ -363,7 +362,7 @@ class Downloader(
if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE
try {
page.imageUrl = download.source.fetchImageUrl(page).awaitSingle()
page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) {
page.status = Page.State.ERROR
}

View File

@ -59,9 +59,9 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_HAS_U
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_READ
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.toMangaUpdate
import tachiyomi.domain.source.model.SourceNotInstalledException
@ -90,7 +90,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private val getCategories: GetCategories = Injekt.get()
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
private val refreshTracks: RefreshTracks = Injekt.get()
private val setFetchInterval: SetFetchInterval = Injekt.get()
private val fetchInterval: FetchInterval = Injekt.get()
private val notifier = LibraryUpdateNotifier(context)
@ -186,7 +186,40 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
.distinctBy { it.manga.id }
}
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get()
val skippedUpdates = mutableListOf<Pair<Manga, String?>>()
val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
mangaToUpdate = listToUpdate
.filter {
when {
it.manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_always_update))
false
}
MANGA_NON_COMPLETED in restrictions && it.manga.status.toInt() == SManga.COMPLETED -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_completed))
false
}
MANGA_HAS_UNREAD in restrictions && it.unreadCount != 0L -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_caught_up))
false
}
MANGA_NON_READ in restrictions && it.totalChapters > 0L && !it.hasStarted -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_started))
false
}
MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && it.manga.nextUpdate > fetchWindow.second -> {
skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_in_release_period))
false
}
else -> true
}
}
.sortedBy { it.manga.title }
// Warn when excessively checking a single source
@ -197,6 +230,17 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
notifier.showQueueSizeWarningNotification()
}
if (skippedUpdates.isNotEmpty()) {
// TODO: surface skipped reasons to user?
logcat {
skippedUpdates
.groupBy { it.second }
.map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" }
.joinToString()
}
notifier.showUpdateSkippedNotification(skippedUpdates.size)
}
}
/**
@ -212,11 +256,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val progressCount = AtomicInteger(0)
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get()
val fetchWindow = setFetchInterval.getWindow(ZonedDateTime.now())
val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values
@ -237,49 +279,30 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
progressCount,
manga,
) {
when {
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update))
try {
val newChapters = updateManga(manga, fetchWindow)
.sortedByDescending { it.sourceOrder }
MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed))
MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_caught_up))
MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started))
MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > fetchWindow.second ->
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period))
else -> {
try {
val newChapters = updateManga(manga, fetchWindow)
.sortedByDescending { it.sourceOrder }
if (newChapters.isNotEmpty()) {
val categoryIds = getCategories.await(manga.id).map { it.id }
if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
downloadChapters(manga, newChapters)
hasDownloads.set(true)
}
libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size }
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoChaptersException -> context.getString(R.string.no_chapters_error)
// failedUpdates will already have the source, don't need to copy it into the message
is SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
else -> e.message
}
failedUpdates.add(manga to errorMessage)
if (newChapters.isNotEmpty()) {
val categoryIds = getCategories.await(manga.id).map { it.id }
if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
downloadChapters(manga, newChapters)
hasDownloads.set(true)
}
libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size }
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = when (e) {
is NoChaptersException -> context.getString(R.string.no_chapters_error)
// failedUpdates will already have the source, don't need to copy it into the message
is SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
else -> e.message
}
failedUpdates.add(manga to errorMessage)
}
if (libraryPreferences.autoUpdateTrackers().get()) {
@ -309,16 +332,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
errorFile.getUriCompat(context),
)
}
if (skippedUpdates.isNotEmpty()) {
// TODO: surface skipped reasons to user
logcat {
skippedUpdates
.groupBy { it.second }
.map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" }
.joinToString()
}
notifier.showUpdateSkippedNotification(skippedUpdates.size)
}
}
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
@ -428,29 +441,27 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
completed: AtomicInteger,
manga: Manga,
block: suspend () -> Unit,
) {
coroutineScope {
ensureActive()
) = coroutineScope {
ensureActive()
updatingManga.add(manga)
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
updatingManga.add(manga)
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
block()
block()
ensureActive()
ensureActive()
updatingManga.remove(manga)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
}
updatingManga.remove(manga)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingManga,
completed.get(),
mangaToUpdate.size,
)
}
/**
@ -497,7 +508,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
private const val WORK_NAME_AUTO = "LibraryUpdate-auto"
private const val WORK_NAME_MANUAL = "LibraryUpdate-manual"
private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting"
private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/docs/guides/troubleshooting/"
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60

View File

@ -30,10 +30,14 @@ import tachiyomi.core.util.lang.launchUI
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy
import java.text.NumberFormat
class LibraryUpdateNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy()
private val percentFormatter = NumberFormat.getPercentInstance().apply {
maximumFractionDigits = 0
}
/**
* Pending intent of action that cancels the library update
@ -78,7 +82,7 @@ class LibraryUpdateNotifier(private val context: Context) {
} else {
val updatingText = manga.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder
.setContentTitle(context.getString(R.string.notification_updating, current, total))
.setContentTitle(context.getString(R.string.notification_updating_progress, percentFormatter.format(current.toFloat() / total)))
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
}
@ -329,11 +333,11 @@ class LibraryUpdateNotifier(private val context: Context) {
}
companion object {
const val HELP_WARNING_URL = "https://tachiyomi.org/help/faq/#why-does-the-app-warn-about-large-bulk-updates-and-downloads"
const val HELP_WARNING_URL = "https://tachiyomi.org/docs/faq/library#why-am-i-warned-about-large-bulk-updates-and-downloads"
}
}
private const val NOTIF_MAX_CHAPTERS = 5
private const val NOTIF_TITLE_MAX_LEN = 45
private const val NOTIF_ICON_SIZE = 192
private const val HELP_SKIPPED_URL = "https://tachiyomi.org/help/faq/#why-does-global-update-skip-some-entries"
private const val HELP_SKIPPED_URL = "https://tachiyomi.org/docs/faq/library#why-is-global-update-skipping-entries"

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.saver
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
@ -28,30 +27,59 @@ class ImageSaver(
val context: Context,
) {
@SuppressLint("InlinedApi")
fun save(image: Image): Uri {
val data = image.data
val type = ImageUtil.findImageType(data) ?: throw Exception("Not an image")
val type = ImageUtil.findImageType(data) ?: throw IllegalArgumentException("Not an image")
val filename = DiskUtil.buildValidFilename("${image.name}.${type.extension}")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) {
return save(data(), image.location.directory(context), filename)
}
return saveApi29(image, type, filename, data)
}
private fun save(inputStream: InputStream, directory: File, filename: String): Uri {
directory.mkdirs()
val destFile = File(directory, filename)
inputStream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.scanMedia(context, destFile.toUri())
return destFile.getUriCompat(context)
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveApi29(
image: Image,
type: ImageUtil.ImageType,
filename: String,
data: () -> InputStream,
): Uri {
val pictureDir =
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val folderRelativePath = "${Environment.DIRECTORY_PICTURES}/${context.getString(R.string.app_name)}/"
val imageLocation = (image.location as Location.Pictures).relativePath
val relativePath = listOf(
Environment.DIRECTORY_PICTURES,
context.getString(R.string.app_name),
imageLocation,
).joinToString(File.separator)
val contentValues = contentValuesOf(
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name,
MediaStore.Images.Media.MIME_TYPE to type.mime,
MediaStore.Images.Media.RELATIVE_PATH to folderRelativePath + imageLocation,
)
val picture = findUriOrDefault(folderRelativePath, "$imageLocation$filename") {
val picture = findUriOrDefault(relativePath, filename) {
context.contentResolver.insert(
pictureDir,
contentValues,
@ -74,49 +102,34 @@ class ImageSaver(
return picture
}
private fun save(inputStream: InputStream, directory: File, filename: String): Uri {
directory.mkdirs()
val destFile = File(directory, filename)
inputStream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.scanMedia(context, destFile.toUri())
return destFile.getUriCompat(context)
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun findUriOrDefault(relativePath: String, imagePath: String, default: () -> Uri): Uri {
private fun findUriOrDefault(path: String, filename: String, default: () -> Uri): Uri {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.MediaColumns.RELATIVE_PATH,
MediaStore.MediaColumns.DATE_MODIFIED,
)
val selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND ${MediaStore.MediaColumns.DISPLAY_NAME}=?"
// Need to make sure it ends with the separator
val normalizedPath = "${path.removeSuffix(File.separator)}${File.separator}"
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
arrayOf(relativePath, imagePath),
arrayOf(normalizedPath, filename),
null,
).use { cursor ->
if (cursor != null && cursor.count >= 1) {
cursor.moveToFirst().let {
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
return ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
}
}
}
return default()
}
}
@ -153,19 +166,12 @@ sealed class Image(
}
sealed interface Location {
data class Pictures private constructor(val relativePath: String) : Location {
companion object {
fun create(relativePath: String = ""): Pictures {
return Pictures(relativePath)
}
}
}
data class Pictures(val relativePath: String) : Location
data object Cache : Location
fun directory(context: Context): File {
return when (this) {
Cache -> context.cacheImageDir
is Pictures -> {
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
@ -179,6 +185,7 @@ sealed interface Location {
}
file
}
Cache -> context.cacheImageDir
}
}
}

View File

@ -3,9 +3,9 @@ package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.database.models.Track
/**
* For track services api that support deleting a manga entry for a user's list
* Tracker that support deleting am entry from a user's list.
*/
interface DeletableTrackService {
interface DeletableTracker {
suspend fun delete(track: Track): Track
}

View File

@ -6,31 +6,32 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track
/**
* An Enhanced Track Service will never prompt the user to match a manga with the remote.
* It is expected that such Track Service can only work with specific sources and unique IDs.
* A tracker that will never prompt the user to manually bind an entry.
* It is expected that such tracker can only work with specific sources and unique IDs.
*/
interface EnhancedTrackService {
interface EnhancedTracker {
/**
* This TrackService will only work with the sources that are accepted by this filter function.
* This tracker will only work with the sources that are accepted by this filter function.
*/
fun accept(source: Source): Boolean {
return source::class.qualifiedName in getAcceptedSources()
}
/**
* Fully qualified source classes that this track service is compatible with.
* Fully qualified source classes that this tracker is compatible with.
*/
fun getAcceptedSources(): List<String>
fun loginNoop()
/**
* match is similar to TrackService.search, but only return zero or one match.
* Similar to [Tracker].search, but only returns zero or one match.
*/
suspend fun match(manga: Manga): TrackSearch?
/**
* Checks whether the provided source/track/manga triplet is from this TrackService
* Checks whether the provided source/track/manga triplet is from this [Tracker]
*/
fun isTrackFrom(track: Track, manga: Manga, source: Source?): Boolean

View File

@ -5,7 +5,7 @@ import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.TrackPreferences
@ -28,7 +28,7 @@ import uy.kohesive.injekt.injectLazy
import java.time.ZoneOffset
import tachiyomi.domain.track.model.Track as DomainTrack
abstract class TrackService(val id: Long, val name: String) {
abstract class Tracker(val id: Long, val name: String) {
val trackPreferences: TrackPreferences by injectLazy()
val networkService: NetworkHelper by injectLazy()
@ -83,7 +83,7 @@ abstract class TrackService(val id: Long, val name: String) {
@CallSuper
open fun logout() {
trackPreferences.setTrackCredentials(this, "", "")
trackPreferences.setCredentials(this, "", "")
}
open val isLoggedIn: Boolean
@ -95,7 +95,7 @@ abstract class TrackService(val id: Long, val name: String) {
fun getPassword() = trackPreferences.trackPassword(this).get()
fun saveCredentials(username: String, password: String) {
trackPreferences.setTrackCredentials(this, username, password)
trackPreferences.setCredentials(this, username, password)
}
// TODO: move this to an interactor, and update all trackers based on common data
@ -111,7 +111,7 @@ abstract class TrackService(val id: Long, val name: String) {
insertTrack.await(track)
// TODO: merge into SyncChaptersWithTrackServiceTwoWay?
// TODO: merge into [SyncChapterProgressWithTrack]?
// Update chapter progress if newer chapters marked read locally
if (hasReadChapters) {
val latestLocalReadChapterNumber = allChapters
@ -143,7 +143,7 @@ abstract class TrackService(val id: Long, val name: String) {
}
}
syncChapterProgressWithTrack.await(mangaId, track, this@TrackService)
syncChapterProgressWithTrack.await(mangaId, track, this@Tracker)
}
} catch (e: Throwable) {
withUIContext { Injekt.get<Application>().toast(e.message) }

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track
import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.kavita.Kavita
@ -11,33 +10,27 @@ import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi
class TrackManager(context: Context) {
class TrackerManager {
companion object {
const val MYANIMELIST = 1L
const val ANILIST = 2L
const val KITSU = 3L
const val SHIKIMORI = 4L
const val BANGUMI = 5L
const val KOMGA = 6L
const val MANGA_UPDATES = 7L
const val KAVITA = 8L
const val SUWAYOMI = 9L
}
val myAnimeList = MyAnimeList(MYANIMELIST)
val myAnimeList = MyAnimeList(1L)
val aniList = Anilist(ANILIST)
val kitsu = Kitsu(KITSU)
val shikimori = Shikimori(SHIKIMORI)
val bangumi = Bangumi(BANGUMI)
val komga = Komga(KOMGA)
val mangaUpdates = MangaUpdates(MANGA_UPDATES)
val kavita = Kavita(context, KAVITA)
val suwayomi = Suwayomi(SUWAYOMI)
val shikimori = Shikimori(4L)
val bangumi = Bangumi(5L)
val komga = Komga(6L)
val mangaUpdates = MangaUpdates(7L)
val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
fun getService(id: Long) = services.find { it.id == id }
fun get(id: Long) = trackers.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLoggedIn }
fun hasLoggedIn() = trackers.any { it.isLoggedIn }
}

View File

@ -4,15 +4,15 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class Anilist(id: Long) : TrackService(id, "AniList"), DeletableTrackService {
class Anilist(id: Long) : Tracker(id, "AniList"), DeletableTracker {
companion object {
const val READING = 1

View File

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
import uy.kohesive.injekt.injectLazy
@ -20,7 +20,7 @@ data class ALManga(
val total_chapters: Int,
) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply {
media_id = this@ALManga.media_id
title = title_user_pref
total_chapters = this@ALManga.total_chapters
@ -50,7 +50,7 @@ data class ALUserManga(
val manga: ALManga,
) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
fun toTrack() = Track.create(TrackerManager.ANILIST).apply {
media_id = manga.media_id
title = manga.title_user_pref
status = toTrackStatus()
@ -62,7 +62,7 @@ data class ALUserManga(
total_chapters = manga.total_chapters
}
fun toTrackStatus() = when (list_status) {
private fun toTrackStatus() = when (list_status) {
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD

View File

@ -4,19 +4,19 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
class Bangumi(id: Long) : TrackService(id, "Bangumi") {
class Bangumi(id: Long) : Tracker(id, "Bangumi") {
private val json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this) }
private val api by lazy { BangumiApi(client, interceptor) }
private val api by lazy { BangumiApi(id, client, interceptor) }
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.track.bangumi
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
@ -26,7 +25,11 @@ import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
class BangumiApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: BangumiInterceptor,
) {
private val json: Json by injectLazy()
@ -105,7 +108,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} else {
0
}
return TrackSearch.create(TrackManager.BANGUMI).apply {
return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long
title = obj["name_cn"]!!.jsonPrimitive.content
cover_url = coverUrl

View File

@ -1,20 +1,22 @@
package eu.kanade.tachiyomi.data.track.kavita
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.sourcePreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest
import tachiyomi.domain.track.model.Track as DomainTrack
class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita"), EnhancedTrackService {
class Kavita(id: Long) : Tracker(id, "Kavita"), EnhancedTracker {
companion object {
const val UNREAD = 1
@ -27,6 +29,8 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita"
private val interceptor by lazy { KavitaInterceptor(this) }
val api by lazy { KavitaApi(client, interceptor) }
private val sourceManager: SourceManager by injectLazy()
override fun getLogo(): Int = R.drawable.ic_tracker_kavita
override fun getLogoColor() = Color.rgb(74, 198, 148)
@ -83,7 +87,7 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita"
saveCredentials("user", "pass")
}
// TrackService.isLogged works by checking that credentials are saved.
// [Tracker].isLogged works by checking that credentials are saved.
// By saving dummy, unused credentials, we can activate the tracker simply by login/logout
override fun loginNoop() {
saveCredentials("user", "pass")
@ -110,28 +114,29 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita"
fun loadOAuth() {
val oauth = OAuth()
for (sourceId in 1..3) {
val authentication = oauth.authentications[sourceId - 1]
val sourceSuffixID by lazy {
val key = "kavita_$sourceId/all/1" // Hardcoded versionID to 1
for (id in 1..3) {
val authentication = oauth.authentications[id - 1]
val sourceId by lazy {
val key = "kavita_$id/all/1" // Hardcoded versionID to 1
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }
.reduce(Long::or) and Long.MAX_VALUE
}
val preferences: SharedPreferences by lazy {
context.getSharedPreferences("source_$sourceSuffixID", 0x0000)
}
val prefApiUrl = preferences.getString("APIURL", "")!!
if (prefApiUrl.isEmpty()) {
val preferences = (sourceManager.get(sourceId) as ConfigurableSource).sourcePreferences()
val prefApiUrl = preferences.getString("APIURL", "")
val prefApiKey = preferences.getString("APIKEY", "")
if (prefApiUrl.isNullOrEmpty() || prefApiKey.isNullOrEmpty()) {
// Source not configured. Skip
continue
}
val prefApiKey = preferences.getString("APIKEY", "")!!
val token = api.getNewToken(apiUrl = prefApiUrl, apiKey = prefApiKey)
if (token.isNullOrEmpty()) {
// Source is not accessible. Skip
continue
}
authentication.apiUrl = prefApiUrl
authentication.jwtToken = token.toString()
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.track.kavita
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
@ -22,7 +22,7 @@ data class SeriesDto(
val libraryId: Int,
val libraryName: String? = "",
) {
fun toTrack(): TrackSearch = TrackSearch.create(TrackManager.KAVITA).also {
fun toTrack(): TrackSearch = TrackSearch.create(TrackerManager.KAVITA).also {
it.title = name
it.summary = ""
}

View File

@ -4,15 +4,15 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
class Kitsu(id: Long) : TrackService(id, "Kitsu"), DeletableTrackService {
class Kitsu(id: Long) : Tracker(id, "Kitsu"), DeletableTracker {
companion object {
const val READING = 1

View File

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@ -35,7 +35,7 @@ class KitsuSearchManga(obj: JsonObject) {
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
@CallSuper
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = this@KitsuSearchManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
@ -67,7 +67,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
media_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0

View File

@ -4,8 +4,8 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source
import okhttp3.Dns
@ -13,7 +13,7 @@ import okhttp3.OkHttpClient
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track as DomainTrack
class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService {
class Komga(id: Long) : Tracker(id, "Komga"), EnhancedTracker {
companion object {
const val UNREAD = 1
@ -26,7 +26,7 @@ class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService {
.dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
.build()
val api by lazy { KomgaApi(client) }
val api by lazy { KomgaApi(id, client) }
override fun getLogo() = R.drawable.ic_tracker_komga
@ -85,7 +85,7 @@ class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService {
saveCredentials("user", "pass")
}
// TrackService.isLogged works by checking that credentials are saved.
// [Tracker].isLogged works by checking that credentials are saved.
// By saving dummy, unused credentials, we can activate the tracker simply by login/logout
override fun loginNoop() {
saveCredentials("user", "pass")

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.track.komga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
@ -19,7 +18,10 @@ import uy.kohesive.injekt.injectLazy
private const val READLIST_API = "/api/v1/readlists"
class KomgaApi(private val client: OkHttpClient) {
class KomgaApi(
private val trackId: Long,
private val client: OkHttpClient,
) {
private val json: Json by injectLazy()
@ -85,13 +87,13 @@ class KomgaApi(private val client: OkHttpClient) {
return getTrackSearch(track.tracking_url)
}
private fun SeriesDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also {
private fun SeriesDto.toTrack(): TrackSearch = TrackSearch.create(trackId).also {
it.title = metadata.title
it.summary = metadata.summary
it.publishing_status = metadata.status
}
private fun ReadListDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also {
private fun ReadListDto.toTrack(): TrackSearch = TrackSearch.create(trackId).also {
it.title = name
}
}

View File

@ -4,13 +4,13 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch
class MangaUpdates(id: Long) : TrackService(id, "MangaUpdates"), DeletableTrackService {
class MangaUpdates(id: Long) : Tracker(id, "MangaUpdates"), DeletableTracker {
companion object {
const val READING_LIST = 0

View File

@ -4,14 +4,14 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
class MyAnimeList(id: Long) : TrackService(id, "MyAnimeList"), DeletableTrackService {
class MyAnimeList(id: Long) : Tracker(id, "MyAnimeList"), DeletableTracker {
companion object {
const val READING = 1
@ -28,7 +28,7 @@ class MyAnimeList(id: Long) : TrackService(id, "MyAnimeList"), DeletableTrackSer
private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
private val api by lazy { MyAnimeListApi(client, interceptor) }
private val api by lazy { MyAnimeListApi(id, client, interceptor) }
override val supportsReadingDates: Boolean = true

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
@ -32,7 +31,11 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
class MyAnimeListApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: MyAnimeListInterceptor,
) {
private val json: Json by injectLazy()
@ -106,7 +109,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.parseAs<JsonObject>()
.let {
val obj = it.jsonObject
TrackSearch.create(TrackManager.MYANIMELIST).apply {
TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long
title = obj["title"]!!.jsonPrimitive.content
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""

View File

@ -4,14 +4,14 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
class Shikimori(id: Long) : TrackService(id, "Shikimori"), DeletableTrackService {
class Shikimori(id: Long) : Tracker(id, "Shikimori"), DeletableTracker {
companion object {
const val READING = 1
@ -26,7 +26,7 @@ class Shikimori(id: Long) : TrackService(id, "Shikimori"), DeletableTrackService
private val interceptor by lazy { ShikimoriInterceptor(this) }
private val api by lazy { ShikimoriApi(client, interceptor) }
private val api by lazy { ShikimoriApi(id, client, interceptor) }
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.track.shikimori
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
@ -28,7 +27,11 @@ import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
class ShikimoriApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: ShikimoriInterceptor,
) {
private val json: Json by injectLazy()
@ -96,7 +99,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKIMORI).apply {
return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long
title = obj["name"]!!.jsonPrimitive.content
total_chapters = obj["chapters"]!!.jsonPrimitive.int
@ -110,7 +113,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
}
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKIMORI).apply {
return Track.create(trackId).apply {
title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.long
total_chapters = mangas["chapters"]!!.jsonPrimitive.int

View File

@ -4,16 +4,16 @@ import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.manga.model.Manga as DomainManga
import tachiyomi.domain.track.model.Track as DomainTrack
class Suwayomi(id: Long) : TrackService(id, "Suwayomi"), EnhancedTrackService {
class Suwayomi(id: Long) : Tracker(id, "Suwayomi"), EnhancedTracker {
val api by lazy { TachideskApi() }
val api by lazy { SuwayomiApi(id) }
override fun getLogo() = R.drawable.ic_tracker_suwayomi

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
@ -23,7 +23,7 @@ import uy.kohesive.injekt.injectLazy
import java.nio.charset.Charset
import java.security.MessageDigest
class TachideskApi {
class SuwayomiApi(private val trackId: Long) {
private val network: NetworkHelper by injectLazy()
private val json: Json by injectLazy()
@ -61,7 +61,7 @@ class TachideskApi {
.parseAs<MangaDataClass>()
}
TrackSearch.create(TrackManager.SUWAYOMI).apply {
TrackSearch.create(trackId).apply {
title = manga.title
cover_url = "$url/thumbnail"
summary = manga.description.orEmpty()
@ -100,26 +100,24 @@ class TachideskApi {
return getTrackSearch(track.tracking_url)
}
private val tachideskExtensionId by lazy {
private val sourceId by lazy {
val key = "tachidesk/en/1"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$tachideskExtensionId", 0x0000)
Injekt.get<Application>().getSharedPreferences("source_$sourceId", Context.MODE_PRIVATE)
}
private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!!
private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
companion object {
private const val ADDRESS_TITLE = "Server URL Address"
private const val ADDRESS_DEFAULT = ""
private const val LOGIN_TITLE = "Login (Basic Auth)"
private const val LOGIN_DEFAULT = ""
private const val PASSWORD_TITLE = "Password (Basic Auth)"
private const val PASSWORD_DEFAULT = ""
}
}
private const val ADDRESS_TITLE = "Server URL Address"
private const val ADDRESS_DEFAULT = ""
private const val LOGIN_TITLE = "Login (Basic Auth)"
private const val LOGIN_DEFAULT = ""
private const val PASSWORD_TITLE = "Password (Basic Auth)"
private const val PASSWORD_DEFAULT = ""

View File

@ -143,7 +143,7 @@ internal class AppUpdateNotifier(private val context: Context) {
setContentTitle(context.getString(R.string.update_check_notification_update_available))
setContentText(context.getString(R.string.update_check_fdroid_migration_info))
setSmallIcon(R.drawable.ic_tachi)
setContentIntent(NotificationHandler.openUrl(context, "https://tachiyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version"))
setContentIntent(NotificationHandler.openUrl(context, "https://tachiyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds"))
}
notificationBuilder.show(Notifications.ID_APP_UPDATE_PROMPT)
}

View File

@ -8,6 +8,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
@ -100,7 +101,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
}
init {
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
ContextCompat.registerReceiver(service, packageActionReceiver, IntentFilter(INSTALL_ACTION), ContextCompat.RECEIVER_EXPORTED)
}
}

View File

@ -264,7 +264,7 @@ internal class ExtensionInstaller(private val context: Context) {
isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
context.registerReceiver(this, filter)
ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED)
}
/**

View File

@ -10,8 +10,6 @@ import uy.kohesive.injekt.api.get
fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this.id)
fun Source.getPreferenceKey(): String = "source_$id"
fun Source.toStubSource(): StubSource = StubSource(id = id, lang = lang, name = name)
fun Source.getNameForMangaInfo(): String {

View File

@ -102,7 +102,7 @@ class ExtensionDetailsScreenModel(
val extension = state.value.extension ?: return ""
if (!extension.hasReadme) {
return "https://tachiyomi.org/help/faq/#extensions"
return "https://tachiyomi.org/docs/faq/browse/extensions"
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")

View File

@ -39,7 +39,7 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.getPreferenceKey
import eu.kanade.tachiyomi.source.sourcePreferences
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.presentation.core.components.material.Scaffold
@ -134,12 +134,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
private fun populateScreen(): PreferenceScreen {
val sourceId = requireArguments().getLong(SOURCE_ID)
val source = Injekt.get<SourceManager>().get(sourceId)!!
val source = Injekt.get<SourceManager>().get(sourceId)!! as ConfigurableSource
check(source is ConfigurableSource)
val sharedPreferences = requireContext().getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
val dataStore = SharedPreferencesDataStore(sharedPreferences)
val dataStore = SharedPreferencesDataStore(source.sourcePreferences())
preferenceManager.preferenceDataStore = dataStore
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext())

View File

@ -35,8 +35,8 @@ import eu.kanade.domain.manga.model.toSManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
@ -177,7 +177,7 @@ internal class MigrateDialogScreenModel(
}
private val enhancedServices by lazy {
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>()
Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>()
}
suspend fun migrateManga(

Some files were not shown because too many files have changed in this diff Show More