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 insert_final_newline=true
ij_kotlin_allow_trailing_comma=true ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=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=2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 ij_kotlin_name_count_to_use_star_import_for_members=2147483647

View File

@ -5,7 +5,7 @@ I acknowledge that:
- I have updated: - I have updated:
- To the latest version of the app (stable is v0.14.6) - To the latest version of the app (stable is v0.14.6)
- All extensions - 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 - 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 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 - 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 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 about: Issues and requests for extensions and sources should be opened in the tachiyomi-extensions repository instead
- name: 📦 Tachiyomi extensions - name: 📦 Tachiyomi extensions
url: https://tachiyomi.org/extensions url: https://tachiyomi.org/extensions/
about: List of all available extensions with download links about: List of all available extensions with download links
- name: 🖥️ Tachiyomi website - name: 🖥️ Tachiyomi website
url: https://tachiyomi.org/help/ url: https://tachiyomi.org/
about: Guides, troubleshooting, and answers to common questions about: Guides, troubleshooting, and answers to common questions

View File

@ -96,7 +96,7 @@ body:
required: true 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). - 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 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 required: true
- label: I have updated the app to version **[0.14.6](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**. - label: I have updated the app to version **[0.14.6](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
required: true required: true

View File

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

View File

@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Clone repo - name: Clone repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
@ -31,7 +31,7 @@ jobs:
- name: Build app and run unit tests - name: Build app and run unit tests
uses: gradle/gradle-command-action@v2 uses: gradle/gradle-command-action@v2
with: with:
arguments: lintKotlin assembleStandardRelease testReleaseUnitTest arguments: ktlintCheck assembleStandardRelease testReleaseUnitTest
# Sign APK and create release for tags # Sign APK and create release for tags
@ -104,3 +104,13 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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.*", "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, "ignoreCase": true,
"labels": ["Cloudflare protected"], "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 auto-close-ignore-label: do-not-autoclose

3
.gitignore vendored
View File

@ -2,7 +2,8 @@
/local.properties /local.properties
/.idea/workspace.xml /.idea/workspace.xml
.DS_Store .DS_Store
.idea/ .idea/*
!.idea/icon.png
*iml *iml
*.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
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 # 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> <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) 2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details> </details>

View File

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

View File

@ -1,7 +1,6 @@
package eu.kanade.domain package eu.kanade.domain
import eu.kanade.domain.chapter.interactor.SetReadStatus 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.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.extension.interactor.GetExtensionLanguages 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.ToggleLanguage
import eu.kanade.domain.source.interactor.ToggleSource import eu.kanade.domain.source.interactor.ToggleSource
import eu.kanade.domain.source.interactor.ToggleSourcePin 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.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
import tachiyomi.data.chapter.ChapterRepositoryImpl 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.RemoveHistory
import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.manga.interactor.GetFavorites import tachiyomi.domain.manga.interactor.GetFavorites
import tachiyomi.domain.manga.interactor.GetLibraryManga 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.GetMangaWithChapters
import tachiyomi.domain.manga.interactor.NetworkToLocalManga import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.interactor.ResetViewerFlags import tachiyomi.domain.manga.interactor.ResetViewerFlags
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.interactor.GetApplicationRelease
@ -102,7 +103,7 @@ class DomainModule : InjektModule {
addFactory { GetNextChapters(get(), get(), get()) } addFactory { GetNextChapters(get(), get(), get()) }
addFactory { ResetViewerFlags(get()) } addFactory { ResetViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) } addFactory { SetMangaChapterFlags(get()) }
addFactory { SetFetchInterval(get()) } addFactory { FetchInterval(get()) }
addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) } addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) }
addFactory { SetMangaViewerFlags(get()) } addFactory { SetMangaViewerFlags(get()) }
addFactory { NetworkToLocalManga(get()) } addFactory { NetworkToLocalManga(get()) }
@ -114,11 +115,13 @@ class DomainModule : InjektModule {
addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) } addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) }
addFactory { TrackChapter(get(), get(), get(), get()) } addFactory { TrackChapter(get(), get(), get(), get()) }
addFactory { AddTracks(get(), get(), get()) }
addFactory { RefreshTracks(get(), get(), get(), get()) } addFactory { RefreshTracks(get(), get(), get(), get()) }
addFactory { DeleteTrack(get()) } addFactory { DeleteTrack(get()) }
addFactory { GetTracksPerManga(get()) } addFactory { GetTracksPerManga(get()) }
addFactory { GetTracks(get()) } addFactory { GetTracks(get()) }
addFactory { InsertTrack(get()) } addFactory { InsertTrack(get()) }
addFactory { SyncChapterProgressWithTrack(get(), get(), get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) } addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) } addFactory { GetChapter(get()) }
@ -127,7 +130,6 @@ class DomainModule : InjektModule {
addFactory { SetReadStatus(get(), get(), get(), get()) } addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() } addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) } addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) }
addFactory { SyncChapterProgressWithTrack(get(), get(), get()) }
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(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.domain.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.SManga 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.Manga
import tachiyomi.domain.manga.model.MangaUpdate import tachiyomi.domain.manga.model.MangaUpdate
import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.manga.repository.MangaRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateManga( class UpdateManga(
private val mangaRepository: MangaRepository, private val mangaRepository: MangaRepository,
private val setFetchInterval: SetFetchInterval, private val fetchInterval: FetchInterval,
) { ) {
suspend fun await(mangaUpdate: MangaUpdate): Boolean { suspend fun await(mangaUpdate: MangaUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval( suspend fun awaitUpdateFetchInterval(
manga: Manga, manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(), dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setFetchInterval.getWindow(dateTime), window: Pair<Long, Long> = fetchInterval.getWindow(dateTime),
): Boolean { ): Boolean {
return setFetchInterval.toMangaUpdateOrNull(manga, dateTime, window) return fetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
?.let { mangaRepository.update(it) } ?.let { mangaRepository.update(it) }
?: false ?: 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 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.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackerManager
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@ -13,7 +12,7 @@ import tachiyomi.domain.track.interactor.InsertTrack
class RefreshTracks( class RefreshTracks(
private val getTracks: GetTracks, private val getTracks: GetTracks,
private val trackManager: TrackManager, private val trackerManager: TrackerManager,
private val insertTrack: InsertTrack, private val insertTrack: InsertTrack,
private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack, private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack,
) { ) {
@ -23,18 +22,17 @@ class RefreshTracks(
* *
* @return Failed updates. * @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 {
return@supervisorScope getTracks.await(mangaId) return@supervisorScope getTracks.await(mangaId)
.map { track -> .map { it to trackerManager.get(it.syncId) }
.filter { (_, service) -> service?.isLoggedIn == true }
.map { (track, service) ->
async { async {
val service = trackManager.getService(track.syncId)
return@async try { return@async try {
if (service?.isLoggedIn == true) { val updatedTrack = service!!.refresh(track.toDbTrack())
val updatedTrack = service.refresh(track.toDbTrack()) insertTrack.await(updatedTrack.toDomainTrack()!!)
insertTrack.await(updatedTrack.toDomainTrack()!!) syncChapterProgressWithTrack.await(mangaId, track, service)
syncChapterProgressWithTrack.await(mangaId, track, service)
}
null null
} catch (e: Throwable) { } catch (e: Throwable) {
service to e 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.domain.track.model.toDbTrack
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChapterByMangaId import tachiyomi.domain.chapter.interactor.GetChapterByMangaId
@ -20,9 +20,9 @@ class SyncChapterProgressWithTrack(
suspend fun await( suspend fun await(
mangaId: Long, mangaId: Long,
remoteTrack: Track, remoteTrack: Track,
service: TrackService, tracker: Tracker,
) { ) {
if (service !is EnhancedTrackService) { if (tracker !is EnhancedTracker) {
return return
} }
@ -39,7 +39,7 @@ class SyncChapterProgressWithTrack(
val updatedTrack = remoteTrack.copy(lastChapterRead = localLastRead.toDouble()) val updatedTrack = remoteTrack.copy(lastChapterRead = localLastRead.toDouble())
try { try {
service.update(updatedTrack.toDbTrack()) tracker.update(updatedTrack.toDbTrack())
updateChapter.awaitAll(chapterUpdates) updateChapter.awaitAll(chapterUpdates)
insertTrack.await(updatedTrack) insertTrack.await(updatedTrack)
} catch (e: Throwable) { } 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.model.toDbTrack
import eu.kanade.domain.track.service.DelayedTrackingUpdateJob import eu.kanade.domain.track.service.DelayedTrackingUpdateJob
import eu.kanade.domain.track.store.DelayedTrackingStore 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.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.lang.launchNonCancellable import tachiyomi.core.util.lang.withNonCancellableContext
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.domain.track.interactor.InsertTrack import tachiyomi.domain.track.interactor.InsertTrack
class TrackChapter( class TrackChapter(
private val getTracks: GetTracks, private val getTracks: GetTracks,
private val trackManager: TrackManager, private val trackerManager: TrackerManager,
private val insertTrack: InsertTrack, private val insertTrack: InsertTrack,
private val delayedTrackingStore: DelayedTrackingStore, private val delayedTrackingStore: DelayedTrackingStore,
) { ) {
suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) = coroutineScope { suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) {
launchNonCancellable { withNonCancellableContext {
val tracks = getTracks.await(mangaId) val tracks = getTracks.await(mangaId)
if (tracks.isEmpty()) return@launchNonCancellable if (tracks.isEmpty()) return@withNonCancellableContext
tracks.mapNotNull { track -> tracks.mapNotNull { track ->
val service = trackManager.getService(track.syncId) val service = trackerManager.get(track.syncId)
if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) { if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) {
return@mapNotNull null return@mapNotNull null
} }

View File

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

View File

@ -1,33 +1,34 @@
package eu.kanade.domain.track.service 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 eu.kanade.tachiyomi.data.track.anilist.Anilist
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
class TrackPreferences( class TrackPreferences(
private val preferenceStore: PreferenceStore, 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) trackUsername(sync).set(username)
trackPassword(sync).set(password) 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 anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
companion object { 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 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 dateFormat() = preferenceStore.getString("app_date_format", "")
fun tabletUiMode() = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC) 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.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.CategoryFloatingActionButton
import eu.kanade.presentation.category.components.CategoryListItem import eu.kanade.presentation.category.components.CategoryListItem
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.category.CategoryScreenState import eu.kanade.tachiyomi.ui.category.CategoryScreenState
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@ -27,6 +36,7 @@ import tachiyomi.presentation.core.util.plus
fun CategoryScreen( fun CategoryScreen(
state: CategoryScreenState.Success, state: CategoryScreenState.Success,
onClickCreate: () -> Unit, onClickCreate: () -> Unit,
onClickSortAlphabetically: () -> Unit,
onClickRename: (Category) -> Unit, onClickRename: (Category) -> Unit,
onClickDelete: (Category) -> Unit, onClickDelete: (Category) -> Unit,
onClickMoveUp: (Category) -> Unit, onClickMoveUp: (Category) -> Unit,
@ -36,9 +46,32 @@ fun CategoryScreen(
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
AppBar( TopAppBar(
title = stringResource(R.string.action_edit_categories), title = {
navigateUp = navigateUp, 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, scrollBehavior = scrollBehavior,
) )
}, },

View File

@ -162,7 +162,7 @@ fun CategoryDeleteDialog(
TextButton(onClick = { TextButton(onClick = {
onDelete() onDelete()
onDismissRequest() onDismissRequest()
},) { }) {
Text(text = stringResource(R.string.action_ok)) 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 @Composable
fun ChangeCategoryDialog( fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>, initialSelection: List<CheckboxState<Category>>,
@ -217,7 +246,7 @@ fun ChangeCategoryDialog(
tachiyomi.presentation.core.components.material.TextButton(onClick = { tachiyomi.presentation.core.components.material.TextButton(onClick = {
onDismissRequest() onDismissRequest()
onEditCategories() onEditCategories()
},) { }) {
Text(text = stringResource(R.string.action_edit)) Text(text = stringResource(R.string.action_edit))
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))

View File

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

View File

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

View File

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

View File

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

View File

@ -143,7 +143,7 @@ fun MangaBottomActionMenu(
if (onMarkPreviousAsReadClicked != null) { if (onMarkPreviousAsReadClicked != null) {
Button( Button(
title = stringResource(R.string.action_mark_previous_as_read), 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], toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) }, onLongClick = { onLongClickItem(4) },
onClick = onMarkPreviousAsReadClicked, 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.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R 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 import tachiyomi.presentation.core.components.WheelTextPicker
@Composable @Composable
@ -67,7 +67,7 @@ fun SetIntervalDialog(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val size = DpSize(width = maxWidth / 2, height = 128.dp) 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) { if (it == 0) {
stringResource(R.string.label_default) stringResource(R.string.label_default)
} else { } else {
@ -91,7 +91,7 @@ fun SetIntervalDialog(
TextButton(onClick = { TextButton(onClick = {
onValueChanged(selectedInterval) onValueChanged(selectedInterval)
onDismissRequest() onDismissRequest()
},) { }) {
Text(text = stringResource(R.string.action_ok)) Text(text = stringResource(R.string.action_ok))
} }
}, },

View File

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

View File

@ -62,7 +62,7 @@ fun MoreScreen(
WarningBanner( WarningBanner(
textRes = R.string.fdroid_warning, textRes = R.string.fdroid_warning,
modifier = Modifier.clickable { 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) stringResource(R.string.paused)
} else { } else {
"${stringResource(R.string.paused)}${ "${stringResource(R.string.paused)}${
pluralStringResource( pluralStringResource(
id = R.plurals.download_queue_summary, id = R.plurals.download_queue_summary,
count = pending, count = pending,
pending, pending,
) )
}" }"
} }
} }

View File

@ -4,9 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.more.settings.Preference.PreferenceItem
import eu.kanade.tachiyomi.R 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 import tachiyomi.core.preference.Preference as PreferenceData
sealed class Preference { sealed class Preference {
@ -133,10 +132,10 @@ sealed class Preference {
) : PreferenceItem<String>() ) : PreferenceItem<String>()
/** /**
* A [PreferenceItem] for individual tracking service. * A [PreferenceItem] for individual tracker.
*/ */
data class TrackingPreference( data class TrackerPreference(
val service: TrackService, val tracker: Tracker,
override val title: String, override val title: String,
val login: () -> Unit, val login: () -> Unit,
val logout: () -> 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>() val uName by Injekt.get<PreferenceStore>()
.getString(TrackPreferences.trackUsername(item.service.id)) .getString(TrackPreferences.trackUsername(item.tracker.id))
.collectAsState() .collectAsState()
item.service.run { item.tracker.run {
TrackingPreferenceWidget( TrackingPreferenceWidget(
service = this, tracker = this,
checked = uName.isNotEmpty(), checked = uName.isNotEmpty(),
onClick = { if (isLoggedIn) item.logout() else item.login() }, 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.cache.ChapterCache
import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadCache
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob 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.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_360 import eu.kanade.tachiyomi.network.PREF_DOH_360
@ -328,7 +328,7 @@ object SettingsAdvancedScreen : SearchableSettings {
private fun getLibraryGroup(): Preference.PreferenceGroup { private fun getLibraryGroup(): Preference.PreferenceGroup {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val trackManager = remember { Injekt.get<TrackManager>() } val trackerManager = remember { Injekt.get<TrackerManager>() }
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(R.string.label_library), title = stringResource(R.string.label_library),
@ -340,7 +340,7 @@ object SettingsAdvancedScreen : SearchableSettings {
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.pref_refresh_library_tracking), title = stringResource(R.string.pref_refresh_library_tracking),
subtitle = stringResource(R.string.pref_refresh_library_tracking_summary), subtitle = stringResource(R.string.pref_refresh_library_tracking_summary),
enabled = trackManager.hasLoggedServices(), enabled = trackerManager.hasLoggedIn(),
onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.TRACKING) }, onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.TRACKING) },
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(

View File

@ -123,6 +123,11 @@ object SettingsAppearanceScreen : SearchableSettings {
var currentLanguage by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") } var currentLanguage by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") }
val now = remember { Date().time } val now = remember { Date().time }
val dateFormat by uiPreferences.dateFormat().collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
}
LaunchedEffect(currentLanguage) { LaunchedEffect(currentLanguage) {
val locale = if (currentLanguage.isEmpty()) { val locale = if (currentLanguage.isEmpty()) {
LocaleListCompat.getEmptyLocaleList() LocaleListCompat.getEmptyLocaleList()
@ -162,6 +167,15 @@ object SettingsAppearanceScreen : SearchableSettings {
"${it.ifEmpty { stringResource(R.string.label_default) }} ($formattedDate)" "${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 showCreateDialog = false
flag = it flag = it
try { try {
chooseBackupDir.launch(Backup.getBackupFilename()) chooseBackupDir.launch(Backup.getFilename())
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
flag = 0 flag = 0
context.toast(R.string.file_picker_error) context.toast(R.string.file_picker_error)
@ -250,6 +250,8 @@ object SettingsBackupAndSyncScreen : SearchableSettings {
BackupConst.BACKUP_CHAPTER to R.string.chapters, BackupConst.BACKUP_CHAPTER to R.string.chapters,
BackupConst.BACKUP_TRACK to R.string.track, BackupConst.BACKUP_TRACK to R.string.track,
BackupConst.BACKUP_HISTORY to R.string.history, 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() } 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.presentation.more.settings.widget.TriStateListDialog
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob 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 eu.kanade.tachiyomi.ui.category.CategoryScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -199,7 +199,7 @@ object SettingsLibraryScreen : SearchableSettings {
), ),
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = libraryPreferences.autoUpdateTrackers(), pref = libraryPreferences.autoUpdateTrackers(),
enabled = Injekt.get<TrackManager>().hasLoggedServices(), enabled = Injekt.get<TrackerManager>().hasLoggedIn(),
title = stringResource(R.string.pref_library_update_refresh_trackers), title = stringResource(R.string.pref_library_update_refresh_trackers),
subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary), 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.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -305,12 +304,6 @@ object SettingsReaderScreen : SearchableSettings {
subtitle = stringResource(R.string.pref_dual_page_invert_summary), subtitle = stringResource(R.string.pref_dual_page_invert_summary),
enabled = dualPageSplit, 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( Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.webtoonDoubleTapZoomEnabled(), pref = readerPreferences.webtoonDoubleTapZoomEnabled(),
title = stringResource(R.string.pref_double_tap_zoom), title = stringResource(R.string.pref_double_tap_zoom),
@ -349,11 +342,6 @@ object SettingsReaderScreen : SearchableSettings {
pref = readerPreferences.readWithLongTap(), pref = readerPreferences.readWithLongTap(),
title = stringResource(R.string.pref_read_with_long_tap), 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.domain.track.service.TrackPreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
@ -70,7 +70,7 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable @Composable
override fun RowScope.AppBarAction() { override fun RowScope.AppBarAction() {
val uriHandler = LocalUriHandler.current 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( Icon(
imageVector = Icons.Outlined.HelpOutline, imageVector = Icons.Outlined.HelpOutline,
contentDescription = stringResource(R.string.tracking_guide), contentDescription = stringResource(R.string.tracking_guide),
@ -82,7 +82,7 @@ object SettingsTrackingScreen : SearchableSettings {
override fun getPreferences(): List<Preference> { override fun getPreferences(): List<Preference> {
val context = LocalContext.current val context = LocalContext.current
val trackPreferences = remember { Injekt.get<TrackPreferences>() } val trackPreferences = remember { Injekt.get<TrackPreferences>() }
val trackManager = remember { Injekt.get<TrackManager>() } val trackerManager = remember { Injekt.get<TrackerManager>() }
val sourceManager = remember { Injekt.get<SourceManager>() } val sourceManager = remember { Injekt.get<SourceManager>() }
var dialog by remember { mutableStateOf<Any?>(null) } var dialog by remember { mutableStateOf<Any?>(null) }
@ -90,24 +90,24 @@ object SettingsTrackingScreen : SearchableSettings {
when (this) { when (this) {
is LoginDialog -> { is LoginDialog -> {
TrackingLoginDialog( TrackingLoginDialog(
service = service, tracker = tracker,
uNameStringRes = uNameStringRes, uNameStringRes = uNameStringRes,
onDismissRequest = { dialog = null }, onDismissRequest = { dialog = null },
) )
} }
is LogoutDialog -> { is LogoutDialog -> {
TrackingLogoutDialog( TrackingLogoutDialog(
service = service, tracker = tracker,
onDismissRequest = { dialog = null }, onDismissRequest = { dialog = null },
) )
} }
} }
} }
val enhancedTrackers = trackManager.services val enhancedTrackers = trackerManager.trackers
.filter { it is EnhancedTrackService } .filter { it is EnhancedTracker }
.partition { service -> .partition { service ->
val acceptedSources = (service as EnhancedTrackService).getAcceptedSources() val acceptedSources = (service as EnhancedTracker).getAcceptedSources()
sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedSources } sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedSources }
} }
var enhancedTrackerInfo = stringResource(R.string.enhanced_tracking_info) var enhancedTrackerInfo = stringResource(R.string.enhanced_tracking_info)
@ -127,41 +127,41 @@ object SettingsTrackingScreen : SearchableSettings {
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(R.string.services), title = stringResource(R.string.services),
preferenceItems = listOf( preferenceItems = listOf(
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.myAnimeList.name, title = trackerManager.myAnimeList.name,
service = trackManager.myAnimeList, tracker = trackerManager.myAnimeList,
login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) }, login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.myAnimeList) }, logout = { dialog = LogoutDialog(trackerManager.myAnimeList) },
), ),
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.aniList.name, title = trackerManager.aniList.name,
service = trackManager.aniList, tracker = trackerManager.aniList,
login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) }, login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.aniList) }, logout = { dialog = LogoutDialog(trackerManager.aniList) },
), ),
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.kitsu.name, title = trackerManager.kitsu.name,
service = trackManager.kitsu, tracker = trackerManager.kitsu,
login = { dialog = LoginDialog(trackManager.kitsu, R.string.email) }, login = { dialog = LoginDialog(trackerManager.kitsu, R.string.email) },
logout = { dialog = LogoutDialog(trackManager.kitsu) }, logout = { dialog = LogoutDialog(trackerManager.kitsu) },
), ),
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.mangaUpdates.name, title = trackerManager.mangaUpdates.name,
service = trackManager.mangaUpdates, tracker = trackerManager.mangaUpdates,
login = { dialog = LoginDialog(trackManager.mangaUpdates, R.string.username) }, login = { dialog = LoginDialog(trackerManager.mangaUpdates, R.string.username) },
logout = { dialog = LogoutDialog(trackManager.mangaUpdates) }, logout = { dialog = LogoutDialog(trackerManager.mangaUpdates) },
), ),
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.shikimori.name, title = trackerManager.shikimori.name,
service = trackManager.shikimori, tracker = trackerManager.shikimori,
login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) }, login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackManager.shikimori) }, logout = { dialog = LogoutDialog(trackerManager.shikimori) },
), ),
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = trackManager.bangumi.name, title = trackerManager.bangumi.name,
service = trackManager.bangumi, tracker = trackerManager.bangumi,
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) }, 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)), Preference.PreferenceItem.InfoPreference(stringResource(R.string.tracking_info)),
), ),
@ -170,10 +170,10 @@ object SettingsTrackingScreen : SearchableSettings {
title = stringResource(R.string.enhanced_services), title = stringResource(R.string.enhanced_services),
preferenceItems = enhancedTrackers.first preferenceItems = enhancedTrackers.first
.map { service -> .map { service ->
Preference.PreferenceItem.TrackingPreference( Preference.PreferenceItem.TrackerPreference(
title = service.name, title = service.name,
service = service, tracker = service,
login = { (service as EnhancedTrackService).loginNoop() }, login = { (service as EnhancedTracker).loginNoop() },
logout = service::logout, logout = service::logout,
) )
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo)), } + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo)),
@ -183,15 +183,15 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable @Composable
private fun TrackingLoginDialog( private fun TrackingLoginDialog(
service: TrackService, tracker: Tracker,
@StringRes uNameStringRes: Int, @StringRes uNameStringRes: Int,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) } var username by remember { mutableStateOf(TextFieldValue(tracker.getUsername())) }
var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) } var password by remember { mutableStateOf(TextFieldValue(tracker.getPassword())) }
var processing by remember { mutableStateOf(false) } var processing by remember { mutableStateOf(false) }
var inputError by remember { mutableStateOf(false) } var inputError by remember { mutableStateOf(false) }
@ -200,7 +200,7 @@ object SettingsTrackingScreen : SearchableSettings {
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = stringResource(R.string.login_title, service.name), text = stringResource(R.string.login_title, tracker.name),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onDismissRequest) { IconButton(onClick = onDismissRequest) {
@ -264,7 +264,7 @@ object SettingsTrackingScreen : SearchableSettings {
processing = true processing = true
val result = checkLogin( val result = checkLogin(
context = context, context = context,
service = service, tracker = tracker,
username = username.text, username = username.text,
password = password.text, password = password.text,
) )
@ -283,16 +283,16 @@ object SettingsTrackingScreen : SearchableSettings {
private suspend fun checkLogin( private suspend fun checkLogin(
context: Context, context: Context,
service: TrackService, tracker: Tracker,
username: String, username: String,
password: String, password: String,
): Boolean { ): Boolean {
return try { return try {
service.login(username, password) tracker.login(username, password)
withUIContext { context.toast(R.string.login_success) } withUIContext { context.toast(R.string.login_success) }
true true
} catch (e: Throwable) { } catch (e: Throwable) {
service.logout() tracker.logout()
withUIContext { context.toast(e.message.toString()) } withUIContext { context.toast(e.message.toString()) }
false false
} }
@ -300,7 +300,7 @@ object SettingsTrackingScreen : SearchableSettings {
@Composable @Composable
private fun TrackingLogoutDialog( private fun TrackingLogoutDialog(
service: TrackService, tracker: Tracker,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -308,7 +308,7 @@ object SettingsTrackingScreen : SearchableSettings {
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = { title = {
Text( Text(
text = stringResource(R.string.logout_title, service.name), text = stringResource(R.string.logout_title, tracker.name),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -324,7 +324,7 @@ object SettingsTrackingScreen : SearchableSettings {
Button( Button(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = { onClick = {
service.logout() tracker.logout()
onDismissRequest() onDismissRequest()
context.toast(R.string.logout_success) context.toast(R.string.logout_success)
}, },
@ -342,10 +342,10 @@ object SettingsTrackingScreen : SearchableSettings {
} }
private data class LoginDialog( private data class LoginDialog(
val service: TrackService, val tracker: Tracker,
@StringRes val uNameStringRes: Int, @StringRes val uNameStringRes: Int,
) )
private data class LogoutDialog( 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 androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow 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.domain.ui.UiPreferences
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.LogoHeader 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.LinkIcon
import tachiyomi.presentation.core.components.ScrollbarLazyColumn import tachiyomi.presentation.core.components.ScrollbarLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.DateFormat import java.text.DateFormat
@ -149,7 +149,7 @@ object AboutScreen : Screen() {
item { item {
TextPreferenceWidget( TextPreferenceWidget(
title = stringResource(R.string.help_translate), 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 { item {
TextPreferenceWidget( TextPreferenceWidget(
title = stringResource(R.string.privacy_policy), 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( LinkIcon(
label = "Discord", label = "Discord",
icon = SimpleIcons.Discord, icon = CustomIcons.Discord,
url = "https://discord.gg/tachiyomi", url = "https://discord.gg/tachiyomi",
) )
LinkIcon( LinkIcon(
label = "Twitter", label = "X",
icon = SimpleIcons.Twitter, icon = CustomIcons.X,
url = "https://twitter.com/tachiyomiorg", url = "https://x.com/tachiyomiorg",
) )
LinkIcon( LinkIcon(
label = "Facebook", label = "Facebook",
icon = SimpleIcons.Facebook, icon = CustomIcons.Facebook,
url = "https://facebook.com/tachiyomiorg", url = "https://facebook.com/tachiyomiorg",
) )
LinkIcon( LinkIcon(
label = "Reddit", label = "Reddit",
icon = SimpleIcons.Reddit, icon = CustomIcons.Reddit,
url = "https://www.reddit.com/r/Tachiyomi", url = "https://www.reddit.com/r/Tachiyomi",
) )
LinkIcon( LinkIcon(
label = "GitHub", label = "GitHub",
icon = SimpleIcons.Github, icon = CustomIcons.Github,
url = "https://github.com/tachiyomiorg", url = "https://github.com/tachiyomiorg",
) )
} }

View File

@ -41,9 +41,9 @@ class OpenSourceLicensesScreen : Screen() {
), ),
onLibraryClick = { onLibraryClick = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen( val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
name = it.name, name = it.library.name,
website = it.website, website = it.library.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
) )
navigator.push(libraryLicenseScreen) 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.more.settings.LocalPreferenceHighlighted
import eu.kanade.presentation.track.components.TrackLogoIcon import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
@Composable @Composable
fun TrackingPreferenceWidget( fun TrackingPreferenceWidget(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
service: TrackService, tracker: Tracker,
checked: Boolean, checked: Boolean,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
@ -38,9 +38,9 @@ fun TrackingPreferenceWidget(
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp), .padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TrackLogoIcon(service) TrackLogoIcon(tracker)
Text( Text(
text = service.name, text = tracker.name,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),

View File

@ -1,25 +1,25 @@
package eu.kanade.presentation.reader package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.manga.model.orientationType import eu.kanade.domain.manga.model.orientationType
import eu.kanade.presentation.components.AdaptiveSheet import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import tachiyomi.presentation.core.components.SettingsChipRow import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.IconToggleButton
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it } private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
@ -32,22 +32,20 @@ fun OrientationModeSelectDialog(
val manga by screenModel.mangaFlow.collectAsState() val manga by screenModel.mangaFlow.collectAsState()
val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) } val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) }
AdaptiveSheet( AdaptiveSheet(onDismissRequest = onDismissRequest) {
onDismissRequest = onDismissRequest, Box(modifier = Modifier.padding(vertical = 16.dp)) {
) { SettingsIconGrid(R.string.rotation_type) {
Row( items(orientationTypeOptions) { (stringRes, mode) ->
modifier = Modifier.padding(vertical = 16.dp), IconToggleButton(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), checked = mode == orientationType,
) { onCheckedChange = {
SettingsChipRow(R.string.rotation_type) { screenModel.onChangeOrientation(mode)
orientationTypeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == orientationType,
onClick = {
screenModel.onChangeOrientation(it)
onChange(stringRes) 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.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun PageIndicatorText( fun PageIndicatorText(
currentPage: Int, currentPage: Int,

View File

@ -1,24 +1,25 @@
package eu.kanade.presentation.reader package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource 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.domain.manga.model.readingModeType
import eu.kanade.presentation.components.AdaptiveSheet import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType 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 import tachiyomi.presentation.core.components.material.padding
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it } private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
@ -32,22 +33,20 @@ fun ReadingModeSelectDialog(
val manga by screenModel.mangaFlow.collectAsState() val manga by screenModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) } val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) }
AdaptiveSheet( AdaptiveSheet(onDismissRequest = onDismissRequest) {
onDismissRequest = onDismissRequest, Box(modifier = Modifier.padding(vertical = MaterialTheme.padding.medium)) {
) { SettingsIconGrid(R.string.pref_category_reading_mode) {
Row( items(readingModeOptions) { (stringRes, mode) ->
modifier = Modifier.padding(vertical = 16.dp), IconToggleButton(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), checked = mode == readingMode,
) { onCheckedChange = {
SettingsChipRow(R.string.pref_category_reading_mode) { screenModel.onChangeReadingMode(mode)
readingModeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == readingMode,
onClick = {
screenModel.onChangeReadingMode(it)
onChange(stringRes) 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.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer 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.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.SettingsChipRow 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( CheckboxItem(
label = stringResource(R.string.pref_double_tap_zoom), label = stringResource(R.string.pref_double_tap_zoom),
pref = screenModel.preferences.webtoonDoubleTapZoomEnabled(), 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.components.DropdownMenu
import eu.kanade.presentation.track.components.TrackLogoIcon import eu.kanade.presentation.track.components.TrackLogoIcon
import eu.kanade.tachiyomi.R 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.ui.manga.track.TrackItem
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import java.text.DateFormat import java.text.DateFormat
@ -80,12 +80,12 @@ fun TrackInfoDialogHome(
) { ) {
trackItems.forEach { item -> trackItems.forEach { item ->
if (item.track != null) { if (item.track != null) {
val supportsScoring = item.service.getScoreList().isNotEmpty() val supportsScoring = item.tracker.getScoreList().isNotEmpty()
val supportsReadingDates = item.service.supportsReadingDates val supportsReadingDates = item.tracker.supportsReadingDates
TrackInfoItem( TrackInfoItem(
title = item.track.title, title = item.track.title,
service = item.service, tracker = item.tracker,
status = item.service.getStatus(item.track.status.toInt()), status = item.tracker.getStatus(item.track.status.toInt()),
onStatusClick = { onStatusClick(item) }, onStatusClick = { onStatusClick(item) },
chapters = "${item.track.lastChapterRead.toInt()}".let { chapters = "${item.track.lastChapterRead.toInt()}".let {
val totalChapters = item.track.totalChapters val totalChapters = item.track.totalChapters
@ -97,7 +97,7 @@ fun TrackInfoDialogHome(
} }
}, },
onChaptersClick = { onChapterClick(item) }, onChaptersClick = { onChapterClick(item) },
score = item.service.displayScore(item.track.toDbTrack()) score = item.tracker.displayScore(item.track.toDbTrack())
.takeIf { supportsScoring && item.track.score != 0.0 }, .takeIf { supportsScoring && item.track.score != 0.0 },
onScoreClick = { onScoreClick(item) } onScoreClick = { onScoreClick(item) }
.takeIf { supportsScoring }, .takeIf { supportsScoring },
@ -115,7 +115,7 @@ fun TrackInfoDialogHome(
) )
} else { } else {
TrackInfoItemEmpty( TrackInfoItemEmpty(
service = item.service, tracker = item.tracker,
onNewSearch = { onNewSearch(item) }, onNewSearch = { onNewSearch(item) },
) )
} }
@ -126,7 +126,7 @@ fun TrackInfoDialogHome(
@Composable @Composable
private fun TrackInfoItem( private fun TrackInfoItem(
title: String, title: String,
service: TrackService, tracker: Tracker,
@StringRes status: Int?, @StringRes status: Int?,
onStatusClick: () -> Unit, onStatusClick: () -> Unit,
chapters: String, chapters: String,
@ -147,7 +147,7 @@ private fun TrackInfoItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TrackLogoIcon( TrackLogoIcon(
service = service, tracker = tracker,
onClick = onOpenInBrowser, onClick = onOpenInBrowser,
) )
Box( Box(
@ -260,13 +260,13 @@ private fun TrackDetailsItem(
@Composable @Composable
private fun TrackInfoItemEmpty( private fun TrackInfoItemEmpty(
service: TrackService, tracker: Tracker,
onNewSearch: () -> Unit, onNewSearch: () -> Unit,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TrackLogoIcon(service) TrackLogoIcon(tracker)
TextButton( TextButton(
onClick = onNewSearch, onClick = onNewSearch,
modifier = Modifier modifier = Modifier

View File

@ -70,7 +70,7 @@ import tachiyomi.presentation.core.util.runOnEnterKeyPressed
import tachiyomi.presentation.core.util.secondaryItemAlpha import tachiyomi.presentation.core.util.secondaryItemAlpha
@Composable @Composable
fun TrackServiceSearch( fun TrackerSearch(
query: TextFieldValue, query: TextFieldValue,
onQueryChange: (TextFieldValue) -> Unit, onQueryChange: (TextFieldValue) -> Unit,
onDispatchQuery: () -> Unit, onDispatchQuery: () -> Unit,
@ -223,6 +223,7 @@ private fun SearchResultItem(
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.clip(shape) .clip(shape)
.background(MaterialTheme.colorScheme.surface) .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.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp 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 import tachiyomi.presentation.core.util.clickableNoIndication
@Composable @Composable
fun TrackLogoIcon( fun TrackLogoIcon(
service: TrackService, tracker: Tracker,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val modifier = if (onClick != null) { val modifier = if (onClick != null) {
@ -29,13 +29,13 @@ fun TrackLogoIcon(
Box( Box(
modifier = modifier modifier = modifier
.size(48.dp) .size(48.dp)
.background(color = Color(service.getLogoColor()), shape = MaterialTheme.shapes.medium) .background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium)
.padding(4.dp), .padding(4.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Image(
painter = painterResource(service.getLogo()), painter = painterResource(tracker.getLogo()),
contentDescription = service.name, contentDescription = tracker.name,
) )
} }
} }

View File

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

View File

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

View File

@ -175,7 +175,7 @@ fun WebViewScreenContent(
WarningBanner( WarningBanner(
textRes = R.string.information_cloudflare_help, textRes = R.string.information_cloudflare_help,
modifier = Modifier.clickable { 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.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.saver.ImageSaver 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.sync.service.GoogleDriveService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.JavaScriptEngine import eu.kanade.tachiyomi.network.JavaScriptEngine
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
@ -134,7 +134,7 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { DownloadManager(app) } addSingletonFactory { DownloadManager(app) }
addSingletonFactory { DownloadCache(app) } addSingletonFactory { DownloadCache(app) }
addSingletonFactory { TrackManager(app) } addSingletonFactory { TrackerManager() }
addSingletonFactory { DelayedTrackingStore(app) } addSingletonFactory { DelayedTrackingStore(app) }
addSingletonFactory { ImageSaver(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.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.BackupCreateJob import eu.kanade.tachiyomi.data.backup.BackupCreateJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob 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.NetworkPreferences
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.system.DeviceUtil 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.toast
import eu.kanade.tachiyomi.util.system.workManager import eu.kanade.tachiyomi.util.system.workManager
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.TriState import tachiyomi.core.preference.TriState
import tachiyomi.core.preference.getAndSet import tachiyomi.core.preference.getAndSet
@ -47,7 +47,7 @@ object Migrations {
libraryPreferences: LibraryPreferences, libraryPreferences: LibraryPreferences,
readerPreferences: ReaderPreferences, readerPreferences: ReaderPreferences,
backupPreferences: BackupPreferences, backupPreferences: BackupPreferences,
trackManager: TrackManager, trackerManager: TrackerManager,
): Boolean { ): Boolean {
val lastVersionCode = preferenceStore.getInt("last_version_code", 0) val lastVersionCode = preferenceStore.getInt("last_version_code", 0)
val oldVersion = lastVersionCode.get() val oldVersion = lastVersionCode.get()
@ -135,8 +135,8 @@ object Migrations {
// Force MAL log out due to login flow change // Force MAL log out due to login flow change
// v52: switched from scraping to WebView // v52: switched from scraping to WebView
// v53: switched from WebView to OAuth // v53: switched from WebView to OAuth
if (trackManager.myAnimeList.isLoggedIn) { if (trackerManager.myAnimeList.isLoggedIn) {
trackManager.myAnimeList.logout() trackerManager.myAnimeList.logout()
context.toast(R.string.myanimelist_relogin) context.toast(R.string.myanimelist_relogin)
} }
} }
@ -342,7 +342,7 @@ object Migrations {
"pref_filter_library_started", "pref_filter_library_started",
"pref_filter_library_bookmarked", "pref_filter_library_bookmarked",
"pref_filter_library_completed", "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 -> prefKeys.forEach { key ->
val pref = preferenceStore.getInt(key, 0) val pref = preferenceStore.getInt(key, 0)
@ -362,19 +362,31 @@ object Migrations {
if (oldVersion < 100) { if (oldVersion < 100) {
BackupCreateJob.setupTask(context) 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) { if (oldVersion < 105) {
val pref = libraryPreferences.autoUpdateDeviceRestrictions() val pref = libraryPreferences.autoUpdateDeviceRestrictions()
if (pref.isSet() && "battery_not_low" in pref.get()) { if (pref.isSet() && "battery_not_low" in pref.get()) {
pref.getAndSet { it - "battery_not_low" } 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 return true
} }

View File

@ -4,11 +4,21 @@ package eu.kanade.tachiyomi.data.backup
internal object BackupConst { internal object BackupConst {
const val BACKUP_CATEGORY = 0x1 const val BACKUP_CATEGORY = 0x1
const val BACKUP_CATEGORY_MASK = 0x1 const val BACKUP_CATEGORY_MASK = 0x1
const val BACKUP_CHAPTER = 0x2 const val BACKUP_CHAPTER = 0x2
const val BACKUP_CHAPTER_MASK = 0x2 const val BACKUP_CHAPTER_MASK = 0x2
const val BACKUP_HISTORY = 0x4 const val BACKUP_HISTORY = 0x4
const val BACKUP_HISTORY_MASK = 0x4 const val BACKUP_HISTORY_MASK = 0x4
const val BACKUP_TRACK = 0x8 const val BACKUP_TRACK = 0x8
const val BACKUP_TRACK_MASK = 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 { 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())) if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri()))
Result.success() Result.success()
} catch (e: Exception) { } 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.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.R 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 eu.kanade.tachiyomi.util.BackupUtil
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -11,7 +11,7 @@ import uy.kohesive.injekt.api.get
class BackupFileValidator( class BackupFileValidator(
private val sourceManager: SourceManager = Injekt.get(), 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 } .map { it.syncId }
.distinct() .distinct()
val missingTrackers = trackers val missingTrackers = trackers
.mapNotNull { trackManager.getService(it.toLong()) } .mapNotNull { trackerManager.get(it.toLong()) }
.filter { !it.isLoggedIn } .filter { !it.isLoggedIn }
.map { it.name } .map { it.name }
.sorted() .sorted()
@ -58,5 +58,8 @@ class BackupFileValidator(
return Results(missingSources, missingTrackers) 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 { override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure() ?: return Result.failure()
val sync = inputData.getBoolean(SYNC, false) val sync = inputData.getBoolean(SYNC_KEY, false)
try { try {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@ -67,7 +67,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
fun start(context: Context, uri: Uri, sync: Boolean = false) { fun start(context: Context, uri: Uri, sync: Boolean = false) {
val inputData = workDataOf( val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
SYNC to sync, SYNC_KEY to sync,
) )
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG) .addTag(TAG)
@ -85,5 +85,4 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
private const val TAG = "BackupRestore" private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String private const val LOCATION_URI_KEY = "location_uri" // String
private const val SYNC_KEY = "sync" // Boolean
private const val SYNC = "sync" // Boolean

View File

@ -2,19 +2,38 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.domain.chapter.model.copyFrom
import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga 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.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.BackupUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive 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.model.Chapter
import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.manga.interactor.SetFetchInterval import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track import tachiyomi.domain.track.model.Track
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -24,19 +43,23 @@ import java.text.SimpleDateFormat
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.max
class BackupRestorer( class BackupRestorer(
private val context: Context, private val context: Context,
private val notifier: BackupNotifier, private val notifier: BackupNotifier,
) { ) {
private val handler: DatabaseHandler = Injekt.get()
private val updateManga: UpdateManga = Injekt.get() private val updateManga: UpdateManga = Injekt.get()
private val chapterRepository: ChapterRepository = Injekt.get() private val getCategories: GetCategories = Injekt.get()
private val setFetchInterval: SetFetchInterval = 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 now = ZonedDateTime.now()
private var currentFetchWindow = setFetchInterval.getWindow(now) private var currentFetchWindow = fetchInterval.getWindow(now)
private var backupManager = BackupManager(context)
private var restoreAmount = 0 private var restoreAmount = 0
private var restoreProgress = 0 private var restoreProgress = 0
@ -92,7 +115,7 @@ class BackupRestorer(
private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean { private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean {
val backup = BackupUtil.decodeBackup(context, uri) 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 // Restore categories
if (backup.backupCategories.isNotEmpty()) { if (backup.backupCategories.isNotEmpty()) {
@ -103,9 +126,12 @@ class BackupRestorer(
val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources
sourceMapping = backupMaps.associate { it.sourceId to it.name } sourceMapping = backupMaps.associate { it.sourceId to it.name }
now = ZonedDateTime.now() now = ZonedDateTime.now()
currentFetchWindow = setFetchInterval.getWindow(now) currentFetchWindow = fetchInterval.getWindow(now)
return coroutineScope { return coroutineScope {
restoreAppPreferences(backup.backupPreferences)
restoreSourcePreferences(backup.backupSourcePreferences)
// Restore individual manga // Restore individual manga
backup.backupManga.forEach { backup.backupManga.forEach {
if (!isActive) { if (!isActive) {
@ -115,12 +141,44 @@ class BackupRestorer(
restoreManga(it, backup.backupCategories, sync) restoreManga(it, backup.backupCategories, sync)
} }
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
true true
} }
} }
private suspend fun restoreCategories(backupCategories: List<BackupCategory>) { 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 restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup)) showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup))
@ -135,14 +193,14 @@ class BackupRestorer(
val tracks = backupManga.getTrackingImpl() val tracks = backupManga.getTrackingImpl()
try { try {
val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) val dbManga = getMangaFromDatabase(manga.url, manga.source)
val restoredManga = if (dbManga == null) { val restoredManga = if (dbManga == null) {
// Manga not in database // Manga not in database
restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories)
} else { } else {
// Manga in database // Manga in database
// Copy information from manga already 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 // Fetch rest of manga information
restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) 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 * Fetches manga information
* *
@ -175,12 +277,131 @@ class BackupRestorer(
tracks: List<Track>, tracks: List<Track>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
): Manga { ): Manga {
val fetchedManga = backupManager.restoreNewManga(manga) val fetchedManga = restoreNewManga(manga)
backupManager.restoreChapters(fetchedManga, chapters) restoreChapters(fetchedManga, chapters)
restoreExtras(fetchedManga, categories, history, tracks, backupCategories) restoreExtras(fetchedManga, categories, history, tracks, backupCategories)
return fetchedManga 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( private suspend fun restoreNewManga(
backupManga: Manga, backupManga: Manga,
chapters: List<Chapter>, chapters: List<Chapter>,
@ -189,24 +410,240 @@ class BackupRestorer(
tracks: List<Track>, tracks: List<Track>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
): Manga { ): Manga {
backupManager.restoreChapters(backupManga, chapters) restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories) restoreExtras(backupManga, categories, history, tracks, backupCategories)
return backupManga return backupManga
} }
private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) { private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
backupManager.restoreCategories(manga, categories, backupCategories) restoreCategories(manga, categories, backupCategories)
backupManager.restoreHistory(history) restoreHistory(history)
backupManager.restoreTracking(manga, tracks) restoreTracking(manga, tracks)
} }
/** /**
* Called to update dialog in [BackupConst] * Restores the categories a manga is in.
* *
* @param progress restore progress * @param manga the manga whose categories have to be restored.
* @param amount total restoreAmount of manga * @param categories the categories to restore.
* @param title title of restored manga
*/ */
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) { private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) {
notifier.showRestoreProgress(title, contentTitle, progress, amount) notifier.showRestoreProgress(title, contentTitle, progress, amount)
} }

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import eu.kanade.tachiyomi.BuildConfig
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -10,15 +11,18 @@ import java.util.Locale
data class Backup( data class Backup(
@ProtoNumber(1) val backupManga: List<BackupManga>, @ProtoNumber(1) val backupManga: List<BackupManga>,
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(), @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(100) var backupBrokenSources: List<BrokenBackupSource> = emptyList(),
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
) { ) {
companion object { 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()) 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(1) var name: String,
@ProtoNumber(2) var order: Long = 0, @ProtoNumber(2) var order: Long = 0,
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x // @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, @ProtoNumber(100) var flags: Long = 0,
) { ) {
fun getCategory(): Category { 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 eu.kanade.tachiyomi.util.storage.saveTo
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.Response import okhttp3.Response
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
@ -97,6 +99,7 @@ class ChapterCache(private val context: Context) {
editor.commit() editor.commit()
editor.abortUnlessCommitted() editor.abortUnlessCommitted()
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to put page list to cache" }
// Ignore. // Ignore.
} finally { } finally {
editor?.abortUnlessCommitted() editor?.abortUnlessCommitted()
@ -174,7 +177,7 @@ class ChapterCache(private val context: Context) {
* @return status of deletion for the file. * @return status of deletion for the file.
*/ */
private fun removeFileFromCache(file: String): Boolean { 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.")) { if (file == "journal" || file.startsWith("journal.")) {
return false return false
} }
@ -182,9 +185,10 @@ class ChapterCache(private val context: Context) {
return try { return try {
// Remove the extension from the file to get the key of the cache // Remove the extension from the file to get the key of the cache
val key = file.substringBeforeLast(".") val key = file.substringBeforeLast(".")
// Remove file from cache. // Remove file from cache
diskCache.remove(key) diskCache.remove(key)
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Failed to remove file from cache" }
false false
} }
} }

View File

@ -43,7 +43,6 @@ import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
@ -363,7 +362,7 @@ class Downloader(
if (page.imageUrl.isNullOrEmpty()) { if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE page.status = Page.State.LOAD_PAGE
try { try {
page.imageUrl = download.source.fetchImageUrl(page).awaitSingle() page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) { } catch (e: Throwable) {
page.status = Page.State.ERROR 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_COMPLETED
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_READ import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_READ
import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD 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.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.SetFetchInterval
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.toMangaUpdate import tachiyomi.domain.manga.model.toMangaUpdate
import tachiyomi.domain.source.model.SourceNotInstalledException 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 getCategories: GetCategories = Injekt.get()
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get() private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
private val refreshTracks: RefreshTracks = 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) private val notifier = LibraryUpdateNotifier(context)
@ -186,7 +186,40 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
.distinctBy { it.manga.id } .distinctBy { it.manga.id }
} }
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get()
val skippedUpdates = mutableListOf<Pair<Manga, String?>>()
val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
mangaToUpdate = listToUpdate 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 } .sortedBy { it.manga.title }
// Warn when excessively checking a single source // 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) { if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
notifier.showQueueSizeWarningNotification() 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 progressCount = AtomicInteger(0)
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>() val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>() val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get() val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now())
val fetchWindow = setFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope { coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values mangaToUpdate.groupBy { it.manga.source }.values
@ -237,49 +279,30 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
progressCount, progressCount,
manga, manga,
) { ) {
when { try {
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> val newChapters = updateManga(manga, fetchWindow)
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update)) .sortedByDescending { it.sourceOrder }
MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED -> if (newChapters.isNotEmpty()) {
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed)) val categoryIds = getCategories.await(manga.id).map { it.id }
if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L -> downloadChapters(manga, newChapters)
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_caught_up)) hasDownloads.set(true)
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)
} }
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()) { if (libraryPreferences.autoUpdateTrackers().get()) {
@ -309,16 +332,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
errorFile.getUriCompat(context), 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>) { private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
@ -428,29 +441,27 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
completed: AtomicInteger, completed: AtomicInteger,
manga: Manga, manga: Manga,
block: suspend () -> Unit, block: suspend () -> Unit,
) { ) = coroutineScope {
coroutineScope { ensureActive()
ensureActive()
updatingManga.add(manga) updatingManga.add(manga)
notifier.showProgressNotification( notifier.showProgressNotification(
updatingManga, updatingManga,
completed.get(), completed.get(),
mangaToUpdate.size, mangaToUpdate.size,
) )
block() block()
ensureActive() ensureActive()
updatingManga.remove(manga) updatingManga.remove(manga)
completed.getAndIncrement() completed.getAndIncrement()
notifier.showProgressNotification( notifier.showProgressNotification(
updatingManga, updatingManga,
completed.get(), completed.get(),
mangaToUpdate.size, 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_AUTO = "LibraryUpdate-auto"
private const val WORK_NAME_MANUAL = "LibraryUpdate-manual" 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 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.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.NumberFormat
class LibraryUpdateNotifier(private val context: Context) { class LibraryUpdateNotifier(private val context: Context) {
private val preferences: SecurityPreferences by injectLazy() private val preferences: SecurityPreferences by injectLazy()
private val percentFormatter = NumberFormat.getPercentInstance().apply {
maximumFractionDigits = 0
}
/** /**
* Pending intent of action that cancels the library update * Pending intent of action that cancels the library update
@ -78,7 +82,7 @@ class LibraryUpdateNotifier(private val context: Context) {
} else { } else {
val updatingText = manga.joinToString("\n") { it.title.chop(40) } val updatingText = manga.joinToString("\n") { it.title.chop(40) }
progressNotificationBuilder 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)) .setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
} }
@ -329,11 +333,11 @@ class LibraryUpdateNotifier(private val context: Context) {
} }
companion object { 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_MAX_CHAPTERS = 5
private const val NOTIF_TITLE_MAX_LEN = 45 private const val NOTIF_TITLE_MAX_LEN = 45
private const val NOTIF_ICON_SIZE = 192 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 package eu.kanade.tachiyomi.data.saver
import android.annotation.SuppressLint
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
@ -28,30 +27,59 @@ class ImageSaver(
val context: Context, val context: Context,
) { ) {
@SuppressLint("InlinedApi")
fun save(image: Image): Uri { fun save(image: Image): Uri {
val data = image.data 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}") val filename = DiskUtil.buildValidFilename("${image.name}.${type.extension}")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) {
return save(data(), image.location.directory(context), filename) 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 = val pictureDir =
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) 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 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( val contentValues = contentValuesOf(
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name, MediaStore.Images.Media.DISPLAY_NAME to image.name,
MediaStore.Images.Media.MIME_TYPE to type.mime, 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( context.contentResolver.insert(
pictureDir, pictureDir,
contentValues, contentValues,
@ -74,49 +102,34 @@ class ImageSaver(
return picture 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) @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( val projection = arrayOf(
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.RELATIVE_PATH,
MediaStore.MediaColumns.DATE_MODIFIED,
) )
val selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND ${MediaStore.MediaColumns.DISPLAY_NAME}=?" 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( context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selection, selection,
arrayOf(relativePath, imagePath), arrayOf(normalizedPath, filename),
null, null,
).use { cursor -> ).use { cursor ->
if (cursor != null && cursor.count >= 1) { if (cursor != null && cursor.count >= 1) {
cursor.moveToFirst().let { if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
return ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) return ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
} }
} }
} }
return default() return default()
} }
} }
@ -153,19 +166,12 @@ sealed class Image(
} }
sealed interface Location { sealed interface Location {
data class Pictures private constructor(val relativePath: String) : Location { data class Pictures(val relativePath: String) : Location
companion object {
fun create(relativePath: String = ""): Pictures {
return Pictures(relativePath)
}
}
}
data object Cache : Location data object Cache : Location
fun directory(context: Context): File { fun directory(context: Context): File {
return when (this) { return when (this) {
Cache -> context.cacheImageDir
is Pictures -> { is Pictures -> {
val file = File( val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
@ -179,6 +185,7 @@ sealed interface Location {
} }
file file
} }
Cache -> context.cacheImageDir
} }
} }
} }

View File

@ -3,9 +3,9 @@ package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.database.models.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 suspend fun delete(track: Track): Track
} }

View File

@ -6,31 +6,32 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track import tachiyomi.domain.track.model.Track
/** /**
* An Enhanced Track Service will never prompt the user to match a manga with the remote. * A tracker that will never prompt the user to manually bind an entry.
* It is expected that such Track Service can only work with specific sources and unique IDs. * 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 { fun accept(source: Source): Boolean {
return source::class.qualifiedName in getAcceptedSources() 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 getAcceptedSources(): List<String>
fun loginNoop() 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? 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 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.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes 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.toDbTrack
import eu.kanade.domain.track.model.toDomainTrack import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
@ -28,7 +28,7 @@ import uy.kohesive.injekt.injectLazy
import java.time.ZoneOffset import java.time.ZoneOffset
import tachiyomi.domain.track.model.Track as DomainTrack 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 trackPreferences: TrackPreferences by injectLazy()
val networkService: NetworkHelper by injectLazy() val networkService: NetworkHelper by injectLazy()
@ -83,7 +83,7 @@ abstract class TrackService(val id: Long, val name: String) {
@CallSuper @CallSuper
open fun logout() { open fun logout() {
trackPreferences.setTrackCredentials(this, "", "") trackPreferences.setCredentials(this, "", "")
} }
open val isLoggedIn: Boolean open val isLoggedIn: Boolean
@ -95,7 +95,7 @@ abstract class TrackService(val id: Long, val name: String) {
fun getPassword() = trackPreferences.trackPassword(this).get() fun getPassword() = trackPreferences.trackPassword(this).get()
fun saveCredentials(username: String, password: String) { 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 // 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) insertTrack.await(track)
// TODO: merge into SyncChaptersWithTrackServiceTwoWay? // TODO: merge into [SyncChapterProgressWithTrack]?
// Update chapter progress if newer chapters marked read locally // Update chapter progress if newer chapters marked read locally
if (hasReadChapters) { if (hasReadChapters) {
val latestLocalReadChapterNumber = allChapters 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) { } catch (e: Throwable) {
withUIContext { Injekt.get<Application>().toast(e.message) } withUIContext { Injekt.get<Application>().toast(e.message) }

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.kavita.Kavita 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.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi
class TrackManager(context: Context) { class TrackerManager {
companion object { companion object {
const val MYANIMELIST = 1L
const val ANILIST = 2L const val ANILIST = 2L
const val KITSU = 3L 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 KAVITA = 8L
const val SUWAYOMI = 9L
} }
val myAnimeList = MyAnimeList(MYANIMELIST) val myAnimeList = MyAnimeList(1L)
val aniList = Anilist(ANILIST) val aniList = Anilist(ANILIST)
val kitsu = Kitsu(KITSU) val kitsu = Kitsu(KITSU)
val shikimori = Shikimori(SHIKIMORI) val shikimori = Shikimori(4L)
val bangumi = Bangumi(BANGUMI) val bangumi = Bangumi(5L)
val komga = Komga(KOMGA) val komga = Komga(6L)
val mangaUpdates = MangaUpdates(MANGA_UPDATES) val mangaUpdates = MangaUpdates(7L)
val kavita = Kavita(context, KAVITA) val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(SUWAYOMI) 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 androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack 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 { companion object {
const val READING = 1 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.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track 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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -20,7 +20,7 @@ data class ALManga(
val total_chapters: Int, val total_chapters: Int,
) { ) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply {
media_id = this@ALManga.media_id media_id = this@ALManga.media_id
title = title_user_pref title = title_user_pref
total_chapters = this@ALManga.total_chapters total_chapters = this@ALManga.total_chapters
@ -50,7 +50,7 @@ data class ALUserManga(
val manga: ALManga, val manga: ALManga,
) { ) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply { fun toTrack() = Track.create(TrackerManager.ANILIST).apply {
media_id = manga.media_id media_id = manga.media_id
title = manga.title_user_pref title = manga.title_user_pref
status = toTrackStatus() status = toTrackStatus()
@ -62,7 +62,7 @@ data class ALUserManga(
total_chapters = manga.total_chapters total_chapters = manga.total_chapters
} }
fun toTrackStatus() = when (list_status) { private fun toTrackStatus() = when (list_status) {
"CURRENT" -> Anilist.READING "CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED "COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD "PAUSED" -> Anilist.ON_HOLD

View File

@ -4,19 +4,19 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track 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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy 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 json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this) } 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> { override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString) 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 android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track 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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
@ -26,7 +25,11 @@ import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets 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() private val json: Json by injectLazy()
@ -105,7 +108,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} else { } else {
0 0
} }
return TrackSearch.create(TrackManager.BANGUMI).apply { return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long media_id = obj["id"]!!.jsonPrimitive.long
title = obj["name_cn"]!!.jsonPrimitive.content title = obj["name_cn"]!!.jsonPrimitive.content
cover_url = coverUrl cover_url = coverUrl

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,13 +4,13 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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 { companion object {
const val READING_LIST = 0 const val READING_LIST = 0

View File

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

View File

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

View File

@ -4,14 +4,14 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.DeletableTrackService import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Shikimori(id: Long) : TrackService(id, "Shikimori"), DeletableTrackService { class Shikimori(id: Long) : Tracker(id, "Shikimori"), DeletableTracker {
companion object { companion object {
const val READING = 1 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 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> { override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString) 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 androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track 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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
@ -28,7 +27,11 @@ import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy 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() private val json: Json by injectLazy()
@ -96,7 +99,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKIMORI).apply { return TrackSearch.create(trackId).apply {
media_id = obj["id"]!!.jsonPrimitive.long media_id = obj["id"]!!.jsonPrimitive.long
title = obj["name"]!!.jsonPrimitive.content title = obj["name"]!!.jsonPrimitive.content
total_chapters = obj["chapters"]!!.jsonPrimitive.int 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 { private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKIMORI).apply { return Track.create(trackId).apply {
title = mangas["name"]!!.jsonPrimitive.content title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.long media_id = obj["id"]!!.jsonPrimitive.long
total_chapters = mangas["chapters"]!!.jsonPrimitive.int total_chapters = mangas["chapters"]!!.jsonPrimitive.int

View File

@ -4,16 +4,16 @@ import android.graphics.Color
import androidx.annotation.StringRes import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.manga.model.Manga as DomainManga import tachiyomi.domain.manga.model.Manga as DomainManga
import tachiyomi.domain.track.model.Track as DomainTrack 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 override fun getLogo() = R.drawable.ic_tracker_suwayomi

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.data.track.suwayomi package eu.kanade.tachiyomi.data.track.suwayomi
import android.app.Application import android.app.Application
import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import eu.kanade.tachiyomi.data.database.models.Track 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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
@ -23,7 +23,7 @@ import uy.kohesive.injekt.injectLazy
import java.nio.charset.Charset import java.nio.charset.Charset
import java.security.MessageDigest import java.security.MessageDigest
class TachideskApi { class SuwayomiApi(private val trackId: Long) {
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
private val json: Json by injectLazy() private val json: Json by injectLazy()
@ -61,7 +61,7 @@ class TachideskApi {
.parseAs<MangaDataClass>() .parseAs<MangaDataClass>()
} }
TrackSearch.create(TrackManager.SUWAYOMI).apply { TrackSearch.create(trackId).apply {
title = manga.title title = manga.title
cover_url = "$url/thumbnail" cover_url = "$url/thumbnail"
summary = manga.description.orEmpty() summary = manga.description.orEmpty()
@ -100,26 +100,24 @@ class TachideskApi {
return getTrackSearch(track.tracking_url) return getTrackSearch(track.tracking_url)
} }
private val tachideskExtensionId by lazy { private val sourceId by lazy {
val key = "tachidesk/en/1" val key = "tachidesk/en/1"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) 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 (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 { 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 getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!! private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!!
private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_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)) setContentTitle(context.getString(R.string.update_check_notification_update_available))
setContentText(context.getString(R.string.update_check_fdroid_migration_info)) setContentText(context.getString(R.string.update_check_fdroid_migration_info))
setSmallIcon(R.drawable.ic_tachi) 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) notificationBuilder.show(Notifications.ID_APP_UPDATE_PROMPT)
} }

View File

@ -8,6 +8,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
@ -100,7 +101,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic
} }
init { 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 isRegistered = true
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) 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.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.toStubSource(): StubSource = StubSource(id = id, lang = lang, name = name)
fun Source.getNameForMangaInfo(): String { fun Source.getNameForMangaInfo(): String {

View File

@ -102,7 +102,7 @@ class ExtensionDetailsScreenModel(
val extension = state.value.extension ?: return "" val extension = state.value.extension ?: return ""
if (!extension.hasReadme) { 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.") 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.R
import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore
import eu.kanade.tachiyomi.source.ConfigurableSource 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 eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@ -134,12 +134,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() {
private fun populateScreen(): PreferenceScreen { private fun populateScreen(): PreferenceScreen {
val sourceId = requireArguments().getLong(SOURCE_ID) 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 dataStore = SharedPreferencesDataStore(source.sourcePreferences())
val sharedPreferences = requireContext().getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
val dataStore = SharedPreferencesDataStore(sharedPreferences)
preferenceManager.preferenceDataStore = dataStore preferenceManager.preferenceDataStore = dataStore
val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) 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.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
@ -177,7 +177,7 @@ internal class MigrateDialogScreenModel(
} }
private val enhancedServices by lazy { private val enhancedServices by lazy {
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>()
} }
suspend fun migrateManga( suspend fun migrateManga(

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