Compare commits

...

71 Commits

Author SHA1 Message Date
1f79444a53 Fix sources not loading 2022-08-14 11:49:19 -04:00
8811d951d0 Release v0.13.6 2022-08-14 10:32:04 -04:00
a89651810d Don't allow swiping away app update install notification
Based on 85ef40d0ff
2022-08-13 15:15:14 -04:00
431c04e54f Detect identical mangas when long pressing to add to library (#7095)
* Detect identical mangas when long pressing to add to library

* Use extracted duplicate manga dialog to avoid duplication

* Partially revert previous commit

* Review changes

* Review changes part 2

(cherry picked from commit f1afeac0bc)
2022-08-13 15:15:01 -04:00
f461c71625 Fix Links to Changelog/Readme/Commits for multisrc (#7252)
* Fix Links to Changelog/Readme/Commits for `multisrc`

working basic fix. Needs to be refactored into `createUrl()`

* Refactor back into `createUrl`

hopefully the logic is understandable
there's three cases:
 - when multisrc, if `path` isn't mentioned, then we're trying to open
   commmit history
 - when multisrc, if `path` is mentioned, then its either a changelog or
   a readme to a multisrc extension, the files are stored in the
   `overrides` subfolder
 - when not multisrc, we're looking at a single source where the links
   are constructed in the same way regardless of it being
   changelog/readme/commit history

(cherry picked from commit e7695aef78)
2022-08-13 15:05:50 -04:00
b635789740 Actually compare chapter numbers as numbers when sorting (fixes #7247)
(cherry picked from commit da8669c826)
2022-08-13 15:05:23 -04:00
f00e03e5ea New: Migrating titles maintains custom covers (#7196)
* New: Migrating titles maintains custom covers #7189

* Added Custom Covers to MigrationFlags.kt, strings.xml

* Reworded covers --> cover

* Updated logic to show/hide Migration flags titles depending on manga.

(cherry picked from commit 5ea03fad87)
2022-08-13 15:03:21 -04:00
6db2becd30 Add auto split tall images setting
Also includes some fixes for bad merges in earlier commits

Co-authored-by: Saud-97 <Saud-97@users.noreply.github.com>
Co-authored-by: AntsyLich <AntsyLich@users.noreply.github.com>
2022-08-13 14:56:08 -04:00
e58945a209 Log extension loading errors directly (#7716)
(cherry picked from commit 7892cc1519)
2022-08-13 13:17:41 -04:00
03e4eb1061 Add missing Authorization header on MAL refresh token request (#7686)
* Add missing Authorization header on MAL refresh token request.

* Make sure to also close the response when it have failed.

(cherry picked from commit 5315467908)
2022-08-13 13:16:55 -04:00
09a3509d79 Filter out empty genres before saving manga to database (#7655)
(cherry picked from commit 4efb736e56)
2022-08-13 13:16:00 -04:00
b3a11eca0f Remove deprecated LibrarySort (#7659)
* Remove deprecated LibrarySort

* Apply suggestions from code review

(cherry picked from commit 58acf0a8aa)
2022-08-13 13:15:50 -04:00
650c2dc6e7 Fix logic for searchWithGenre (#7559)
(cherry picked from commit b563e85c3b)
2022-08-13 13:15:36 -04:00
d4adb664cc Avoid catastrophic failure when cover can't be created in local source (fixes #7577)
(cherry picked from commit d6977e5676)
2022-08-13 13:14:33 -04:00
5194bdb229 Show better error when trying to open RARv5 file
(cherry picked from commit a843054388)
2022-08-13 13:14:23 -04:00
87ec71142b Add downloaded icon in TransitionView when chapter is downloaded (#7575)
* Add downloaded icon in TransitionView

* Change icon

(cherry picked from commit e8b7743826)
2022-08-13 13:13:23 -04:00
85f2996ae9 Fix logic of app unlock (#7569)
(cherry picked from commit 8ea05e852e)
2022-08-13 13:11:12 -04:00
e296d56e09 Fix image MIME issues that cause download errors (#7562)
* Downloader: ignore non-image MIME to prevent .bin extensions

* ProgressResponseBody: allow null content type

Co-authored-by: anenasa <84259093+anenasa@users.noreply.github.com>

Co-authored-by: anenasa <84259093+anenasa@users.noreply.github.com>
(cherry picked from commit 3547d0142f)
2022-08-13 13:11:03 -04:00
dd676b6d14 fix concurrent download (#7552)
* Fix concurrent download

* lower Concurrency

* artist Update app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
(cherry picked from commit b635f02d93)
2022-08-13 13:10:53 -04:00
7c7bd72c8e Make default user agent string configurable
(cherry picked from commit 4ee1d72b6f)
2022-08-13 13:09:55 -04:00
c7e44aa22f Replace deprecated ACTION_MEDIA_SCANNER_SCAN_FILE intent
(cherry picked from commit 0b4f3f5532)
2022-08-13 13:09:19 -04:00
ac4f98e152 Configure SQLite
- Turn on `foreign_keys` to cascade on delete properly
- Turn on `journal_mode` and set `synchronous` to NORMAL which may help performance for larger libraries

Based on d977b89af1

Co-authored-by: ghostbear <andreas.everos@gmail.com>
2022-08-13 13:08:16 -04:00
e0d23cd688 Use Material3 switches in XML layouts
(cherry picked from commit da7a64b40d)
2022-08-13 13:05:36 -04:00
3966a917ee Bump dependencies + compile SDK to 33 + linting 2022-08-13 12:52:18 -04:00
be33a57d43 Update .editorconfig 2022-08-13 12:37:13 -04:00
4a71022a60 Update chapter recognition and related tests
Includes 3e07100dc2

Co-authored-by: Saud-97 <Saud-97@users.noreply.github.com>
2022-08-13 12:37:02 -04:00
34ac39e7e5 Update AGP/Gradle 2022-08-13 10:13:37 -04:00
26ddc6e3aa Release v0.13.5 2022-07-08 15:52:48 -04:00
1dc4a52f61 Bump dependencies 2022-07-08 09:11:36 -04:00
473a4fec70 Fix cherry pick errors 2022-07-08 09:11:28 -04:00
1919c2d925 Update default user agent string
(cherry picked from commit 7d3fe0ed43)
2022-07-08 08:58:55 -04:00
71e31e6c03 Add MIME type mapping for image/jxl (fixes #7117)
(cherry picked from commit 591df8abcc)
2022-07-08 08:58:46 -04:00
c01df7f0a1 Increase height of transition view in webtoon viewers (fixes #7242)
(cherry picked from commit 46734c525f)
2022-07-08 08:57:45 -04:00
6024f6175b Extension API: change fallback source and logic (#7400)
* Extension API: change fallback source and logic

* remove ghproxy

(cherry picked from commit 284445c364)
2022-07-08 08:56:51 -04:00
33500e5b69 RateLimitInterceptor: ignore canceled calls (#7389)
* RateLimitInterceptor: ignore canceled calls

* SpecificHostRateLimit: ignore canceled calls

(cherry picked from commit 5b8cd68cf3)
2022-07-08 08:56:29 -04:00
17899a6d6d Add new "Lavender" theme (#7343)
* Add new "Lavender" theme

* Add light theme values for Lavender theme

* Fix order of enums

* Fix accented UI elements in set categories sheet being different colors

Co-authored-by: CrepeTF <trungnguyen02@outlookcom>
(cherry picked from commit ad106bd884)
2022-07-08 08:56:15 -04:00
4c3eb68d3a Use primary color for excluded tristate filter icon (fixes #7360)
(cherry picked from commit 3ca1ce4636)
2022-07-08 08:55:59 -04:00
29ced9642d Fix downloader crash related to UnmeteredSource (#7365)
Fix crash when starting a download with chaqpters from a UnmeteredSource

(cherry picked from commit 470a576441)
2022-07-08 08:55:52 -04:00
af82591d85 Fix accented UI elements in library sheet being different colors
(cherry picked from commit cd5bcc3673)
2022-07-08 08:55:38 -04:00
5bc4a446ec Fix wrapped long page numbers in reader (closes #7300)
(cherry picked from commit 6bc484617e)
2022-07-08 08:55:01 -04:00
83e93b254e Don't show clipboard copy confirmation toast on Android 13 or above
(cherry picked from commit 40f5d26945)
2022-07-08 08:53:46 -04:00
49c7dd0cac Add more DoH providers (#7256)
* Add more DoH providers

* Fix IPs

(cherry picked from commit 18ea6c4f65)
2022-07-08 08:53:41 -04:00
96d2fb62e4 ChapterSourceSync: set default timestamp to max timestamp (#7197)
(cherry picked from commit dd5da56695)
2022-07-08 08:53:19 -04:00
c76a136d3f Fix global update ignoring network constraint (#7188)
* update library update network constraint logic

* add explicit 'only on unmetered network' update constraint

(cherry picked from commit 63238b388d)
2022-07-08 08:52:49 -04:00
940409a4c3 Local Source - qol, cleanup and cover related fixes (#7166)
* Local Source - qol, cleanup and cover related fixes

* Review Changes

(cherry picked from commit ad17eb1386)
2022-07-08 08:52:26 -04:00
071dd88ef8 Add ability to show manga when clicking item in migration search process (#7134)
(cherry picked from commit bbb69482e1)
2022-07-08 08:51:26 -04:00
a58a4634e2 Fix reader menu appearing then disappearing in webtoon viewer when there is no next chapter (#7115)
(cherry picked from commit 6580f5771f)
2022-07-08 08:51:13 -04:00
5979e72662 Fix webtoon viewer showing transition view when going to next/prev chapter using next/prev button (#7133)
(cherry picked from commit b21bcc2d45)
2022-07-08 08:51:04 -04:00
010436e797 Change jsDelivr CDN URL to Fastly (#7156)
(cherry picked from commit 7b242bf118)
2022-07-08 08:50:54 -04:00
980709cccb Use jsDelivr as fallback when GitHub can't be reached for extensions (closes #5517)
Re-implementation of 24bb2f02dc

(cherry picked from commit d61bfd7caf)
2022-07-08 08:50:35 -04:00
fe80356756 Save reader progress when activity is paused (#7121)
(cherry picked from commit f1ab34e27c)
2022-07-08 08:50:06 -04:00
cecf532ffd Fix category tabs incorrect scroll position (#7120)
(cherry picked from commit 6d655ff757)
2022-07-08 08:49:57 -04:00
6cb255e60a Add switch to DownloadPageLoader when chapter is downloaded (#7119)
(cherry picked from commit 63627c81eb)
2022-07-08 08:49:48 -04:00
b46fb7d1e1 Fix "Move to top" showing at the most top item in download queue (#7109)
(cherry picked from commit b26daf8824)
2022-07-08 08:49:21 -04:00
8874193927 Update build workflow actions
(cherry picked from commit 8bee5accb7)
2022-07-08 08:49:04 -04:00
a4515ad251 Check for app updates by comparing semver (#7100)
Instead of just checking whether the current app version *matches* with
latest app version in GitHub Releases, compare the semver from the tag
names to check whether the latter is greater and the app needs an update

Reference: semver spec #11 https://semver.org/#spec-item-11

Co-authored-by: Andreas <6576096+ghostbear@users.noreply.github.com>

Co-authored-by: Andreas <6576096+ghostbear@users.noreply.github.com>
(cherry picked from commit e7ed130f2a)
2022-07-08 08:48:39 -04:00
55b0b57699 Use theme primary color for slider track (#7102)
(cherry picked from commit bc053580ad)
2022-07-08 08:48:00 -04:00
aab7795b4c Don't save categories in backup if not selected (#7101)
Currently, manually created backups contain list of categories even if
Categories option is not selected during Backup Prompt. This leads to
empty categories being created when restoring such backup files

This commit adds a check before saving categories list info to the
backup file. The check is the same check which is used while backing up
category info of manga in library

Tested and worked successfully on app installed on Android 12

(cherry picked from commit 11c01235ac)
2022-07-08 08:47:51 -04:00
196a8e6829 Rename "navigation layout" to "tap zones"
(cherry picked from commit c49d862fc5)
2022-07-08 08:47:43 -04:00
972cd98d7b Fix removing manga from library reverts during global update (#7063)
* Fix removing manga from library reverts during global update

* Review Changes

* Review changes 2

(cherry picked from commit c4088bad12)
2022-07-08 08:47:12 -04:00
a16b5d241b Add -r flag to ShizukuInstaller createCommand (#7080)
(cherry picked from commit 49d3ddb830)
2022-07-08 08:46:49 -04:00
bfa918140f Fix Android 13 icon sizing
(cherry picked from commit 9fdc803c14)
2022-07-08 08:46:27 -04:00
0721de5b81 Add links to website FAQ for library update and download warning notifications
(cherry picked from commit 70698e6494)
2022-07-08 08:45:48 -04:00
a409fde519 Download new chapters when only excluded categories is selected (#6984)
(cherry picked from commit 06bec0ad54)
2022-07-08 08:45:29 -04:00
8e34a30dce Fix skipped library entries and size warning notifications using same ID
(cherry picked from commit 91ed3a4a5f)
2022-07-08 08:43:55 -04:00
ba43462041 Fix update warning notifications being cut off (fixes #6983)
(cherry picked from commit 20145f7a12)
2022-07-08 08:43:47 -04:00
c8ae936ce9 Default to downloading as CBZ (closes #6942)
Generally seems fine. People with weak devices may experience some issues, but they can toggle it off/extract the archives separately if needed.

(cherry picked from commit 883945e3e8)
2022-07-08 08:43:39 -04:00
853f949140 Add battery not low restriction for global updates (closes #6980)
(cherry picked from commit 3feea71146)
2022-07-08 08:43:31 -04:00
615b01a006 Fix chapter transition setting for one page chapters (#6998)
(cherry picked from commit 5e32b8e49f)
2022-07-08 08:43:14 -04:00
0eb5a3176b Delete entire app_webview folder when clearing WebView data
(cherry picked from commit 6e95fde4ec)
2022-07-08 08:43:01 -04:00
867a5a3ea0 Move clear webview data action to network group
(cherry picked from commit bf0bb5aa88)
2022-07-08 08:42:45 -04:00
134 changed files with 1999 additions and 2042 deletions

View File

@ -2,4 +2,6 @@
indent_size=4
insert_final_newline=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_for_members = 2147483647

View File

@ -3,7 +3,7 @@
I acknowledge that:
- I have updated:
- To the latest version of the app (stable is v0.13.4)
- To the latest version of the app (stable is v0.13.6)
- All extensions
- I have tried the troubleshooting guide: https://tachiyomi.org/help/guides/troubleshooting-problems/
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions

View File

@ -53,7 +53,7 @@ body:
label: Tachiyomi version
description: You can find your Tachiyomi version in **More → About**.
placeholder: |
Example: "0.13.4"
Example: "0.13.6"
validations:
required: true
@ -98,7 +98,7 @@ body:
required: true
- label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
required: true
- label: I have updated the app to version **[0.13.4](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
- label: I have updated the app to version **[0.13.6](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
required: true
- label: I have updated all installed extensions.
required: true

View File

@ -33,7 +33,7 @@ body:
required: true
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
required: true
- label: I have updated the app to version **[0.13.4](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
- label: I have updated the app to version **[0.13.6](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

View File

@ -1,5 +0,0 @@
org.gradle.daemon=false
org.gradle.jvmargs=-Xmx5120m
org.gradle.workers.max=2
kotlin.incremental=false

View File

@ -5,6 +5,10 @@ on:
- '**.md'
- 'app/src/main/res/**/strings.xml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
@ -21,19 +25,15 @@ jobs:
uses: gradle/wrapper-validation-action@v1
- name: Dependency Review
uses: actions/dependency-review-action@v1
uses: actions/dependency-review-action@v2
- name: Set up JDK 11
uses: actions/setup-java@v1
uses: actions/setup-java@v3
with:
java-version: 11
distribution: adopt
- name: Copy CI gradle.properties
run: |
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Build app
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: assembleStandardRelease
arguments: assembleStandardRelease testStandardReleaseUnitTest

View File

@ -6,18 +6,16 @@ on:
tags:
- v*
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
build:
name: Build app
runs-on: ubuntu-latest
steps:
- name: Cancel previous runs
uses: styfle/cancel-workflow-action@0.9.1
with:
access_token: ${{ github.token }}
all_but_latest: true
- name: Clone repo
uses: actions/checkout@v3
@ -25,19 +23,15 @@ jobs:
uses: gradle/wrapper-validation-action@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
uses: actions/setup-java@v3
with:
java-version: 11
distribution: adopt
- name: Copy CI gradle.properties
run: |
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Build app
- name: Build app and run unit tests
uses: gradle/gradle-command-action@v2
with:
arguments: assembleStandardRelease
arguments: assembleStandardRelease testStandardReleaseUnitTest
# Sign APK and create release for tags

View File

@ -1,16 +0,0 @@
name: Cancel old pull request workflows
on:
workflow_run:
workflows: ["PR build check"]
types:
- requested
jobs:
cancel:
runs-on: ubuntu-latest
steps:
- uses: styfle/cancel-workflow-action@0.9.1
with:
all_but_latest: true
workflow_id: ${{ github.event.workflow.id }}

View File

@ -1,3 +1,4 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
@ -17,6 +18,7 @@ shortcutHelper.setFilePath("./shortcuts.xml")
val SUPPORTED_ABIS = setOf("armeabi-v7a", "arm64-v8a", "x86")
android {
namespace = "eu.kanade.tachiyomi"
compileSdk = AndroidConfig.compileSdk
ndkVersion = AndroidConfig.ndk
@ -24,8 +26,8 @@ android {
applicationId = "eu.kanade.tachiyomi"
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
versionCode = 80
versionName = "0.13.4"
versionCode = 82
versionName = "0.13.6"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@ -242,32 +244,36 @@ dependencies {
// Tests
testImplementation(libs.junit)
testImplementation(libs.assertj.core)
testImplementation(libs.mockito.core)
testImplementation(libs.bundles.robolectric)
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
}
tasks {
withType<Test> {
useJUnitPlatform()
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
}
}
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
withType<KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-Xopt-in=kotlin.Experimental",
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlin.ExperimentalStdlibApi",
"-Xopt-in=kotlinx.coroutines.FlowPreview",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xopt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=kotlin.Experimental",
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.ExperimentalStdlibApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=coil.annotation.ExperimentalCoilApi",
)
}
// Duplicating Hebrew string assets due to some locale code issues on different devices
val copyHebrewStrings = task("copyHebrewStrings", type = Copy::class) {
val copyHebrewStrings by registering(Copy::class) {
from("./src/main/res/values-he")
into("./src/main/res/values-iw")
include("**/*")

View File

@ -82,4 +82,4 @@
-keepclassmembers class kotlinx.serialization.** {
<methods>;
}
##---------------End: proguard configuration for kotlinx.serialization ----------
##---------------End: proguard configuration for kotlinx.serialization ----------

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET" />

View File

@ -52,6 +52,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.Security
import java.util.Date
open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
@ -148,6 +149,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
}
override fun onStop(owner: LifecycleOwner) {
preferences.lastAppClosed().set(Date().time)
if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) {
SecureActivityDelegate.locked = true
}

View File

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.updater.AppUpdateJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.library.LibrarySort
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
@ -104,10 +103,9 @@ object Migrations {
// Reset sorting preference if using removed sort by source
val oldSortingMode = prefs.getInt(PreferenceKeys.librarySortingMode, 0)
@Suppress("DEPRECATION")
if (oldSortingMode == LibrarySort.SOURCE) {
if (oldSortingMode == 5 /* SOURCE */) {
prefs.edit {
putInt(PreferenceKeys.librarySortingMode, LibrarySort.ALPHA)
putInt(PreferenceKeys.librarySortingMode, 0 /* ALPHABETICAL */)
}
}
}
@ -200,16 +198,15 @@ object Migrations {
val oldSortingMode = prefs.getInt(PreferenceKeys.librarySortingMode, 0)
val oldSortingDirection = prefs.getBoolean(PreferenceKeys.librarySortingDirection, true)
@Suppress("DEPRECATION")
val newSortingMode = when (oldSortingMode) {
LibrarySort.ALPHA -> SortModeSetting.ALPHABETICAL
LibrarySort.LAST_READ -> SortModeSetting.LAST_READ
LibrarySort.LAST_CHECKED -> SortModeSetting.LAST_CHECKED
LibrarySort.UNREAD -> SortModeSetting.UNREAD
LibrarySort.TOTAL -> SortModeSetting.TOTAL_CHAPTERS
LibrarySort.LATEST_CHAPTER -> SortModeSetting.LATEST_CHAPTER
LibrarySort.CHAPTER_FETCH_DATE -> SortModeSetting.DATE_FETCHED
LibrarySort.DATE_ADDED -> SortModeSetting.DATE_ADDED
0 -> SortModeSetting.ALPHABETICAL
1 -> SortModeSetting.LAST_READ
2 -> SortModeSetting.LAST_CHECKED
3 -> SortModeSetting.UNREAD
4 -> SortModeSetting.TOTAL_CHAPTERS
6 -> SortModeSetting.LATEST_CHAPTER
8 -> SortModeSetting.DATE_FETCHED
7 -> SortModeSetting.DATE_ADDED
else -> SortModeSetting.ALPHABETICAL
}

View File

@ -55,7 +55,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
backup = Backup(
backupManga(databaseManga, flags),
backupCategories(),
backupCategories(flags),
emptyList(),
backupExtensionInfo(databaseManga),
)
@ -133,10 +133,15 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
*
* @return list of [BackupCategory] to be backed up
*/
private fun backupCategories(): List<BackupCategory> {
return databaseHelper.getCategories()
.executeAsBlocking()
.map { BackupCategory.copyFrom(it) }
private fun backupCategories(options: Int): List<BackupCategory> {
// Check if user wants category information in backup
return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
databaseHelper.getCategories()
.executeAsBlocking()
.map { BackupCategory.copyFrom(it) }
} else {
emptyList()
}
}
/**

View File

@ -98,5 +98,14 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
override fun onConfigure(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
}

View File

@ -32,11 +32,6 @@ interface Manga : SManga {
return chapter_flags and CHAPTER_SORT_MASK == CHAPTER_SORT_DESC
}
fun getGenres(): List<String>? {
if (genre.isNullOrBlank()) return null
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
}
private fun setChapterFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
}

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.download
import android.app.PendingIntent
import android.content.Context
import android.graphics.BitmapFactory
import androidx.core.app.NotificationCompat
@ -187,16 +188,17 @@ internal class DownloadNotifier(private val context: Context) {
* @param timeout duration after which to automatically dismiss the notification.
* Only works on Android 8+.
*/
fun onWarning(reason: String, timeout: Long? = null) {
fun onWarning(reason: String, timeout: Long? = null, contentIntent: PendingIntent? = null) {
with(errorNotificationBuilder) {
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
setContentText(reason)
setStyle(NotificationCompat.BigTextStyle().bigText(reason))
setSmallIcon(R.drawable.ic_warning_white_24dp)
setAutoCancel(true)
clearActions()
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
setProgress(0, 0, false)
timeout?.let { setTimeoutAfter(it) }
contentIntent?.let { setContentIntent(it) }
show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.webkit.MimeTypeMap
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
@ -11,6 +10,8 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.UnmeteredSource
@ -272,11 +273,12 @@ class Downloader(
// Start downloader if needed
if (autoStart && wasEmpty) {
val queuedDownloads = queue.filter { it.source !is UnmeteredSource }.count()
val queuedDownloads = queue.count { it.source !is UnmeteredSource }
val maxDownloadsFromSource = queue
.groupBy { it.source }
.filterKeys { it !is UnmeteredSource }
.maxOf { it.value.size }
.maxOfOrNull { it.value.size }
?: 0
if (
queuedDownloads > DOWNLOADS_QUEUED_WARNING_THRESHOLD ||
maxDownloadsFromSource > CHAPTERS_PER_SOURCE_QUEUE_WARNING_THRESHOLD
@ -285,6 +287,7 @@ class Downloader(
notifier.onWarning(
context.getString(R.string.download_queue_size_warning),
WARNING_NOTIF_TIMEOUT_MS,
NotificationHandler.openUrl(context, LibraryUpdateNotifier.HELP_WARNING_URL),
)
}
}
@ -338,8 +341,8 @@ class Downloader(
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
// Concurrently do 5 pages at a time
.flatMap({ page -> getOrDownloadImage(page, download, tmpDir) }, 5)
// Concurrently do 2 pages at a time
.flatMap({ page -> getOrDownloadImage(page, download, tmpDir).subscribeOn(Schedulers.io()) }, 2)
.onBackpressureLatest()
// Do when page is downloaded.
.doOnNext { notifier.onProgressChange(download) }
@ -349,6 +352,7 @@ class Downloader(
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
// If the page list threw, it will resume here
.onErrorReturn { error ->
logcat(LogPriority.ERROR, error)
download.status = Download.State.ERROR
notifier.onError(error.message, download.chapter.name, download.manga.title)
download
@ -376,7 +380,7 @@ class Downloader(
tmpFile?.delete()
// Try to find the image file.
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") }
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") || it.name!!.contains("${filename}__001") }
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = when {
@ -386,8 +390,12 @@ class Downloader(
}
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
// When the page is ready, set page path, progress (just in case) and status
.doOnNext { file ->
val success = splitTallImageIfNeeded(page, tmpDir)
if (success.not()) {
notifier.onError(context.getString(R.string.download_notifier_split_failed), download.chapter.name, download.manga.title)
}
page.uri = file.uri
page.progress = 100
download.downloadedImages++
@ -398,6 +406,7 @@ class Downloader(
.onErrorReturn {
page.progress = 0
page.status = Page.ERROR
notifier.onError(it.message, download.chapter.name, download.manga.title)
page
}
}
@ -462,13 +471,33 @@ class Downloader(
*/
private fun getImageExtension(response: Response, file: UniFile): String {
// Read content type if available.
val mime = response.body?.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" }
val mime = response.body?.contentType()?.run { if (type == "image") "image/$subtype" else null }
// Else guess from the uri.
?: context.contentResolver.getType(file.uri)
// Else read magic numbers.
?: ImageUtil.findImageType { file.openInputStream() }?.mime
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
return ImageUtil.getExtensionFromMimeType(mime)
}
private fun splitTallImageIfNeeded(page: Page, tmpDir: UniFile): Boolean {
if (!preferences.splitTallImages().get()) return true
val filename = String.format("%03d", page.number)
val imageFile = tmpDir.listFiles()?.find { it.name!!.startsWith(filename) }
?: throw Error(context.getString(R.string.download_notifier_split_page_not_found, page.number))
val imageFilePath = imageFile.filePath
?: throw Error(context.getString(R.string.download_notifier_split_page_path_not_found, page.number))
// check if the original page was previously splitted before then skip.
if (imageFile.name!!.contains("__")) return true
return try {
ImageUtil.splitTallImage(imageFile, imageFilePath)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
false
}
}
/**
@ -486,16 +515,10 @@ class Downloader(
dirname: String,
) {
// Ensure that the chapter folder has all the images.
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") || (it.name!!.contains("__") && !it.name!!.contains("__001.jpg")) }
download.status = if (downloadedImages.size == download.pages!!.size) {
Download.State.DOWNLOADED
} else {
Download.State.ERROR
}
// Only rename the directory if it's downloaded.
if (download.status == Download.State.DOWNLOADED) {
// Only rename the directory if it's downloaded.
if (preferences.saveChaptersAsCBZ().get()) {
archiveChapter(mangaDir, dirname, tmpDir)
} else {
@ -504,6 +527,10 @@ class Downloader(
cache.addChapter(dirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context)
Download.State.DOWNLOADED
} else {
Download.State.ERROR
}
}

View File

@ -8,9 +8,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.*
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -21,8 +19,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
override fun doWork(): Result {
val preferences = Injekt.get<PreferencesHelper>()
if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) {
Result.failure()
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
return Result.failure()
}
return if (LibraryUpdateService.start(context)) {
@ -41,8 +40,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
if (interval > 0) {
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiredNetworkType(if (DEVICE_NETWORK_NOT_METERED in restrictions) { NetworkType.UNMETERED } else { NetworkType.CONNECTED })
.setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(DEVICE_BATTERY_NOT_LOW in restrictions)
.build()
val request = PeriodicWorkRequestBuilder<LibraryUpdateJob>(
@ -60,10 +60,5 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
fun requiresWifiConnection(preferences: PreferencesHelper): Boolean {
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
return DEVICE_ONLY_ON_WIFI in restrictions
}
}
}

View File

@ -93,9 +93,10 @@ class LibraryUpdateNotifier(private val context: Context) {
fun showQueueSizeWarningNotification() {
val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) {
setContentTitle(context.getString(R.string.label_warning))
setContentText(context.getString(R.string.notification_size_warning))
setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notification_size_warning)))
setSmallIcon(R.drawable.ic_warning_white_24dp)
setTimeoutAfter(Downloader.WARNING_NOTIF_TIMEOUT_MS)
setContentIntent(NotificationHandler.openUrl(context, HELP_WARNING_URL))
}
context.notificationManager.notify(
@ -340,6 +341,10 @@ class LibraryUpdateNotifier(private val context: Context) {
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
companion object {
const val HELP_WARNING_URL = "https://tachiyomi.org/help/faq/#why-does-the-app-warn-about-large-bulk-updates-and-downloads"
}
}
private const val NOTIF_MAX_CHAPTERS = 5

View File

@ -174,6 +174,8 @@ class LibraryUpdateService(
*/
override fun onDestroy() {
updateJob?.cancel()
// Despite what Android Studio
// states this can be null
ioScope?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
@ -233,8 +235,7 @@ class LibraryUpdateService(
/**
* Adds list of manga to be updated.
*
* @param category the ID of the category to update, or -1 if no category specified.
* @param target the target to update.
* @param categoryId the ID of the category to update, or -1 if no category specified.
*/
fun addMangaToQueue(categoryId: Int) {
val libraryManga = db.getLibraryMangas().executeAsBlocking()
@ -274,12 +275,11 @@ class LibraryUpdateService(
}
/**
* Method that updates the given list of manga. It's called in a background thread, so it's safe
* Method that updates manga in [mangaToUpdate]. It's called in a background thread, so it's safe
* to do heavy operations or network calls here.
* For each manga it calls [updateManga] and updates the notification showing the current
* progress.
*
* @param mangaToUpdate the list to update
* @return an observable delivering the progress of each update.
*/
suspend fun updateChapterList() {
@ -305,35 +305,38 @@ class LibraryUpdateService(
return@async
}
// Don't continue to update if manga not in library
db.getManga(manga.id!!).executeAsBlocking() ?: return@forEach
withUpdateNotification(
currentlyUpdatingManga,
progressCount,
manga,
) { manga ->
) { mangaWithNotif ->
try {
when {
MANGA_NON_COMPLETED in restrictions && manga.status == SManga.COMPLETED -> {
skippedUpdates.add(manga to getString(R.string.skipped_reason_completed))
}
MANGA_HAS_UNREAD in restrictions && manga.unreadCount != 0 -> {
skippedUpdates.add(manga to getString(R.string.skipped_reason_not_caught_up))
}
MANGA_NON_READ in restrictions && manga.totalChapters > 0 && !manga.hasStarted -> {
skippedUpdates.add(manga to getString(R.string.skipped_reason_not_started))
}
MANGA_NON_COMPLETED in restrictions && mangaWithNotif.status == SManga.COMPLETED ->
skippedUpdates.add(mangaWithNotif to getString(R.string.skipped_reason_completed))
MANGA_HAS_UNREAD in restrictions && mangaWithNotif.unreadCount != 0 ->
skippedUpdates.add(mangaWithNotif to getString(R.string.skipped_reason_not_caught_up))
MANGA_NON_READ in restrictions && mangaWithNotif.totalChapters > 0 && !mangaWithNotif.hasStarted ->
skippedUpdates.add(mangaWithNotif to getString(R.string.skipped_reason_not_started))
else -> {
// Convert to the manga that contains new chapters
val (newChapters, _) = updateManga(manga)
val (newChapters, _) = updateManga(mangaWithNotif)
if (newChapters.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(manga, newChapters)
if (mangaWithNotif.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(mangaWithNotif, newChapters)
hasDownloads.set(true)
}
// Convert to the manga that contains new chapters
newUpdates.add(
manga to newChapters.sortedByDescending { ch -> ch.source_order }
mangaWithNotif to newChapters.sortedByDescending { ch -> ch.source_order }
.toTypedArray(),
)
}
@ -352,11 +355,11 @@ class LibraryUpdateService(
e.message
}
}
failedUpdates.add(manga to errorMessage)
failedUpdates.add(mangaWithNotif to errorMessage)
}
if (preferences.autoUpdateTrackers()) {
updateTrackings(manga, loggedServices)
updateTrackings(mangaWithNotif, loggedServices)
}
}
}
@ -404,6 +407,7 @@ class LibraryUpdateService(
suspend fun updateManga(manga: Manga): Pair<List<Chapter>, List<Chapter>> {
val source = sourceManager.getOrStub(manga.source)
var networkSManga: SManga? = null
// Update manga details metadata
if (preferences.autoUpdateMetadata()) {
val updatedManga = source.getMangaDetails(manga.toMangaInfo())
@ -415,14 +419,26 @@ class LibraryUpdateService(
sManga.thumbnail_url = manga.thumbnail_url
}
manga.copyFrom(sManga)
db.insertManga(manga).executeAsBlocking()
networkSManga = sManga
}
val chapters = source.getChapterList(manga.toMangaInfo())
.map { it.toSChapter() }
return syncChaptersWithSource(db, chapters, manga, source)
// Get manga from database to account for if it was removed
// from library or database
val dbManga = db.getManga(manga.id!!).executeAsBlocking()
?: return Pair(emptyList(), emptyList())
// Copy into [dbManga] to retain favourite value
networkSManga?.let {
dbManga.copyFrom(it)
db.insertManga(dbManga).executeAsBlocking()
}
// [dbmanga] was used so that manga data doesn't get overwritten
// incase manga gets new chapter
return syncChaptersWithSource(db, chapters, dbManga, source)
}
private suspend fun updateCovers() {
@ -445,16 +461,16 @@ class LibraryUpdateService(
currentlyUpdatingManga,
progressCount,
manga,
) { manga ->
sourceManager.get(manga.source)?.let { source ->
) { mangaWithNotif ->
sourceManager.get(mangaWithNotif.source)?.let { source ->
try {
val networkManga =
source.getMangaDetails(manga.toMangaInfo())
source.getMangaDetails(mangaWithNotif.toMangaInfo())
val sManga = networkManga.toSManga()
manga.prepUpdateCover(coverCache, sManga, true)
mangaWithNotif.prepUpdateCover(coverCache, sManga, true)
sManga.thumbnail_url?.let {
manga.thumbnail_url = it
db.insertManga(manga).executeAsBlocking()
mangaWithNotif.thumbnail_url = it
db.insertManga(mangaWithNotif).executeAsBlocking()
}
} catch (e: Throwable) {
// Ignore errors and continue

View File

@ -7,6 +7,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -193,7 +194,7 @@ class NotificationReceiver : BroadcastReceiver() {
val file = File(path)
file.delete()
DiskUtil.scanMedia(context, file)
DiskUtil.scanMedia(context, file.toUri())
}
/**

View File

@ -30,7 +30,7 @@ object Notifications {
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
const val ID_LIBRARY_ERROR = -102
const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel"
const val ID_LIBRARY_SKIPPED = -103
const val ID_LIBRARY_SKIPPED = -104
/**
* Notification channel and ids used by the downloader.

View File

@ -63,6 +63,8 @@ object PreferenceKeys {
const val dohProvider = "doh_provider"
const val defaultUserAgent = "default_user_agent"
const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded"

View File

@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.data.preference
import eu.kanade.tachiyomi.R
const val DEVICE_ONLY_ON_WIFI = "wifi"
const val DEVICE_NETWORK_NOT_METERED = "network_not_metered"
const val DEVICE_CHARGING = "ac"
const val DEVICE_BATTERY_NOT_LOW = "battery_not_low"
const val MANGA_NON_COMPLETED = "manga_ongoing"
const val MANGA_HAS_UNREAD = "manga_fully_read"
@ -28,13 +30,14 @@ object PreferenceValues {
enum class AppTheme(val titleResId: Int?) {
DEFAULT(R.string.label_default),
MONET(R.string.theme_monet),
GREEN_APPLE(R.string.theme_greenapple),
LAVENDER(R.string.theme_lavender),
MIDNIGHT_DUSK(R.string.theme_midnightdusk),
STRAWBERRY_DAIQUIRI(R.string.theme_strawberrydaiquiri),
YOTSUBA(R.string.theme_yotsuba),
TAKO(R.string.theme_tako),
GREEN_APPLE(R.string.theme_greenapple),
TEALTURQUOISE(R.string.theme_tealturquoise),
YINYANG(R.string.theme_yinyang),
YOTSUBA(R.string.theme_yotsuba),
// Deprecated
DARK_BLUE(null),

View File

@ -56,7 +56,7 @@ class PreferencesHelper(val context: Context) {
fun lockAppAfter() = flowPrefs.getInt("lock_app_after", 0)
fun lastAppUnlock() = flowPrefs.getLong("last_app_unlock", 0)
fun lastAppClosed() = flowPrefs.getLong("last_app_closed", 0)
fun secureScreen() = flowPrefs.getEnum("secure_screen_v2", Values.SecureScreenMode.INCOGNITO)
@ -204,7 +204,9 @@ class PreferencesHelper(val context: Context) {
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
fun saveChaptersAsCBZ() = flowPrefs.getBoolean("save_chapter_as_cbz", false)
fun saveChaptersAsCBZ() = flowPrefs.getBoolean("save_chapter_as_cbz", true)
fun splitTallImages() = flowPrefs.getBoolean("split_tall_images", false)
fun folderPerManga() = prefs.getBoolean(Keys.folderPerManga, false)
@ -278,10 +280,10 @@ class PreferencesHelper(val context: Context) {
fun pinnedSources() = flowPrefs.getStringSet("pinned_catalogues", emptySet())
fun downloadNew() = flowPrefs.getBoolean("download_new", false)
fun downloadNewChapter() = flowPrefs.getBoolean("download_new", false)
fun downloadNewCategories() = flowPrefs.getStringSet("download_new_categories", emptySet())
fun downloadNewCategoriesExclude() = flowPrefs.getStringSet("download_new_categories_exclude", emptySet())
fun downloadNewChapterCategories() = flowPrefs.getStringSet("download_new_categories", emptySet())
fun downloadNewChapterCategoriesExclude() = flowPrefs.getStringSet("download_new_categories_exclude", emptySet())
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
@ -297,6 +299,8 @@ class PreferencesHelper(val context: Context) {
fun dohProvider() = prefs.getInt(Keys.dohProvider, -1)
fun defaultUserAgent() = flowPrefs.getString(Keys.defaultUserAgent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44")
fun lastSearchQuerySearchSettings() = flowPrefs.getString("last_search_query", "")
fun filterChapterByRead() = prefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL)

View File

@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.core.net.toUri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
@ -82,7 +83,7 @@ class ImageSaver(
}
}
DiskUtil.scanMedia(context, destFile)
DiskUtil.scanMedia(context, destFile.toUri())
return destFile.getUriCompat(context)
}

View File

@ -22,6 +22,7 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
@ -256,13 +257,21 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.appendPath("my_list_status")
.build()
fun refreshTokenRequest(refreshToken: String): Request {
fun refreshTokenRequest(oauth: OAuth): Request {
val formBody: RequestBody = FormBody.Builder()
.add("client_id", clientId)
.add("refresh_token", refreshToken)
.add("refresh_token", oauth.refresh_token)
.add("grant_type", "refresh_token")
.build()
return POST("$baseOAuthUrl/token", body = formBody)
// Add the Authorization header manually as this particular
// request is called by the interceptor itself so it doesn't reach
// the part where the token is added automatically.
val headers = Headers.Builder()
.add("Authorization", "Bearer ${oauth.access_token}")
.build()
return POST("$baseOAuthUrl/token", body = formBody, headers = headers)
}
private fun getPkceChallengeCode(): String {

View File

@ -1,9 +1,10 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.decodeFromString
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import uy.kohesive.injekt.injectLazy
import java.io.IOException
@ -24,11 +25,22 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t
}
// Refresh access token if expired
if (oauth != null && oauth!!.isExpired()) {
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!.refresh_token)).use {
if (it.isSuccessful) {
setAuth(json.decodeFromString(it.body!!.string()))
val newOauth = runCatching {
val oauthResponse = chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!))
if (oauthResponse.isSuccessful) {
oauthResponse.parseAs<OAuth>()
} else {
oauthResponse.closeQuietly()
null
}
}
if (newOauth.getOrNull() == null) {
throw IOException("Failed to refresh the access token")
}
setAuth(newOauth.getOrNull())
}
if (oauth == null) {
throw IOException("No authentication token")

View File

@ -47,6 +47,7 @@ class AppUpdateChecker {
when (result) {
is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate()
else -> {}
}
result
@ -56,6 +57,7 @@ class AppUpdateChecker {
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
val oldVersion = BuildConfig.VERSION_NAME.replace("[^\\d.]".toRegex(), "")
return if (BuildConfig.PREVIEW) {
// Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
@ -64,7 +66,15 @@ class AppUpdateChecker {
} else {
// Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
// tagged as something like "v0.1.2"
newVersion != BuildConfig.VERSION_NAME
val newSemVer = newVersion.split(".").map { it.toInt() }
val oldSemVer = oldVersion.split(".").map { it.toInt() }
oldSemVer.mapIndexed { index, i ->
if (newSemVer[index] > i) {
return true
}
}
false
}
}
}

View File

@ -116,6 +116,7 @@ internal class AppUpdateNotifier(private val context: Context) {
setOnlyAlertOnce(false)
setProgress(0, 0, false)
setContentIntent(installIntent)
setOngoing(true)
clearActions()
addAction(

View File

@ -11,7 +11,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable
import logcat.LogPriority
import uy.kohesive.injekt.injectLazy
import java.util.Date
import java.util.concurrent.TimeUnit
@ -21,11 +23,27 @@ internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
private var requiresFallbackSource = false
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext {
val extensions = networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
val githubResponse = if (requiresFallbackSource) null else try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
requiresFallbackSource = true
null
}
val response = githubResponse ?: run {
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.await()
}
val extensions = response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions()
@ -85,7 +103,7 @@ internal class ExtensionGithubApi {
hasChangelog = it.hasChangelog == 1,
sources = it.sources?.toExtensionSources() ?: emptyList(),
apkName = it.apk,
iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}",
iconUrl = "${getUrlPrefix()}icon/${it.apk.replace(".apk", ".png")}",
)
}
}
@ -101,11 +119,20 @@ internal class ExtensionGithubApi {
}
fun getApkUrl(extension: Extension.Available): String {
return "${REPO_URL_PREFIX}apk/${extension.apkName}"
return "${getUrlPrefix()}apk/${extension.apkName}"
}
private fun getUrlPrefix(): String {
return if (requiresFallbackSource) {
FALLBACK_REPO_URL_PREFIX
} else {
REPO_URL_PREFIX
}
}
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"
@Serializable
private data class ExtensionJsonObject(

View File

@ -52,9 +52,9 @@ class ShizukuInstaller(private val service: Service) : Installer(service) {
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use {
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"pm install-create --user current -i ${service.packageName} -S $size"
"pm install-create --user current -r -i ${service.packageName} -S $size"
} else {
"pm install-create -i ${service.packageName} -S $size"
"pm install-create -r -i ${service.packageName} -S $size"
}
val createResult = exec(createCommand)
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value

View File

@ -4,7 +4,5 @@ sealed class LoadResult {
class Success(val extension: Extension.Installed) : LoadResult()
class Untrusted(val extension: Extension.Untrusted) : LoadResult()
class Error(val message: String? = null) : LoadResult() {
constructor(exception: Throwable) : this(exception.message)
}
object Error : LoadResult()
}

View File

@ -7,10 +7,12 @@ import android.content.IntentFilter
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.util.lang.launchNow
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import logcat.LogPriority
/**
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
@ -52,6 +54,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
else -> {}
}
}
}
@ -60,8 +63,8 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
// Not needed as a package can't be upgraded if the signature is different
is LoadResult.Untrusted -> {
}
is LoadResult.Untrusted -> {}
else -> {}
}
}
}
@ -93,7 +96,10 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
*/
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent)
?: return LoadResult.Error("Package name not found")
if (pkgName == null) {
logcat(LogPriority.WARN) { "Package name not found" }
return LoadResult.Error
}
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT) { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
}

View File

@ -80,10 +80,12 @@ internal object ExtensionLoader {
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
return LoadResult.Error(error)
logcat(LogPriority.ERROR, error)
return LoadResult.Error
}
if (!isPackageAnExtension(pkgInfo)) {
return LoadResult.Error("Tried to load a package that wasn't a extension")
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
return LoadResult.Error
}
return loadExtension(context, pkgName, pkgInfo)
}
@ -102,7 +104,8 @@ internal object ExtensionLoader {
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
return LoadResult.Error(error)
logcat(LogPriority.ERROR, error)
return LoadResult.Error
}
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
@ -112,7 +115,7 @@ internal object ExtensionLoader {
if (versionName.isNullOrEmpty()) {
val exception = Exception("Missing versionName for extension $extName")
logcat(LogPriority.WARN, exception)
return LoadResult.Error(exception)
return LoadResult.Error
}
// Validate lib version
@ -123,13 +126,14 @@ internal object ExtensionLoader {
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
logcat(LogPriority.WARN, exception)
return LoadResult.Error(exception)
return LoadResult.Error
}
val signatureHash = getSignatureHash(pkgInfo)
if (signatureHash == null) {
return LoadResult.Error("Package $pkgName isn't signed")
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return LoadResult.Error
} else if (signatureHash !in trustedSignatures) {
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash)
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
@ -138,7 +142,8 @@ internal object ExtensionLoader {
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
if (!loadNsfwSource && isNsfw) {
return LoadResult.Error("NSFW extension $pkgName not allowed")
logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" }
return LoadResult.Error
}
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
@ -165,7 +170,7 @@ internal object ExtensionLoader {
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }
return LoadResult.Error(e)
return LoadResult.Error
}
}

View File

@ -13,6 +13,10 @@ const val PREF_DOH_CLOUDFLARE = 1
const val PREF_DOH_GOOGLE = 2
const val PREF_DOH_ADGUARD = 3
const val PREF_DOH_QUAD9 = 4
const val PREF_DOH_ALIDNS = 5
const val PREF_DOH_DNSPOD = 6
const val PREF_DOH_360 = 7
const val PREF_DOH_QUAD101 = 8
fun OkHttpClient.Builder.dohCloudflare() = dns(
DnsOverHttps.Builder().client(build())
@ -68,3 +72,51 @@ fun OkHttpClient.Builder.dohQuad9() = dns(
)
.build(),
)
fun OkHttpClient.Builder.dohAliDNS() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.alidns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("223.5.5.5"),
InetAddress.getByName("223.6.6.6"),
InetAddress.getByName("2400:3200::1"),
InetAddress.getByName("2400:3200:baba::1"),
)
.build(),
)
fun OkHttpClient.Builder.dohDNSPod() = dns(
DnsOverHttps.Builder().client(build())
.url("https://doh.pub/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("1.12.12.12"),
InetAddress.getByName("120.53.53.53"),
)
.build(),
)
fun OkHttpClient.Builder.doh360() = dns(
DnsOverHttps.Builder().client(build())
.url("https://doh.360.cn/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("101.226.4.6"),
InetAddress.getByName("218.30.118.6"),
InetAddress.getByName("123.125.81.6"),
InetAddress.getByName("140.207.198.6"),
InetAddress.getByName("180.163.249.75"),
InetAddress.getByName("101.199.113.208"),
InetAddress.getByName("36.99.170.86"),
)
.build(),
)
fun OkHttpClient.Builder.dohQuad101() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.twnic.tw/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("101.101.101.101"),
InetAddress.getByName("2001:de4::101"),
InetAddress.getByName("2001:de4::102"),
)
.build(),
)

View File

@ -43,6 +43,10 @@ class NetworkHelper(context: Context) {
PREF_DOH_GOOGLE -> builder.dohGoogle()
PREF_DOH_ADGUARD -> builder.dohAdGuard()
PREF_DOH_QUAD9 -> builder.dohQuad9()
PREF_DOH_ALIDNS -> builder.dohAliDNS()
PREF_DOH_DNSPOD -> builder.dohDNSPod()
PREF_DOH_360 -> builder.doh360()
PREF_DOH_QUAD101 -> builder.dohQuad101()
}
return builder
@ -55,4 +59,8 @@ class NetworkHelper(context: Context) {
.addInterceptor(CloudflareInterceptor(context))
.build()
}
val defaultUserAgent by lazy {
preferences.defaultUserAgent().get()
}
}

View File

@ -15,8 +15,8 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType {
return responseBody.contentType()!!
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {

View File

@ -9,7 +9,6 @@ import android.widget.Toast
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
@ -109,7 +108,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
webview.settings.userAgentString = request.header("User-Agent")
?: HttpSource.DEFAULT_USER_AGENT
?: networkHelper.defaultUserAgent
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {

View File

@ -4,6 +4,7 @@ import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
@ -36,6 +37,11 @@ private class RateLimitInterceptor(
private val rateLimitMillis = unit.toMillis(period)
override fun intercept(chain: Interceptor.Chain): Response {
// Ignore canceled calls, otherwise they would jam the queue
if (chain.call().isCanceled()) {
throw IOException()
}
synchronized(requestQueue) {
val now = SystemClock.elapsedRealtime()
val waitTime = if (requestQueue.size < permits) {
@ -51,6 +57,11 @@ private class RateLimitInterceptor(
}
}
// Final check
if (chain.call().isCanceled()) {
throw IOException()
}
if (requestQueue.size == permits) {
requestQueue.removeAt(0)
}

View File

@ -5,6 +5,7 @@ import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
@ -41,9 +42,13 @@ class SpecificHostRateLimitInterceptor(
private val host = httpUrl.host
override fun intercept(chain: Interceptor.Chain): Response {
if (chain.request().url.host != host) {
// Ignore canceled calls, otherwise they would jam the queue
if (chain.call().isCanceled()) {
throw IOException()
} else if (chain.request().url.host != host) {
return chain.proceed(chain.request())
}
synchronized(requestQueue) {
val now = SystemClock.elapsedRealtime()
val waitTime = if (requestQueue.size < permits) {
@ -59,6 +64,11 @@ class SpecificHostRateLimitInterceptor(
}
}
// Final check
if (chain.call().isCanceled()) {
throw IOException()
}
if (requestQueue.size == permits) {
requestQueue.removeAt(0)
}

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.network.interceptor
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class UserAgentInterceptor : Interceptor {
private val networkHelper: NetworkHelper by injectLazy()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
@ -12,7 +16,7 @@ class UserAgentInterceptor : Interceptor {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
.addHeader("User-Agent", networkHelper.defaultUserAgent)
.build()
chain.proceed(newRequest)
} else {

View File

@ -2,7 +2,9 @@ package eu.kanade.tachiyomi.source
import android.content.Context
import com.github.junrar.Archive
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@ -30,6 +32,8 @@ import logcat.LogPriority
import rx.Observable
import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
@ -37,130 +41,104 @@ import java.io.InputStream
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource {
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val COVER_NAME = "cover.jpg"
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
val dir = getBaseDirectories(context).firstOrNull()
if (dir == null) {
input.close()
return null
}
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
if (cover == null) {
cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
}
// It might not exist if using the external SD card
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
manga.thumbnail_url = cover.absolutePath
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
private fun getBaseDirectories(context: Context): List<File> {
val c = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
}
}
class LocalSource(
private val context: Context,
private val coverCache: CoverCache = Injekt.get(),
) : CatalogueSource, UnmeteredSource {
private val json: Json by injectLazy()
override val id = ID
override val name = context.getString(R.string.local_source)
override val lang = "other"
override val supportsLatest = true
override val name: String = context.getString(R.string.local_source)
override val id: Long = ID
override val lang: String = "other"
override fun toString() = name
override val supportsLatest: Boolean = true
// Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirs = getBaseDirectories(context)
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = baseDirs
.asSequence()
.mapNotNull { it.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirsFiles = getBaseDirectoriesFiles(context)
var mangaDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name }))
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name }))
}
}
1 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// Filter by query or last modified
mangaDirs = mangaDirs.filter {
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
}
}
filters.forEach { filter ->
when (filter) {
is OrderBy -> {
when (filter.state!!.index) {
0 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
1 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
}
}
else -> { /* Do nothing */ }
}
}
// Transform mangaDirs to list of SManga
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
for (dir in baseDirs) {
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
break
}
val cover = getCoverFile(mangaDir.name, baseDirsFiles)
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
}
}
}
val sManga = this
val mangaInfo = this.toMangaInfo()
runBlocking {
val chapters = getChapterList(mangaInfo)
if (chapters.isNotEmpty()) {
val chapter = chapters.last().toSChapter()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(sManga)
}
}
// Fetch chapters of all the manga
mangas.forEach { manga ->
val mangaInfo = manga.toMangaInfo()
runBlocking {
val chapters = getChapterList(mangaInfo)
if (chapters.isNotEmpty()) {
val chapter = chapters.last().toSChapter()
val format = getFormat(chapter)
// Copy the cover from the first chapter found.
if (thumbnail_url == null) {
try {
val dest = updateCover(chapter, sManga)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(manga)
}
}
// Copy the cover from the first chapter found if not available
if (manga.thumbnail_url == null) {
updateCover(chapter, manga)
}
}
}
}
@ -168,38 +146,44 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
return Observable.just(MangasPage(mangas.toList(), false))
}
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
// Manga details related
override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
val localDetails = getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, manga.key).listFiles()?.toList() }
.flatten()
var mangaInfo = manga
val baseDirsFile = getBaseDirectoriesFiles(context)
val coverFile = getCoverFile(manga.key, baseDirsFile)
coverFile?.let {
mangaInfo = mangaInfo.copy(cover = it.absolutePath)
}
val localDetails = getMangaDirsFiles(manga.key, baseDirsFile)
.firstOrNull { it.extension.equals("json", ignoreCase = true) }
return if (localDetails != null) {
if (localDetails != null) {
val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream())
manga.copy(
title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title,
author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author,
artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist,
description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description,
genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres,
status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status,
mangaInfo = mangaInfo.copy(
title = obj["title"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.title,
author = obj["author"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.author,
artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.artist,
description = obj["description"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.description,
genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: mangaInfo.genres,
status = obj["status"]?.jsonPrimitive?.intOrNull ?: mangaInfo.status,
)
} else {
manga
}
return mangaInfo
}
// Chapters
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
val sManga = manga.toSManga()
val chapters = getBaseDirectories(context)
.asSequence()
.mapNotNull { File(it, manga.key).listFiles()?.toList() }
.flatten()
val baseDirsFile = getBaseDirectoriesFiles(context)
return getMangaDirsFiles(manga.key, baseDirsFile)
// Only keep supported formats
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
@ -211,14 +195,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
date_upload = chapterFile.lastModified()
ChapterRecognition.parseChapterNumber(this, sManga)
val format = getFormat(chapterFile)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
ChapterRecognition.parseChapterNumber(this, sManga)
}
}
.map { it.toChapterInfo() }
@ -227,12 +211,24 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
.toList()
return chapters
}
override suspend fun getPageList(chapter: ChapterInfo) = throw Exception("Unused")
// Filters
override fun getFilterList() = FilterList(OrderBy(context))
private val POPULAR_FILTERS = FilterList(OrderBy(context))
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
Selection(0, true),
)
// Unused stuff
override suspend fun getPageList(chapter: ChapterInfo) = throw UnsupportedOperationException("Unused")
// Miscellaneous
private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
@ -260,61 +256,129 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
return try {
when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" }
null
}
.also { coverCache.clearMemoryCache() }
}
override fun getFilterList() = POPULAR_FILTERS
private val POPULAR_FILTERS = FilterList(OrderBy(context))
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
Selection(0, true),
)
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val DEFAULT_COVER_NAME = "cover.jpg"
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private fun getBaseDirectories(context: Context): Sequence<File> {
val localFolder = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, localFolder) }
.asSequence()
}
private fun getBaseDirectoriesFiles(context: Context): Sequence<File> {
return getBaseDirectories(context)
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getMangaDir(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return baseDirsFile
// Get the first mangaDir or null
.firstOrNull { it.isDirectory && it.name == mangaUrl }
}
private fun getMangaDirsFiles(mangaUrl: String, baseDirsFile: Sequence<File>): Sequence<File> {
return baseDirsFile
// Filter out ones that are not related to the manga and is not a directory
.filter { it.isDirectory && it.name == mangaUrl }
// Get all the files inside the filtered folders
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getCoverFile(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return getMangaDirsFiles(mangaUrl, baseDirsFile)
// Get all file whose names start with 'cover'
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull {
ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun updateCover(context: Context, manga: SManga, inputStream: InputStream): File? {
val baseDirsFiles = getBaseDirectoriesFiles(context)
val mangaDir = getMangaDir(manga.url, baseDirsFiles)
if (mangaDir == null) {
inputStream.close()
return null
}
var coverFile = getCoverFile(manga.url, baseDirsFiles)
if (coverFile == null) {
coverFile = File(mangaDir.absolutePath, DEFAULT_COVER_NAME)
}
// It might not exist at this point
coverFile.parentFile?.mkdirs()
inputStream.use { input ->
coverFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context)
manga.thumbnail_url = coverFile.absolutePath
return coverFile
}
}
}
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")

View File

@ -23,6 +23,11 @@ interface SManga : Serializable {
var initialized: Boolean
fun getGenres(): List<String>? {
if (genre.isNullOrBlank()) return null
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
}
fun copyFrom(other: SManga) {
if (other.author != null) {
author = other.author
@ -73,7 +78,7 @@ fun SManga.toMangaInfo(): MangaInfo {
artist = this.artist ?: "",
author = this.author ?: "",
description = this.description ?: "",
genres = this.genre?.split(", ") ?: emptyList(),
genres = this.getGenres() ?: emptyList(),
status = this.status,
cover = this.thumbnail_url ?: "",
)

View File

@ -15,6 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
@ -67,7 +68,7 @@ abstract class HttpSource : CatalogueSource {
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USER_AGENT)
add("User-Agent", network.defaultUserAgent)
}
/**
@ -369,8 +370,4 @@ abstract class HttpSource : CatalogueSource {
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
}
}

View File

@ -97,7 +97,7 @@ abstract class DialogController : Controller {
/**
* Dismiss the dialog and pop this controller
*/
private fun dismissDialog() {
fun dismissDialog() {
if (dismissed) {
return
}

View File

@ -59,16 +59,17 @@ abstract class SearchableNucleusController<VB : ViewBinding, P : BasePresenter<*
val searchAutoComplete: SearchView.SearchAutoComplete = searchView.findViewById(
R.id.search_src_text,
)
searchAutoComplete.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
searchAutoComplete.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(editable: Editable) {
editable.getSpans(0, editable.length, CharacterStyle::class.java)
.forEach { editable.removeSpan(it) }
}
},
override fun afterTextChanged(editable: Editable) {
editable.getSpans(0, editable.length, CharacterStyle::class.java)
.forEach { editable.removeSpan(it) }
}
},
)
searchView.queryTextEvents()
@ -134,12 +135,12 @@ abstract class SearchableNucleusController<VB : ViewBinding, P : BasePresenter<*
searchItem.setOnActionExpandListener(
object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
onSearchMenuItemActionExpand(item)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val localSearchView = searchItem.actionView as SearchView
// if it is blank the flow event won't trigger so we would stay in a COLLAPSING state

View File

@ -68,6 +68,6 @@ class SecureActivityDelegateImpl : SecureActivityDelegate, DefaultLifecycleObser
private fun isAppLocked(): Boolean {
if (!SecureActivityDelegate.locked) return false
return preferences.lockAppAfter().get() <= 0 ||
Date().time >= preferences.lastAppUnlock().get() + 60 * 1000 * preferences.lockAppAfter().get()
Date().time >= preferences.lastAppClosed().get() + 60 * 1000 * preferences.lockAppAfter().get()
}
}

View File

@ -20,6 +20,9 @@ interface ThemingDelegate {
PreferenceValues.AppTheme.GREEN_APPLE -> {
resIds += R.style.Theme_Tachiyomi_GreenApple
}
PreferenceValues.AppTheme.LAVENDER -> {
resIds += R.style.Theme_Tachiyomi_Lavender
}
PreferenceValues.AppTheme.MIDNIGHT_DUSK -> {
resIds += R.style.Theme_Tachiyomi_MidnightDusk
}

View File

@ -247,9 +247,13 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
}
private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
return when {
!pkgFactory.isNullOrEmpty() -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory$path"
else -> "$url/src/${pkgName.replace(".", "/")}$path"
return if (!pkgFactory.isNullOrEmpty()) {
when (path.isEmpty()) {
true -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory"
else -> "$url/multisrc/overrides/$pkgFactory/" + (pkgName.split(".").lastOrNull() ?: "") + path
}
} else {
url + "/src/" + pkgName.replace(".", "/") + path
}
}

View File

@ -1,20 +1,29 @@
package eu.kanade.tachiyomi.ui.browse.migration
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.hasCustomCover
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
object MigrationFlags {
private const val CHAPTERS = 0b001
private const val CATEGORIES = 0b010
private const val TRACK = 0b100
private const val CHAPTERS = 0b0001
private const val CATEGORIES = 0b0010
private const val TRACK = 0b0100
private const val CUSTOM_COVER = 0b1000
private const val CHAPTERS2 = 0x1
private const val CATEGORIES2 = 0x2
private const val TRACK2 = 0x4
val titles get() = arrayOf(R.string.chapters, R.string.categories, R.string.track)
private val coverCache: CoverCache by injectLazy()
private val db: DatabaseHelper = Injekt.get()
val flags get() = arrayOf(CHAPTERS, CATEGORIES, TRACK)
val flags get() = arrayOf(CHAPTERS, CATEGORIES, TRACK, CUSTOM_COVER)
fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0
@ -28,11 +37,31 @@ object MigrationFlags {
return value and TRACK != 0
}
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
fun getEnabledFlagsPositions(value: Int): List<Int> {
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
}
fun getFlagsFromPositions(positions: Array<Int>): Int {
return positions.fold(0, { accumulated, position -> accumulated or (1 shl position) })
return positions.fold(0) { accumulated, position -> accumulated or (1 shl position) }
}
fun titles(manga: Manga?): Array<Int> {
val titles = arrayOf(R.string.chapters, R.string.categories).toMutableList()
if (manga != null) {
db.inTransaction {
if (db.getTracks(manga).executeAsBlocking().isNotEmpty()) {
titles.add(R.string.track)
}
if (manga.hasCustomCover(coverCache)) {
titles.add(R.string.custom_cover)
}
}
}
return titles.toTypedArray()
}
}

View File

@ -95,7 +95,7 @@ class SearchController(
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val prefValue = preferences.migrateFlags().get()
val enabledFlagsPositions = MigrationFlags.getEnabledFlagsPositions(prefValue)
val items = MigrationFlags.titles
val items = MigrationFlags.titles(manga)
.map { resources?.getString(it) }
.toTypedArray()
val selected = items
@ -129,7 +129,10 @@ class SearchController(
}
(targetController as? SearchController)?.copyManga(manga, newManga)
}
.setNeutralButton(android.R.string.cancel, null)
.setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
dismissDialog()
router.pushController(MangaController(newManga).withFadeTransaction())
}
.create()
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
@ -17,12 +18,14 @@ import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchCardItem
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchItem
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.hasCustomCover
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Date
class SearchPresenter(
@ -31,7 +34,7 @@ class SearchPresenter(
) : GlobalSearchPresenter(initialQuery) {
private val replacingMangaRelay = BehaviorRelay.create<Pair<Boolean, Manga?>>()
private val coverCache: CoverCache by injectLazy()
private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
override fun onCreate(savedState: Bundle?) {
@ -103,6 +106,10 @@ class SearchPresenter(
MigrationFlags.hasTracks(
flags,
)
val migrateCustomCover =
MigrationFlags.hasCustomCover(
flags,
)
db.inTransaction {
// Update chapters read
@ -174,6 +181,11 @@ class SearchPresenter(
manga.date_added = Date().time
}
// Update custom cover
if (migrateCustomCover) {
coverCache.setCustomCoverToCache(manga, coverCache.getCustomCoverFile(prevManga).inputStream())
}
// SearchPresenter#networkToLocalManga may have updated the manga title,
// so ensure db gets updated title too
db.insertManga(manga).executeAsBlocking()

View File

@ -37,6 +37,7 @@ import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.AddDuplicateMangaDialog
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@ -343,19 +344,20 @@ open class BrowseSourceController(bundle: Bundle) :
* @param genreName the name of the genre
*/
fun searchWithGenre(genreName: String) {
presenter.sourceFilters = presenter.source.getFilterList()
val defaultFilters = presenter.source.getFilterList()
var filterList: FilterList? = null
var genreExists = false
filter@ for (sourceFilter in presenter.sourceFilters) {
filter@ for (sourceFilter in defaultFilters) {
if (sourceFilter is Filter.Group<*>) {
for (filter in sourceFilter.state) {
if (filter is Filter<*> && filter.name.equals(genreName, true)) {
when (filter) {
is Filter.TriState -> filter.state = 1
is Filter.CheckBox -> filter.state = true
else -> {}
}
filterList = presenter.sourceFilters
genreExists = true
break@filter
}
}
@ -365,19 +367,20 @@ open class BrowseSourceController(bundle: Bundle) :
if (index != -1) {
sourceFilter.state = index
filterList = presenter.sourceFilters
genreExists = true
break
}
}
}
if (filterList != null) {
if (genreExists) {
presenter.sourceFilters = defaultFilters
filterSheet?.setFilters(presenter.filterItems)
showProgressBar()
adapter?.clear()
presenter.restartPager("", filterList)
presenter.restartPager("", defaultFilters)
} else {
searchWithQuery(genreName)
}
@ -586,6 +589,7 @@ open class BrowseSourceController(bundle: Bundle) :
override fun onItemLongClick(position: Int) {
val activity = activity ?: return
val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
if (manga.favorite) {
MaterialAlertDialogBuilder(activity)
@ -601,43 +605,53 @@ open class BrowseSourceController(bundle: Bundle) :
}
.show()
} else {
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
if (duplicateManga != null) {
AddDuplicateMangaDialog(this, duplicateManga) { addToLibrary(manga, position) }
.showDialog(router)
} else {
addToLibrary(manga, position)
}
}
}
when {
// Default category set
defaultCategory != null -> {
presenter.moveMangaToCategory(manga, defaultCategory)
private fun addToLibrary(newManga: Manga, position: Int) {
val activity = activity ?: return
val categories = presenter.getCategories()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
presenter.changeMangaFavorite(manga)
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
when {
// Default category set
defaultCategory != null -> {
presenter.moveMangaToCategory(newManga, defaultCategory)
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
presenter.moveMangaToCategory(manga, null)
presenter.changeMangaFavorite(newManga)
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
presenter.changeMangaFavorite(manga)
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
// Automatic 'Default' or no categories
defaultCategoryId == 0 || categories.isEmpty() -> {
presenter.moveMangaToCategory(newManga, null)
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
presenter.changeMangaFavorite(newManga)
adapter?.notifyItemChanged(position)
activity.toast(activity.getString(R.string.manga_added_library))
}
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
// Choose a category
else -> {
val ids = presenter.getMangaCategoryIds(newManga)
val preselected = categories.map {
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(newManga), categories, preselected)
.showDialog(router)
}
}
}

View File

@ -351,6 +351,10 @@ open class BrowseSourcePresenter(
return db.getCategories().executeAsBlocking()
}
fun getDuplicateLibraryManga(manga: Manga): Manga? {
return db.getDuplicateLibraryManga(manga).executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*

View File

@ -42,10 +42,10 @@ open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem<TriS
else -> throw Exception("Unknown state")
},
)?.apply {
val color = if (filter.state == Filter.TriState.STATE_INCLUDE) {
view.context.getResourceColor(R.attr.colorAccent)
} else {
val color = if (filter.state == Filter.TriState.STATE_IGNORE) {
view.context.getResourceColor(R.attr.colorOnBackground, 0.38f)
} else {
view.context.getResourceColor(R.attr.colorPrimary)
}
setTint(color)

View File

@ -28,8 +28,7 @@ class DownloadHeaderHolder(view: View, adapter: FlexibleAdapter<*>) : Expandable
override fun onItemReleased(position: Int) {
super.onItemReleased(position)
binding.container.isDragged = false
mAdapter as DownloadAdapter
mAdapter.expandAll()
mAdapter.downloadItemListener.onItemReleased(position)
(mAdapter as DownloadAdapter).downloadItemListener.onItemReleased(position)
}
}

View File

@ -89,7 +89,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) :
view.popupMenu(
menuRes = R.menu.download_single,
initMenu = {
findItem(R.id.move_to_top).isVisible = bindingAdapterPosition != 0
findItem(R.id.move_to_top).isVisible = bindingAdapterPosition > 1
findItem(R.id.move_to_bottom).isVisible =
bindingAdapterPosition != adapter.itemCount - 1
},

View File

@ -8,7 +8,6 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.core.view.doOnAttach
import androidx.core.view.isVisible
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
@ -304,8 +303,10 @@ class LibraryController(
onTabsSettingsChanged(firstLaunch = true)
// Delay the scroll position to allow the view to be properly measured.
view.doOnAttach {
(activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true)
view.post {
if (isAttached) {
(activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true)
}
}
// Send the manga map to child fragments after the adapter is updated.
@ -387,7 +388,7 @@ class LibraryController(
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search)
// Mutate the filter icon because it needs to be tinted and the resource is shared.
menu.findItem(R.id.action_filter).icon.mutate()
menu.findItem(R.id.action_filter).icon?.mutate()
}
fun search(query: String) {
@ -413,7 +414,7 @@ class LibraryController(
// Tint icon if there's a filter active
if (settingsSheet.filters.hasActiveFilters()) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive)
filterItem.icon.setTint(filterColor)
filterItem.icon?.setTint(filterColor)
}
}

View File

@ -394,6 +394,7 @@ class LibrarySettingsSheet(
unreadBadge -> preferences.unreadBadge().set((item.checked))
localBadge -> preferences.localBadge().set((item.checked))
languageBadge -> preferences.languageBadge().set((item.checked))
else -> {}
}
adapter.notifyItemChanged(item)
}
@ -418,6 +419,7 @@ class LibrarySettingsSheet(
when (item) {
showTabs -> preferences.categoryTabs().set(item.checked)
showNumberOfItems -> preferences.categoryNumberOfItems().set(item.checked)
else -> {}
}
adapter.notifyItemChanged(item)
}

View File

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.ui.library
@Deprecated("Deprecated in favor for SortModeSetting")
object LibrarySort {
const val ALPHA = 0
const val LAST_READ = 1
const val LAST_CHECKED = 2
const val UNREAD = 3
const val TOTAL = 4
const val LATEST_CHAPTER = 6
const val CHAPTER_FETCH_DATE = 8
const val DATE_ADDED = 7
@Deprecated("Removed in favor of searching by source")
const val SOURCE = 5
}

View File

@ -466,7 +466,7 @@ class MainActivity : BaseActivity() {
// Binding sometimes isn't actually instantiated yet somehow
nav?.setOnItemSelectedListener(null)
binding?.toolbar.setNavigationOnClickListener(null)
binding?.toolbar?.setNavigationOnClickListener(null)
}
override fun onBackPressed() {

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.ui.manga
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import uy.kohesive.injekt.injectLazy
class AddDuplicateMangaDialog(bundle: Bundle? = null) : DialogController(bundle) {
private val sourceManager: SourceManager by injectLazy()
private lateinit var libraryManga: Manga
private lateinit var onAddToLibrary: () -> Unit
constructor(
target: Controller,
libraryManga: Manga,
onAddToLibrary: () -> Unit,
) : this() {
targetController = target
this.libraryManga = libraryManga
this.onAddToLibrary = onAddToLibrary
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val source = sourceManager.getOrStub(libraryManga.source)
return MaterialAlertDialogBuilder(activity!!)
.setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name))
.setPositiveButton(activity?.getString(R.string.action_add)) { _, _ ->
onAddToLibrary()
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
dismissDialog()
router.pushController(MangaController(libraryManga.id!!).withFadeTransaction())
}
.setCancelable(true)
.create()
}
}

View File

@ -29,7 +29,6 @@ import coil.request.ImageRequest
import com.bluelinelabs.conductor.Controller
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
@ -542,18 +541,8 @@ class MangaController :
private fun showAddDuplicateDialog(newManga: Manga, libraryManga: Manga) {
activity?.let {
val source = sourceManager.getOrStub(libraryManga.source)
MaterialAlertDialogBuilder(it).apply {
setMessage(activity?.getString(R.string.confirm_manga_add_duplicate, source.name))
setPositiveButton(activity?.getString(R.string.action_add)) { _, _ ->
addToLibrary(newManga)
}
setNegativeButton(activity?.getString(R.string.action_cancel)) { _, _ -> }
setNeutralButton(activity?.getString(R.string.action_show_manga)) { _, _ ->
router.pushController(MangaController(libraryManga).withFadeTransaction())
}
setCancelable(true)
}.create().show()
AddDuplicateMangaDialog(this, libraryManga) { addToLibrary(newManga) }
.showDialog(router)
}
}

View File

@ -113,6 +113,7 @@ class ChaptersSettingsSheet(
downloaded -> presenter.setDownloadedFilter(newState)
unread -> presenter.setUnreadFilter(newState)
bookmarked -> presenter.setBookmarkedFilter(newState)
else -> {}
}
initModels()

View File

@ -226,6 +226,11 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
super.onSaveInstanceState(outState)
}
override fun onPause() {
presenter.saveProgress()
super.onPause()
}
/**
* Set menu visibility again on activity resume to apply immersive mode again if needed.
* Helps with rotations.
@ -355,15 +360,16 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
// Init listeners on bottom menu
binding.pageSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
isScrollingThroughPages = true
}
binding.pageSlider.addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
isScrollingThroughPages = true
}
override fun onStopTrackingTouch(slider: Slider) {
isScrollingThroughPages = false
}
},
override fun onStopTrackingTouch(slider: Slider) {
isScrollingThroughPages = false
}
},
)
binding.pageSlider.addOnChangeListener { slider, value, fromUser ->
if (viewer != null && fromUser) {

View File

@ -4,7 +4,6 @@ import android.app.Application
import android.net.Uri
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.History
@ -22,6 +21,7 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@ -345,6 +345,14 @@ class ReaderPresenter(
* that the user doesn't have to wait too long to continue reading.
*/
private fun preload(chapter: ReaderChapter) {
if (chapter.pageLoader is HttpPageLoader) {
val manga = manga ?: return
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga)
if (isDownloaded) {
chapter.state = ReaderChapter.State.Wait
}
}
if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) {
return
}
@ -456,6 +464,10 @@ class ReaderPresenter(
}
}
fun saveProgress() {
getCurrentChapter()?.let { onChapterChanged(it) }
}
/**
* Called from the activity to preload the given [chapter].
*/
@ -662,20 +674,22 @@ class ReaderPresenter(
Observable
.fromCallable {
if (manga.isLocal()) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream())
manga.updateCoverLastModified(db)
R.string.cover_updated
SetAsCoverResult.Success
} else {
if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, stream())
stream().use {
if (manga.isLocal()) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, it)
manga.updateCoverLastModified(db)
coverCache.clearMemoryCache()
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, it)
manga.updateCoverLastModified(db)
coverCache.clearMemoryCache()
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
}
}
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
@ -83,7 +84,11 @@ class ChapterLoader(
when (format) {
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
is LocalSource.Format.Rar -> RarPageLoader(format.file)
is LocalSource.Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.getString(R.string.loader_rar5_error))
}
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
}
}

View File

@ -10,9 +10,9 @@ data class ReaderChapter(val chapter: Chapter) {
var state: State =
State.Wait
set(value) {
field = value
stateRelay.call(value)
}
field = value
stateRelay.call(value)
}
private val stateRelay by lazy { BehaviorRelay.create(state) }

View File

@ -34,27 +34,28 @@ class ReaderSettingsSheet(
behavior.halfExpandedRatio = 0.25f
val filterTabIndex = getTabViews().indexOf(colorFilterSettings)
binding.tabs.addOnTabSelectedListener(object : SimpleTabSelectedListener() {
override fun onTabSelected(tab: TabLayout.Tab?) {
val isFilterTab = tab?.position == filterTabIndex
binding.tabs.addOnTabSelectedListener(
object : SimpleTabSelectedListener() {
override fun onTabSelected(tab: TabLayout.Tab?) {
val isFilterTab = tab?.position == filterTabIndex
// Remove dimmed backdrop so color filter changes can be previewed
backgroundDimAnimator.run {
if (isFilterTab) {
if (animatedFraction < 1f) {
start()
// Remove dimmed backdrop so color filter changes can be previewed
backgroundDimAnimator.run {
if (isFilterTab) {
if (animatedFraction < 1f) {
start()
}
} else if (animatedFraction > 0f) {
reverse()
}
} else if (animatedFraction > 0f) {
reverse()
}
// Hide toolbars
if (activity.menuVisible != !isFilterTab) {
activity.setMenuVisibility(!isFilterTab)
}
}
// Hide toolbars
if (activity.menuVisible != !isFilterTab) {
activity.setMenuVisibility(!isFilterTab)
}
}
},
},
)
if (showColorFilterSettings) {

View File

@ -249,6 +249,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
ZoomStartPosition.LEFT -> setScaleAndCenter(scale, PointF(0F, 0F))
ZoomStartPosition.RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0F))
ZoomStartPosition.CENTER -> setScaleAndCenter(scale, center.also { it?.y = 0F })
else -> {}
}
}
@ -310,7 +311,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
return true
}
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
this@ReaderPageImageView.onViewClicked()
return super.onSingleTapConfirmed(e)
}

View File

@ -1,15 +1,24 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlin.math.roundToInt
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
LinearLayout(context, attrs) {
@ -21,32 +30,42 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
fun bind(transition: ChapterTransition) {
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?) {
manga ?: return
when (transition) {
is ChapterTransition.Prev -> bindPrevChapterTransition(transition)
is ChapterTransition.Next -> bindNextChapterTransition(transition)
is ChapterTransition.Prev -> bindPrevChapterTransition(transition, downloadManager, manga)
is ChapterTransition.Next -> bindNextChapterTransition(transition, downloadManager, manga)
}
missingChapterWarning(transition)
}
/**
* Binds a previous chapter transition on this view and subscribes to the page load status.
*/
private fun bindPrevChapterTransition(transition: ChapterTransition) {
val prevChapter = transition.to
private fun bindPrevChapterTransition(
transition: ChapterTransition,
downloadManager: DownloadManager,
manga: Manga,
) {
val prevChapter = transition.to?.chapter
val hasPrevChapter = prevChapter != null
binding.lowerText.isVisible = hasPrevChapter
if (hasPrevChapter) {
binding.lowerText.isVisible = prevChapter != null
if (prevChapter != null) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
val isPrevDownloaded = downloadManager.isChapterDownloaded(
prevChapter,
manga,
)
val isCurrentDownloaded = transition.from.pageLoader is DownloadPageLoader
binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_previous)) }
append("\n${prevChapter!!.chapter.name}")
append("\n${prevChapter.name}")
if (isPrevDownloaded) addDLImageSpan()
}
binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_current)) }
append("\n${transition.from.chapter.name}")
if (isCurrentDownloaded) addDLImageSpan()
}
} else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER
@ -57,20 +76,30 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
/**
* Binds a next chapter transition on this view and subscribes to the load status.
*/
private fun bindNextChapterTransition(transition: ChapterTransition) {
val nextChapter = transition.to
private fun bindNextChapterTransition(
transition: ChapterTransition,
downloadManager: DownloadManager,
manga: Manga,
) {
val nextChapter = transition.to?.chapter
val hasNextChapter = nextChapter != null
binding.lowerText.isVisible = hasNextChapter
if (hasNextChapter) {
binding.lowerText.isVisible = nextChapter != null
if (nextChapter != null) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
val isCurrentDownloaded = transition.from.pageLoader is DownloadPageLoader
val isNextDownloaded = downloadManager.isChapterDownloaded(
nextChapter,
manga,
)
binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_finished)) }
append("\n${transition.from.chapter.name}")
if (isCurrentDownloaded) addDLImageSpan()
}
binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_next)) }
append("\n${nextChapter!!.chapter.name}")
append("\n${nextChapter.name}")
if (isNextDownloaded) addDLImageSpan()
}
} else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER
@ -78,6 +107,17 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
}
}
private fun SpannableStringBuilder.addDLImageSpan() {
val icon = ContextCompat.getDrawable(context, R.drawable.ic_offline_pin_24dp)?.mutate()
?.apply {
val size = binding.lowerText.textSize + 4.dpToPx
setTint(binding.lowerText.currentTextColor)
setBounds(0, 0, size.roundToInt(), size.roundToInt())
} ?: return
append(" ")
inSpans(ImageSpan(icon)) { append("image") }
}
private fun missingChapterWarning(transition: ChapterTransition) {
if (transition.to == null) {
binding.warning.isVisible = false

View File

@ -19,6 +19,7 @@ import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
@ -238,7 +239,7 @@ class PagerPageHolder(
.subscribe({}, {})
}
private fun process(page: ReaderPage, imageStream: InputStream): InputStream {
private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream {
if (!viewer.config.dualPageSplit) {
return imageStream
}
@ -247,7 +248,7 @@ class PagerPageHolder(
return splitInHalf(imageStream)
}
val isDoublePage = ImageUtil.isDoublePage(imageStream)
val isDoublePage = ImageUtil.isWideImage(imageStream)
if (!isDoublePage) {
return imageStream
}

View File

@ -61,7 +61,7 @@ class PagerTransitionHolder(
addView(transitionView)
addView(pagesContainer)
transitionView.bind(transition)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga)
transition.to?.let { observeStatus(it) }
}

View File

@ -11,6 +11,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.viewpager.widget.ViewPager
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
@ -21,6 +22,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import uy.kohesive.injekt.injectLazy
import kotlin.math.min
/**
@ -29,6 +31,8 @@ import kotlin.math.min
@Suppress("LeakingThis")
abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
val downloadManager: DownloadManager by injectLazy()
private val scope = MainScope()
/**
@ -66,9 +70,14 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let {
setChaptersInternal(it)
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersInternal(viewerChapters)
awaitingIdleViewerChapters = null
if (viewerChapters.currChapter.pages?.size == 1) {
adapter.nextTransition?.to?.let {
activity.requestPreloadChapter(it)
}
}
}
}
}

View File

@ -44,7 +44,7 @@ class WebtoonFrame(context: Context) : FrameLayout(context) {
* Scale listener used to delegate events to the recycler view.
*/
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin()
return true
}
@ -63,13 +63,13 @@ class WebtoonFrame(context: Context) : FrameLayout(context) {
* Fling listener used to delegate events to the recycler view.
*/
inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent?): Boolean {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
e1: MotionEvent,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {

View File

@ -23,6 +23,7 @@ import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.BufferedInputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
@ -272,12 +273,12 @@ class WebtoonPageHolder(
addSubscription(readImageHeaderSubscription)
}
private fun process(imageStream: InputStream): InputStream {
private fun process(imageStream: BufferedInputStream): InputStream {
if (!viewer.config.dualPageSplit) {
return imageStream
}
val isDoublePage = ImageUtil.isDoublePage(imageStream)
val isDoublePage = ImageUtil.isWideImage(imageStream)
if (!isDoublePage) {
return imageStream
}

View File

@ -46,7 +46,7 @@ class WebtoonTransitionHolder(
layout.orientation = LinearLayout.VERTICAL
layout.gravity = Gravity.CENTER
val paddingVertical = 48.dpToPx
val paddingVertical = 128.dpToPx
val paddingHorizontal = 32.dpToPx
layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
@ -63,7 +63,7 @@ class WebtoonTransitionHolder(
* Binds the given [transition] with this view holder, subscribing to its state.
*/
fun bind(transition: ChapterTransition) {
transitionView.bind(transition)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga)
transition.to?.let { observeStatus(it, transition) }
}

View File

@ -11,6 +11,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.WebtoonLayoutManager
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
@ -24,6 +25,7 @@ import kotlinx.coroutines.cancel
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.math.max
import kotlin.math.min
@ -32,6 +34,8 @@ import kotlin.math.min
*/
class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer {
val downloadManager: DownloadManager by injectLazy()
private val scope = MainScope()
/**
@ -103,6 +107,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
activity.requestPreloadChapter(firstItem.to)
}
}
val lastIndex = layoutManager.findLastEndVisibleItemPosition()
val lastItem = adapter.items.getOrNull(lastIndex)
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
activity.showMenu()
}
}
},
)
@ -216,9 +226,6 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
@ -245,7 +252,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
logcat { "moveToPage" }
val position = adapter.items.indexOf(page)
if (position != -1) {
recycler.scrollToPosition(position)
layoutManager.scrollToPositionWithOffset(position, 0)
if (layoutManager.findLastEndVisibleItemPosition() == -1) {
onScrolled(pos = position)
}

View File

@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.util.system.AuthenticatorUtil
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.startAuthentication
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
import java.util.Date
/**
* Blank activity with a BiometricPrompt.
@ -39,7 +38,6 @@ class UnlockActivity : BaseActivity() {
) {
super.onAuthenticationSucceeded(activity, result)
SecureActivityDelegate.locked = false
preferences.lastAppUnlock().set(Date().time)
finish()
}
},

View File

@ -16,9 +16,13 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PREF_DOH_360
import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD
import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.network.PREF_DOH_DNSPOD
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
@ -28,6 +32,7 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.bindTo
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.editTextPreference
import eu.kanade.tachiyomi.util.preference.entriesRes
import eu.kanade.tachiyomi.util.preference.intListPreference
import eu.kanade.tachiyomi.util.preference.listPreference
@ -48,6 +53,7 @@ import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority
import rikka.sui.Sui
import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
class SettingsAdvancedController : SettingsController() {
@ -143,12 +149,6 @@ class SettingsAdvancedController : SettingsController() {
titleRes = R.string.pref_auto_clear_chapter_cache
defaultValue = false
}
preference {
key = "pref_clear_webview_data"
titleRes = R.string.pref_clear_webview_data
onClick { clearWebViewData() }
}
preference {
key = "pref_clear_database"
titleRes = R.string.pref_clear_database
@ -172,6 +172,12 @@ class SettingsAdvancedController : SettingsController() {
activity?.toast(R.string.cookies_cleared)
}
}
preference {
key = "pref_clear_webview_data"
titleRes = R.string.pref_clear_webview_data
onClick { clearWebViewData() }
}
intListPreference {
key = Keys.dohProvider
titleRes = R.string.pref_dns_over_https
@ -181,6 +187,10 @@ class SettingsAdvancedController : SettingsController() {
"Google",
"AdGuard",
"Quad9",
"AliDNS",
"DNSPod",
"360",
"Quad 101",
)
entryValues = arrayOf(
"-1",
@ -188,6 +198,10 @@ class SettingsAdvancedController : SettingsController() {
PREF_DOH_GOOGLE.toString(),
PREF_DOH_ADGUARD.toString(),
PREF_DOH_QUAD9.toString(),
PREF_DOH_ALIDNS.toString(),
PREF_DOH_DNSPOD.toString(),
PREF_DOH_360.toString(),
PREF_DOH_QUAD101.toString(),
)
defaultValue = "-1"
summary = "%s"
@ -197,6 +211,28 @@ class SettingsAdvancedController : SettingsController() {
true
}
}
editTextPreference {
key = Keys.defaultUserAgent
titleRes = R.string.pref_user_agent_string
text = preferences.defaultUserAgent().get()
summary = network.defaultUserAgent
onChange {
activity?.toast(R.string.requires_app_restart)
true
}
}
if (preferences.defaultUserAgent().isSet()) {
preference {
key = "pref_reset_user_agent"
titleRes = R.string.pref_reset_user_agent_string
onClick {
preferences.defaultUserAgent().delete()
activity?.toast(R.string.requires_app_restart)
}
}
}
}
preferenceCategory {
@ -301,6 +337,7 @@ class SettingsAdvancedController : SettingsController() {
webview.clearHistory()
webview.clearSslPreferences()
WebStorage.getInstance().deleteAllData()
activity?.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
activity?.toast(R.string.webview_data_deleted)
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)

View File

@ -24,6 +24,7 @@ import eu.kanade.tachiyomi.util.preference.multiSelectListPreference
import eu.kanade.tachiyomi.util.preference.onClick
import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.toast
@ -72,6 +73,12 @@ class SettingsDownloadController : SettingsController() {
bindTo(preferences.saveChaptersAsCBZ())
titleRes = R.string.save_chapter_as_cbz
}
switchPreference {
bindTo(preferences.splitTallImages())
titleRes = R.string.split_tall_images
summaryRes = R.string.split_tall_images_summary
}
preferenceCategory {
titleRes = R.string.pref_category_delete_chapters
@ -125,20 +132,20 @@ class SettingsDownloadController : SettingsController() {
titleRes = R.string.pref_category_auto_download
switchPreference {
bindTo(preferences.downloadNew())
bindTo(preferences.downloadNewChapter())
titleRes = R.string.pref_download_new
}
preference {
bindTo(preferences.downloadNewCategories())
bindTo(preferences.downloadNewChapterCategories())
titleRes = R.string.categories
onClick {
DownloadCategoriesDialog().showDialog(router)
}
visibleIf(preferences.downloadNew()) { it }
visibleIf(preferences.downloadNewChapter()) { it }
fun updateSummary() {
val selectedCategories = preferences.downloadNewCategories().get()
val selectedCategories = preferences.downloadNewChapterCategories().get()
.mapNotNull { id -> categories.find { it.id == id.toInt() } }
.sortedBy { it.order }
val includedItemsText = if (selectedCategories.isEmpty()) {
@ -147,7 +154,7 @@ class SettingsDownloadController : SettingsController() {
selectedCategories.joinToString { it.name }
}
val excludedCategories = preferences.downloadNewCategoriesExclude().get()
val excludedCategories = preferences.downloadNewChapterCategoriesExclude().get()
.mapNotNull { id -> categories.find { it.id == id.toInt() } }
.sortedBy { it.order }
val excludedItemsText = if (excludedCategories.isEmpty()) {
@ -163,10 +170,10 @@ class SettingsDownloadController : SettingsController() {
}
}
preferences.downloadNewCategories().asFlow()
preferences.downloadNewChapterCategories().asFlow()
.onEach { updateSummary() }
.launchIn(viewScope)
preferences.downloadNewCategoriesExclude().asFlow()
preferences.downloadNewChapterCategoriesExclude().asFlow()
.onEach { updateSummary() }
.launchIn(viewScope)
}
@ -254,8 +261,8 @@ class SettingsDownloadController : SettingsController() {
var selected = categories
.map {
when (it.id.toString()) {
in preferences.downloadNewCategories().get() -> QuadStateTextView.State.CHECKED.ordinal
in preferences.downloadNewCategoriesExclude().get() -> QuadStateTextView.State.INVERSED.ordinal
in preferences.downloadNewChapterCategories().get() -> QuadStateTextView.State.CHECKED.ordinal
in preferences.downloadNewChapterCategoriesExclude().get() -> QuadStateTextView.State.INVERSED.ordinal
else -> QuadStateTextView.State.UNCHECKED.ordinal
}
}
@ -282,8 +289,8 @@ class SettingsDownloadController : SettingsController() {
.map { categories[it].id.toString() }
.toSet()
preferences.downloadNewCategories().set(included)
preferences.downloadNewCategoriesExclude().set(excluded)
preferences.downloadNewChapterCategories().set(included)
preferences.downloadNewChapterCategoriesExclude().set(excluded)
}
.setNegativeButton(android.R.string.cancel, null)
.create()

View File

@ -11,12 +11,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.*
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.PrefLibraryColumnsBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
@ -159,8 +154,8 @@ class SettingsLibraryController : SettingsController() {
multiSelectListPreference {
bindTo(preferences.libraryUpdateDeviceRestriction())
titleRes = R.string.pref_library_update_restriction
entriesRes = arrayOf(R.string.connected_to_wifi, R.string.charging)
entryValues = arrayOf(DEVICE_ONLY_ON_WIFI, DEVICE_CHARGING)
entriesRes = arrayOf(R.string.connected_to_wifi, R.string.network_not_metered, R.string.charging, R.string.battery_not_low)
entryValues = arrayOf(DEVICE_ONLY_ON_WIFI, DEVICE_NETWORK_NOT_METERED, DEVICE_CHARGING, DEVICE_BATTERY_NOT_LOW)
visibleIf(preferences.libraryUpdateInterval()) { it > 0 }
@ -176,7 +171,9 @@ class SettingsLibraryController : SettingsController() {
.map {
when (it) {
DEVICE_ONLY_ON_WIFI -> context.getString(R.string.connected_to_wifi)
DEVICE_NETWORK_NOT_METERED -> context.getString(R.string.network_not_metered)
DEVICE_CHARGING -> context.getString(R.string.charging)
DEVICE_BATTERY_NOT_LOW -> context.getString(R.string.battery_not_low)
else -> it
}
}

View File

@ -102,13 +102,13 @@ class SettingsMainController : SettingsController() {
searchItem.setOnActionExpandListener(
object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
preferences.lastSearchQuerySearchSettings().set("") // reset saved search query
router.pushController(SettingsSearchController().withFadeTransaction())
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
return true
}
},

View File

@ -66,7 +66,7 @@ class ClearDatabaseController :
adapter = FlexibleAdapter<ClearDatabaseSourceItem>(null, this, true)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(activity)
binding.recycler.layoutManager = LinearLayoutManager(activity!!)
binding.recycler.setHasFixedSize(true)
adapter?.fastScroller = binding.fastScroller
recycler = binding.recycler

View File

@ -74,11 +74,11 @@ class SettingsSearchController :
searchItem.setOnActionExpandListener(
object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
router.popCurrentController()
return false
}

View File

@ -166,12 +166,12 @@ class WebViewActivity : BaseActivity() {
menu.findItem(R.id.action_web_back).apply {
isEnabled = binding.webview.canGoBack()
icon.setTint(if (binding.webview.canGoBack()) iconTintColor else translucentIconTintColor)
icon?.setTint(if (binding.webview.canGoBack()) iconTintColor else translucentIconTintColor)
}
menu.findItem(R.id.action_web_forward).apply {
isEnabled = binding.webview.canGoForward()
icon.setTint(if (binding.webview.canGoForward()) iconTintColor else translucentIconTintColor)
icon?.setTint(if (binding.webview.canGoForward()) iconTintColor else translucentIconTintColor)
}
return super.onPrepareOptionsMenu(menu)

View File

@ -56,14 +56,14 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
if (!favorite) return false
// Boolean to determine if user wants to automatically download new chapters.
val downloadNew = prefs.downloadNew().get()
if (!downloadNew) return false
val downloadNewChapter = prefs.downloadNewChapter().get()
if (!downloadNewChapter) return false
val categoriesToDownload = prefs.downloadNewCategories().get().map(String::toInt)
val categoriesToExclude = prefs.downloadNewCategoriesExclude().get().map(String::toInt)
val includedCategories = prefs.downloadNewChapterCategories().get().map { it.toInt() }
val excludedCategories = prefs.downloadNewChapterCategoriesExclude().get().map { it.toInt() }
// Default: download from all categories
if (categoriesToDownload.isEmpty() && categoriesToExclude.isEmpty()) return true
// Default: Download from all categories
if (includedCategories.isEmpty() && excludedCategories.isEmpty()) return true
// Get all categories, else default category (0)
val categoriesForManga =
@ -72,8 +72,11 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
.takeUnless { it.isEmpty() } ?: listOf(0)
// In excluded category
if (categoriesForManga.intersect(categoriesToExclude).isNotEmpty()) return false
if (categoriesForManga.any { it in excludedCategories }) return false
// Included category not selected
if (includedCategories.isEmpty()) return true
// In included category
return categoriesForManga.intersect(categoriesToDownload).isNotEmpty()
return categoriesForManga.any { it in includedCategories }
}

View File

@ -46,8 +46,8 @@ object ChapterRecognition {
// Get chapter title with lower case
var name = chapter.name.lowercase()
// Remove comma's from chapter.
name = name.replace(',', '.')
// Remove comma's or hyphens.
name = name.replace(',', '.').replace('-', '.')
// Remove unwanted white spaces.
unwantedWhiteSpace.findAll(name).let {

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
return when (manga.sorting) {
@ -11,13 +10,13 @@ fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending(
false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
}
Manga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
true -> { c1, c2 -> c2.chapter_number.toString().compareToCaseInsensitiveNaturalOrder(c1.chapter_number.toString()) }
false -> { c1, c2 -> c1.chapter_number.toString().compareToCaseInsensitiveNaturalOrder(c2.chapter_number.toString()) }
true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) }
false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
}
Manga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) }
false -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
}
else -> throw NotImplementedError("Unimplemented sorting method")
else -> throw NotImplementedError("Invalid chapter sorting method: ${manga.sorting}")
}
}

View File

@ -11,6 +11,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
import java.util.TreeSet
import kotlin.math.max
/**
* Helper method for syncing the list of chapters from the source with the ones from the database.
@ -59,6 +60,9 @@ fun syncChaptersWithSource(
}
}
var maxTimestamp = 0L // in previous chapters to add
val rightNow = Date().time
for (sourceChapter in sourceChapters) {
// This forces metadata update for the main viewable things in the chapter list.
if (source is HttpSource) {
@ -72,7 +76,9 @@ fun syncChaptersWithSource(
// Add the chapter if not in db already, or update if the metadata changed.
if (dbChapter == null) {
if (sourceChapter.date_upload == 0L) {
sourceChapter.date_upload = Date().time
sourceChapter.date_upload = if (maxTimestamp == 0L) rightNow else maxTimestamp
} else {
maxTimestamp = max(maxTimestamp, sourceChapter.date_upload)
}
toAdd.add(sourceChapter)
} else {
@ -97,6 +103,7 @@ fun syncChaptersWithSource(
return Pair(emptyList(), emptyList())
}
// Keep it a List instead of a Set. See #6372.
val readded = mutableListOf<Chapter>()
db.inTransaction {
@ -154,6 +161,7 @@ fun syncChaptersWithSource(
db.updateLastUpdated(manga).executeAsBlocking()
}
@Suppress("ConvertArgumentToSet")
return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList())
}

View File

@ -1,12 +1,11 @@
package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File
@ -74,21 +73,11 @@ object DiskUtil {
}
}
/**
* Scans the given file so that it can be shown in gallery apps, for example.
*/
fun scanMedia(context: Context, file: File) {
scanMedia(context, file.toUri())
}
/**
* Scans the given file so that it can be shown in gallery apps, for example.
*/
fun scanMedia(context: Context, uri: Uri) {
val action = Intent.ACTION_MEDIA_SCANNER_SCAN_FILE
val mediaScanIntent = Intent(action)
mediaScanIntent.data = uri
context.sendBroadcast(mediaScanIntent)
MediaScannerConnection.scanFile(context, arrayOf(uri.path), null, null)
}
/**

View File

@ -47,6 +47,7 @@ import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
@ -87,7 +88,11 @@ fun Context.copyToClipboard(label: String, content: String) {
val clipboard = getSystemService<ClipboardManager>()!!
clipboard.setPrimaryClip(ClipData.newPlainText(label, content))
toast(getString(R.string.copied_to_clipboard, content.truncateCenter(50)))
// Android 13 and higher shows a visual confirmation of copied contents
// https://developer.android.com/about/versions/13/features/copy-paste
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
toast(getString(R.string.copied_to_clipboard, content.truncateCenter(50)))
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
toast(R.string.clipboard_copy_error)
@ -162,6 +167,9 @@ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermissio
}
}
val getDisplayMaxHeightInPx: Int
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
/**
* Converts to dp.
*/
@ -254,7 +262,7 @@ fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
}
fun Context.defaultBrowserPackageName(): String? {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri())
return packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo?.packageName
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers }
@ -311,8 +319,8 @@ fun Context.isNightMode(): Boolean {
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=348;drc=e28752c96fc3fb4d3354781469a1af3dbded4898
*/
fun Context.createReaderThemeContext(): Context {
val prefs = Injekt.get<PreferencesHelper>()
val isDarkBackground = when (prefs.readerTheme().get()) {
val preferences = Injekt.get<PreferencesHelper>()
val isDarkBackground = when (preferences.readerTheme().get()) {
1, 2 -> true // Black, Gray
3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default
else -> false // White
@ -325,7 +333,7 @@ fun Context.createReaderThemeContext(): Context {
val wrappedContext = ContextThemeWrapper(this, R.style.Theme_Tachiyomi)
wrappedContext.applyOverrideConfiguration(overrideConf)
ThemingDelegate.getThemeResIds(prefs.appTheme().get(), prefs.themeDarkAmoled().get())
ThemingDelegate.getThemeResIds(preferences.appTheme().get(), preferences.themeDarkAmoled().get())
.forEach { wrappedContext.theme.applyStyle(it, true) }
return wrappedContext
}

View File

@ -4,25 +4,35 @@ import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.blue
import androidx.core.graphics.createBitmap
import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import com.hippo.unifile.UniFile
import logcat.LogPriority
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.URLConnection
import kotlin.math.abs
import kotlin.math.min
object ImageUtil {
@ -56,6 +66,12 @@ object ImageUtil {
return null
}
fun getExtensionFromMimeType(mime: String?): String {
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
?: SUPPLEMENTARY_MIMETYPE_MAPPING[mime]
?: "jpg"
}
fun isAnimatedAndSupported(stream: InputStream): Boolean {
try {
val type = getImageType(stream) ?: return false
@ -66,8 +82,7 @@ object ImageUtil {
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
else -> false
}
} catch (e: Exception) {
}
} catch (e: Exception) { /* Do Nothing */ }
return false
}
@ -99,19 +114,12 @@ object ImageUtil {
}
/**
* Check whether the image is a double-page spread
* Check whether the image is wide (which we consider a double-page spread).
*
* @return true if the width is greater than the height
*/
fun isDoublePage(imageStream: InputStream): Boolean {
imageStream.mark(imageStream.available() + 1)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
imageStream.reset()
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
return options.outWidth > options.outHeight
}
@ -178,6 +186,111 @@ object ImageUtil {
RIGHT, LEFT
}
/**
* Check whether the image is considered a tall image.
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
return (options.outHeight / options.outWidth) > 3
}
/**
* Splits tall images to improve performance of reader
*/
fun splitTallImage(imageFile: UniFile, imageFilePath: String): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
return true
}
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false }
// Values are stored as they get modified during split loop
val imageHeight = options.outHeight
val imageWidth = options.outWidth
val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt()
// -1 so it doesn't try to split when imageHeight = getDisplayHeightInPx
val partCount = (imageHeight - 1) / splitHeight + 1
val optimalSplitHeight = imageHeight / partCount
val splitDataList = (0 until partCount).fold(mutableListOf<SplitData>()) { list, index ->
list.apply {
// Only continue if the list is empty or there is image remaining
if (isEmpty() || imageHeight > last().bottomOffset) {
val topOffset = index * optimalSplitHeight
var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset)
val remainingHeight = imageHeight - (topOffset + outputImageHeight)
// If remaining height is smaller or equal to 1/3th of
// optimal split height then include it in current page
if (remainingHeight <= (optimalSplitHeight / 3)) {
outputImageHeight += remainingHeight
}
add(SplitData(index, topOffset, outputImageHeight))
}
}
}
val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageFile.openInputStream())
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(imageFile.openInputStream(), false)
}
if (bitmapRegionDecoder == null) {
logcat { "Failed to create new instance of BitmapRegionDecoder" }
return false
}
logcat {
"Splitting image with height of $imageHeight into $partCount part " +
"with estimated ${optimalSplitHeight}px height per split"
}
return try {
splitDataList.forEach { splitData ->
val splitPath = splitImagePath(imageFilePath, splitData.index)
val region = Rect(0, splitData.topOffset, imageWidth, splitData.bottomOffset)
FileOutputStream(splitPath).use { outputStream ->
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
splitBitmap.recycle()
}
logcat {
"Success: Split #${splitData.index + 1} with topOffset=${splitData.topOffset} " +
"height=${splitData.outputImageHeight} bottomOffset=${splitData.bottomOffset}"
}
}
imageFile.delete()
true
} catch (e: Exception) {
// Image splits were not successfully saved so delete them and keep the original image
splitDataList
.map { splitImagePath(imageFilePath, it.index) }
.forEach { File(it).delete() }
logcat(LogPriority.ERROR, e)
false
} finally {
bitmapRegionDecoder.recycle()
}
}
private fun splitImagePath(imageFilePath: String, index: Int) =
imageFilePath.substringBeforeLast(".") + "__${"%03d".format(index + 1)}.jpg"
data class SplitData(
val index: Int,
val topOffset: Int,
val outputImageHeight: Int,
) {
val bottomOffset = topOffset + outputImageHeight
}
/**
* Algorithm for determining what background to accompany a comic/manga page
*/
@ -202,14 +315,14 @@ object ImageUtil {
val leftOffsetX = left - offsetX
val rightOffsetX = right + offsetX
val topLeftPixel = image.getPixel(left, top)
val topRightPixel = image.getPixel(right, top)
val midLeftPixel = image.getPixel(left, midY)
val midRightPixel = image.getPixel(right, midY)
val topCenterPixel = image.getPixel(midX, top)
val botLeftPixel = image.getPixel(left, bot)
val bottomCenterPixel = image.getPixel(midX, bot)
val botRightPixel = image.getPixel(right, bot)
val topLeftPixel = image[left, top]
val topRightPixel = image[right, top]
val midLeftPixel = image[left, midY]
val midRightPixel = image[right, midY]
val topCenterPixel = image[midX, top]
val botLeftPixel = image[left, bot]
val bottomCenterPixel = image[midX, bot]
val botRightPixel = image[right, bot]
val topLeftIsDark = topLeftPixel.isDark()
val topRightIsDark = topRightPixel.isDark()
@ -262,8 +375,8 @@ object ImageUtil {
var whiteStreak = false
val notOffset = x == left || x == right
inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) {
val pixel = image.getPixel(x, y)
val pixelOff = image.getPixel(x + (if (x < image.width / 2) -offsetX else offsetX), y)
val pixel = image[x, y]
val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
if (pixel.isWhite()) {
whitePixelsStreak++
whitePixels++
@ -354,8 +467,8 @@ object ImageUtil {
val topCornersIsDark = topLeftIsDark && topRightIsDark
val botCornersIsDark = botLeftIsDark && botRightIsDark
val topOffsetCornersIsDark = image.getPixel(leftOffsetX, top).isDark() && image.getPixel(rightOffsetX, top).isDark()
val botOffsetCornersIsDark = image.getPixel(leftOffsetX, bot).isDark() && image.getPixel(rightOffsetX, bot).isDark()
val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
val gradient = when {
darkBG && botCornersIsWhite -> {
@ -384,12 +497,34 @@ object ImageUtil {
)
}
private fun Int.isDark(): Boolean =
private fun @receiver:ColorInt Int.isDark(): Boolean =
red < 40 && blue < 40 && green < 40 && alpha > 200
private fun Int.isCloseTo(other: Int): Boolean =
private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean =
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
private fun Int.isWhite(): Boolean =
private fun @receiver:ColorInt Int.isWhite(): Boolean =
red + blue + green > 740
/**
* Used to check an image's dimensions without loading it in the memory.
*/
private fun extractImageOptions(
imageStream: InputStream,
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
imageStream.mark(imageStream.available() + 1)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
if (resetAfterExtraction) imageStream.reset()
return options
}
// Android doesn't include some mappings
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
// https://issuetracker.google.com/issues/182703810
"image/jxl" to "jxl",
)
}

View File

@ -73,7 +73,7 @@ open class ExtendedNavigationView @JvmOverloads constructor(
* @param context any context.
* @param resId the vector resource to load and tint
*/
fun tintVector(context: Context, resId: Int, @AttrRes colorAttrRes: Int = R.attr.colorAccent): Drawable {
fun tintVector(context: Context, resId: Int, @AttrRes colorAttrRes: Int = R.attr.colorPrimary): Drawable {
return AppCompatResources.getDrawable(context, resId)!!.apply {
setTint(context.getResourceColor(if (enabled) colorAttrRes else R.attr.colorControlNormal))
}

View File

@ -115,12 +115,13 @@ class TachiyomiBottomNavigationView @JvmOverloads constructor(
.setInterpolator(interpolator)
.setDuration(duration)
.applySystemAnimatorScale(context)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
currentAnimator = null
postInvalidate()
}
},
.setListener(
object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
currentAnimator = null
postInvalidate()
}
},
)
}

View File

@ -29,7 +29,7 @@ class QuadStateTextView @JvmOverloads constructor(context: Context, attrs: Attri
val tint = if (state == State.UNCHECKED) {
context.getThemeColor(R.attr.colorControlNormal)
} else {
context.getThemeColor(R.attr.colorAccent)
context.getThemeColor(R.attr.colorPrimary)
}
if (tint != 0) {
TextViewCompat.setCompoundDrawableTintList(this, ColorStateList.valueOf(tint))

View File

@ -37,12 +37,13 @@ class ThemesPreference @JvmOverloads constructor(context: Context, attrs: Attrib
recycler?.adapter = adapter
// Retain scroll position on activity recreate after changing theme
recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
lastScrollPosition = recyclerView.computeHorizontalScrollOffset()
}
},
recycler?.addOnScrollListener(
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
lastScrollPosition = recyclerView.computeHorizontalScrollOffset()
}
},
)
lastScrollPosition?.let { scrollToOffset(it) }
}

View File

@ -45,11 +45,12 @@ class BottomSheetViewPager @JvmOverloads constructor(
}
init {
addOnPageChangeListener(object : SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
requestLayout()
}
},
addOnPageChangeListener(
object : SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
requestLayout()
}
},
)
}
}

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