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

View File

@ -3,7 +3,7 @@
I acknowledge that: I acknowledge that:
- I have updated: - 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 - All extensions
- I have tried the troubleshooting guide: https://tachiyomi.org/help/guides/troubleshooting-problems/ - 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 - 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 label: Tachiyomi version
description: You can find your Tachiyomi version in **More → About**. description: You can find your Tachiyomi version in **More → About**.
placeholder: | placeholder: |
Example: "0.13.4" Example: "0.13.6"
validations: validations:
required: true required: true
@ -98,7 +98,7 @@ body:
required: true required: true
- label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/). - label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
required: true 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 required: true
- label: I have updated all installed extensions. - label: I have updated all installed extensions.
required: true required: true

View File

@ -33,7 +33,7 @@ body:
required: true required: true
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose). - label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
required: true required: true
- label: I have 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 required: true
- label: I will fill out all of the requested information in this form. - label: I will fill out all of the requested information in this form.
required: true 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' - '**.md'
- 'app/src/main/res/**/strings.xml' - 'app/src/main/res/**/strings.xml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions: permissions:
contents: read contents: read
@ -21,19 +25,15 @@ jobs:
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@v1 uses: actions/dependency-review-action@v2
- name: Set up JDK 11 - name: Set up JDK 11
uses: actions/setup-java@v1 uses: actions/setup-java@v3
with: with:
java-version: 11 java-version: 11
distribution: adopt
- name: Copy CI gradle.properties - name: Build app and run unit tests
run: |
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Build app
uses: gradle/gradle-command-action@v2 uses: gradle/gradle-command-action@v2
with: with:
arguments: assembleStandardRelease arguments: assembleStandardRelease testStandardReleaseUnitTest

View File

@ -6,18 +6,16 @@ on:
tags: tags:
- v* - v*
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs: jobs:
build: build:
name: Build app name: Build app
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - name: Clone repo
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -25,19 +23,15 @@ jobs:
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
- name: Set up JDK 11 - name: Set up JDK 11
uses: actions/setup-java@v1 uses: actions/setup-java@v3
with: with:
java-version: 11 java-version: 11
distribution: adopt
- name: Copy CI gradle.properties - name: Build app and run unit tests
run: |
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Build app
uses: gradle/gradle-command-action@v2 uses: gradle/gradle-command-action@v2
with: with:
arguments: assembleStandardRelease arguments: assembleStandardRelease testStandardReleaseUnitTest
# Sign APK and create release for tags # 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 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
@ -17,6 +18,7 @@ shortcutHelper.setFilePath("./shortcuts.xml")
val SUPPORTED_ABIS = setOf("armeabi-v7a", "arm64-v8a", "x86") val SUPPORTED_ABIS = setOf("armeabi-v7a", "arm64-v8a", "x86")
android { android {
namespace = "eu.kanade.tachiyomi"
compileSdk = AndroidConfig.compileSdk compileSdk = AndroidConfig.compileSdk
ndkVersion = AndroidConfig.ndk ndkVersion = AndroidConfig.ndk
@ -24,8 +26,8 @@ android {
applicationId = "eu.kanade.tachiyomi" applicationId = "eu.kanade.tachiyomi"
minSdk = AndroidConfig.minSdk minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk targetSdk = AndroidConfig.targetSdk
versionCode = 80 versionCode = 82
versionName = "0.13.4" versionName = "0.13.6"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"") buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@ -242,32 +244,36 @@ dependencies {
// Tests // Tests
testImplementation(libs.junit) 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/ // For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation(libs.leakcanary.android) // debugImplementation(libs.leakcanary.android)
implementation(libs.leakcanary.plumber)
} }
tasks { 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) // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
withType<KotlinCompile> { withType<KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf( kotlinOptions.freeCompilerArgs += listOf(
"-Xopt-in=kotlin.Experimental", "-opt-in=kotlin.Experimental",
"-Xopt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlin.ExperimentalStdlibApi", "-opt-in=kotlin.ExperimentalStdlibApi",
"-Xopt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi", "-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi", "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-Xopt-in=coil.annotation.ExperimentalCoilApi", "-opt-in=coil.annotation.ExperimentalCoilApi",
) )
} }
// Duplicating Hebrew string assets due to some locale code issues on different devices // 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") from("./src/main/res/values-he")
into("./src/main/res/values-iw") into("./src/main/res/values-iw")
include("**/*") include("**/*")

View File

@ -82,4 +82,4 @@
-keepclassmembers class kotlinx.serialization.** { -keepclassmembers class kotlinx.serialization.** {
<methods>; <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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="eu.kanade.tachiyomi">
<!-- Internet --> <!-- Internet -->
<uses-permission android:name="android.permission.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.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.security.Security import java.security.Security
import java.util.Date
open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
@ -148,6 +149,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
} }
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
preferences.lastAppClosed().set(Date().time)
if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) { if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) {
SecureActivityDelegate.locked = true 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.data.updater.AppUpdateJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE 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.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
@ -104,10 +103,9 @@ object Migrations {
// Reset sorting preference if using removed sort by source // Reset sorting preference if using removed sort by source
val oldSortingMode = prefs.getInt(PreferenceKeys.librarySortingMode, 0) val oldSortingMode = prefs.getInt(PreferenceKeys.librarySortingMode, 0)
@Suppress("DEPRECATION") if (oldSortingMode == 5 /* SOURCE */) {
if (oldSortingMode == LibrarySort.SOURCE) {
prefs.edit { 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 oldSortingMode = prefs.getInt(PreferenceKeys.librarySortingMode, 0)
val oldSortingDirection = prefs.getBoolean(PreferenceKeys.librarySortingDirection, true) val oldSortingDirection = prefs.getBoolean(PreferenceKeys.librarySortingDirection, true)
@Suppress("DEPRECATION")
val newSortingMode = when (oldSortingMode) { val newSortingMode = when (oldSortingMode) {
LibrarySort.ALPHA -> SortModeSetting.ALPHABETICAL 0 -> SortModeSetting.ALPHABETICAL
LibrarySort.LAST_READ -> SortModeSetting.LAST_READ 1 -> SortModeSetting.LAST_READ
LibrarySort.LAST_CHECKED -> SortModeSetting.LAST_CHECKED 2 -> SortModeSetting.LAST_CHECKED
LibrarySort.UNREAD -> SortModeSetting.UNREAD 3 -> SortModeSetting.UNREAD
LibrarySort.TOTAL -> SortModeSetting.TOTAL_CHAPTERS 4 -> SortModeSetting.TOTAL_CHAPTERS
LibrarySort.LATEST_CHAPTER -> SortModeSetting.LATEST_CHAPTER 6 -> SortModeSetting.LATEST_CHAPTER
LibrarySort.CHAPTER_FETCH_DATE -> SortModeSetting.DATE_FETCHED 8 -> SortModeSetting.DATE_FETCHED
LibrarySort.DATE_ADDED -> SortModeSetting.DATE_ADDED 7 -> SortModeSetting.DATE_ADDED
else -> SortModeSetting.ALPHABETICAL else -> SortModeSetting.ALPHABETICAL
} }

View File

@ -55,7 +55,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
backup = Backup( backup = Backup(
backupManga(databaseManga, flags), backupManga(databaseManga, flags),
backupCategories(), backupCategories(flags),
emptyList(), emptyList(),
backupExtensionInfo(databaseManga), backupExtensionInfo(databaseManga),
) )
@ -133,10 +133,15 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
* *
* @return list of [BackupCategory] to be backed up * @return list of [BackupCategory] to be backed up
*/ */
private fun backupCategories(): List<BackupCategory> { private fun backupCategories(options: Int): List<BackupCategory> {
return databaseHelper.getCategories() // Check if user wants category information in backup
.executeAsBlocking() return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
.map { BackupCategory.copyFrom(it) } 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) { override fun onConfigure(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true) 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 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) { private fun setChapterFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask) chapter_flags = chapter_flags and mask.inv() or (flag and mask)
} }

View File

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

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.webkit.MimeTypeMap
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay 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.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue 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.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.UnmeteredSource
@ -272,11 +273,12 @@ class Downloader(
// Start downloader if needed // Start downloader if needed
if (autoStart && wasEmpty) { if (autoStart && wasEmpty) {
val queuedDownloads = queue.filter { it.source !is UnmeteredSource }.count() val queuedDownloads = queue.count { it.source !is UnmeteredSource }
val maxDownloadsFromSource = queue val maxDownloadsFromSource = queue
.groupBy { it.source } .groupBy { it.source }
.filterKeys { it !is UnmeteredSource } .filterKeys { it !is UnmeteredSource }
.maxOf { it.value.size } .maxOfOrNull { it.value.size }
?: 0
if ( if (
queuedDownloads > DOWNLOADS_QUEUED_WARNING_THRESHOLD || queuedDownloads > DOWNLOADS_QUEUED_WARNING_THRESHOLD ||
maxDownloadsFromSource > CHAPTERS_PER_SOURCE_QUEUE_WARNING_THRESHOLD maxDownloadsFromSource > CHAPTERS_PER_SOURCE_QUEUE_WARNING_THRESHOLD
@ -285,6 +287,7 @@ class Downloader(
notifier.onWarning( notifier.onWarning(
context.getString(R.string.download_queue_size_warning), context.getString(R.string.download_queue_size_warning),
WARNING_NOTIF_TIMEOUT_MS, 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 // Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) } .flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already // Start downloading images, consider we can have downloaded images already
// Concurrently do 5 pages at a time // Concurrently do 2 pages at a time
.flatMap({ page -> getOrDownloadImage(page, download, tmpDir) }, 5) .flatMap({ page -> getOrDownloadImage(page, download, tmpDir).subscribeOn(Schedulers.io()) }, 2)
.onBackpressureLatest() .onBackpressureLatest()
// Do when page is downloaded. // Do when page is downloaded.
.doOnNext { notifier.onProgressChange(download) } .doOnNext { notifier.onProgressChange(download) }
@ -349,6 +352,7 @@ class Downloader(
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
// If the page list threw, it will resume here // If the page list threw, it will resume here
.onErrorReturn { error -> .onErrorReturn { error ->
logcat(LogPriority.ERROR, error)
download.status = Download.State.ERROR download.status = Download.State.ERROR
notifier.onError(error.message, download.chapter.name, download.manga.title) notifier.onError(error.message, download.chapter.name, download.manga.title)
download download
@ -376,7 +380,7 @@ class Downloader(
tmpFile?.delete() tmpFile?.delete()
// Try to find the image file. // 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 // If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = when { val pageObservable = when {
@ -386,8 +390,12 @@ class Downloader(
} }
return pageObservable 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 -> .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.uri = file.uri
page.progress = 100 page.progress = 100
download.downloadedImages++ download.downloadedImages++
@ -398,6 +406,7 @@ class Downloader(
.onErrorReturn { .onErrorReturn {
page.progress = 0 page.progress = 0
page.status = Page.ERROR page.status = Page.ERROR
notifier.onError(it.message, download.chapter.name, download.manga.title)
page page
} }
} }
@ -462,13 +471,33 @@ class Downloader(
*/ */
private fun getImageExtension(response: Response, file: UniFile): String { private fun getImageExtension(response: Response, file: UniFile): String {
// Read content type if available. // 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. // Else guess from the uri.
?: context.contentResolver.getType(file.uri) ?: context.contentResolver.getType(file.uri)
// Else read magic numbers. // Else read magic numbers.
?: ImageUtil.findImageType { file.openInputStream() }?.mime ?: 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, dirname: String,
) { ) {
// Ensure that the chapter folder has all the images. // 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.status = if (downloadedImages.size == download.pages!!.size) {
Download.State.DOWNLOADED // Only rename the directory if it's downloaded.
} else {
Download.State.ERROR
}
// Only rename the directory if it's downloaded.
if (download.status == Download.State.DOWNLOADED) {
if (preferences.saveChaptersAsCBZ().get()) { if (preferences.saveChaptersAsCBZ().get()) {
archiveChapter(mangaDir, dirname, tmpDir) archiveChapter(mangaDir, dirname, tmpDir)
} else { } else {
@ -504,6 +527,10 @@ class Downloader(
cache.addChapter(dirname, mangaDir, download.manga) cache.addChapter(dirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context) 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.WorkManager
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING import eu.kanade.tachiyomi.data.preference.*
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.isConnectedToWifi import eu.kanade.tachiyomi.util.system.isConnectedToWifi
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -21,8 +19,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
override fun doWork(): Result { override fun doWork(): Result {
val preferences = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()
if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) { val restrictions = preferences.libraryUpdateDeviceRestriction().get()
Result.failure() if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
return Result.failure()
} }
return if (LibraryUpdateService.start(context)) { return if (LibraryUpdateService.start(context)) {
@ -41,8 +40,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
if (interval > 0) { if (interval > 0) {
val restrictions = preferences.libraryUpdateDeviceRestriction().get() val restrictions = preferences.libraryUpdateDeviceRestriction().get()
val constraints = Constraints.Builder() 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) .setRequiresCharging(DEVICE_CHARGING in restrictions)
.setRequiresBatteryNotLow(DEVICE_BATTERY_NOT_LOW in restrictions)
.build() .build()
val request = PeriodicWorkRequestBuilder<LibraryUpdateJob>( val request = PeriodicWorkRequestBuilder<LibraryUpdateJob>(
@ -60,10 +60,5 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
WorkManager.getInstance(context).cancelAllWorkByTag(TAG) 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() { fun showQueueSizeWarningNotification() {
val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) { val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) {
setContentTitle(context.getString(R.string.label_warning)) 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) setSmallIcon(R.drawable.ic_warning_white_24dp)
setTimeoutAfter(Downloader.WARNING_NOTIF_TIMEOUT_MS) setTimeoutAfter(Downloader.WARNING_NOTIF_TIMEOUT_MS)
setContentIntent(NotificationHandler.openUrl(context, HELP_WARNING_URL))
} }
context.notificationManager.notify( context.notificationManager.notify(
@ -340,6 +341,10 @@ class LibraryUpdateNotifier(private val context: Context) {
} }
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) 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 private const val NOTIF_MAX_CHAPTERS = 5

View File

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

View File

@ -7,6 +7,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.BackupRestoreService
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -193,7 +194,7 @@ class NotificationReceiver : BroadcastReceiver() {
val file = File(path) val file = File(path)
file.delete() 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 CHANNEL_LIBRARY_ERROR = "library_errors_channel"
const val ID_LIBRARY_ERROR = -102 const val ID_LIBRARY_ERROR = -102
const val CHANNEL_LIBRARY_SKIPPED = "library_skipped_channel" 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. * Notification channel and ids used by the downloader.

View File

@ -63,6 +63,8 @@ object PreferenceKeys {
const val dohProvider = "doh_provider" const val dohProvider = "doh_provider"
const val defaultUserAgent = "default_user_agent"
const val defaultChapterFilterByRead = "default_chapter_filter_by_read" const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded" 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 import eu.kanade.tachiyomi.R
const val DEVICE_ONLY_ON_WIFI = "wifi" const val DEVICE_ONLY_ON_WIFI = "wifi"
const val DEVICE_NETWORK_NOT_METERED = "network_not_metered"
const val DEVICE_CHARGING = "ac" const val DEVICE_CHARGING = "ac"
const val DEVICE_BATTERY_NOT_LOW = "battery_not_low"
const val MANGA_NON_COMPLETED = "manga_ongoing" const val MANGA_NON_COMPLETED = "manga_ongoing"
const val MANGA_HAS_UNREAD = "manga_fully_read" const val MANGA_HAS_UNREAD = "manga_fully_read"
@ -28,13 +30,14 @@ object PreferenceValues {
enum class AppTheme(val titleResId: Int?) { enum class AppTheme(val titleResId: Int?) {
DEFAULT(R.string.label_default), DEFAULT(R.string.label_default),
MONET(R.string.theme_monet), MONET(R.string.theme_monet),
GREEN_APPLE(R.string.theme_greenapple),
LAVENDER(R.string.theme_lavender),
MIDNIGHT_DUSK(R.string.theme_midnightdusk), MIDNIGHT_DUSK(R.string.theme_midnightdusk),
STRAWBERRY_DAIQUIRI(R.string.theme_strawberrydaiquiri), STRAWBERRY_DAIQUIRI(R.string.theme_strawberrydaiquiri),
YOTSUBA(R.string.theme_yotsuba),
TAKO(R.string.theme_tako), TAKO(R.string.theme_tako),
GREEN_APPLE(R.string.theme_greenapple),
TEALTURQUOISE(R.string.theme_tealturquoise), TEALTURQUOISE(R.string.theme_tealturquoise),
YINYANG(R.string.theme_yinyang), YINYANG(R.string.theme_yinyang),
YOTSUBA(R.string.theme_yotsuba),
// Deprecated // Deprecated
DARK_BLUE(null), DARK_BLUE(null),

View File

@ -56,7 +56,7 @@ class PreferencesHelper(val context: Context) {
fun lockAppAfter() = flowPrefs.getInt("lock_app_after", 0) 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) 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 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) fun folderPerManga() = prefs.getBoolean(Keys.folderPerManga, false)
@ -278,10 +280,10 @@ class PreferencesHelper(val context: Context) {
fun pinnedSources() = flowPrefs.getStringSet("pinned_catalogues", emptySet()) 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 downloadNewChapterCategories() = flowPrefs.getStringSet("download_new_categories", emptySet())
fun downloadNewCategoriesExclude() = flowPrefs.getStringSet("download_new_categories_exclude", emptySet()) fun downloadNewChapterCategoriesExclude() = flowPrefs.getStringSet("download_new_categories_exclude", emptySet())
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1) fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
@ -297,6 +299,8 @@ class PreferencesHelper(val context: Context) {
fun dohProvider() = prefs.getInt(Keys.dohProvider, -1) 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 lastSearchQuerySearchSettings() = flowPrefs.getString("last_search_query", "")
fun filterChapterByRead() = prefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL) 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.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.net.toUri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir 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) return destFile.getUriCompat(context)
} }

View File

@ -22,6 +22,7 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
@ -256,13 +257,21 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.appendPath("my_list_status") .appendPath("my_list_status")
.build() .build()
fun refreshTokenRequest(refreshToken: String): Request { fun refreshTokenRequest(oauth: OAuth): Request {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("client_id", clientId) .add("client_id", clientId)
.add("refresh_token", refreshToken) .add("refresh_token", oauth.refresh_token)
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.build() .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 { private fun getPkceChallengeCode(): String {

View File

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

View File

@ -47,6 +47,7 @@ class AppUpdateChecker {
when (result) { when (result) {
is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release) is AppUpdateResult.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate() is AppUpdateResult.NewUpdateFdroidInstallation -> AppUpdateNotifier(context).promptFdroidUpdate()
else -> {}
} }
result result
@ -56,6 +57,7 @@ class AppUpdateChecker {
private fun isNewVersion(versionTag: String): Boolean { private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v" // Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "") val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
val oldVersion = BuildConfig.VERSION_NAME.replace("[^\\d.]".toRegex(), "")
return if (BuildConfig.PREVIEW) { return if (BuildConfig.PREVIEW) {
// Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo // Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
@ -64,7 +66,15 @@ class AppUpdateChecker {
} else { } else {
// Release builds: based on releases in "tachiyomiorg/tachiyomi" repo // Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
// tagged as something like "v0.1.2" // 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) setOnlyAlertOnce(false)
setProgress(0, 0, false) setProgress(0, 0, false)
setContentIntent(installIntent) setContentIntent(installIntent)
setOngoing(true)
clearActions() clearActions()
addAction( addAction(

View File

@ -11,7 +11,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import logcat.LogPriority
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -21,11 +23,27 @@ internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy() private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
private var requiresFallbackSource = false
suspend fun findExtensions(): List<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext {
val extensions = networkService.client val githubResponse = if (requiresFallbackSource) null else try {
.newCall(GET("${REPO_URL_PREFIX}index.min.json")) networkService.client
.await() .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>>() .parseAs<List<ExtensionJsonObject>>()
.toExtensions() .toExtensions()
@ -85,7 +103,7 @@ internal class ExtensionGithubApi {
hasChangelog = it.hasChangelog == 1, hasChangelog = it.hasChangelog == 1,
sources = it.sources?.toExtensionSources() ?: emptyList(), sources = it.sources?.toExtensionSources() ?: emptyList(),
apkName = it.apk, 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 { 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 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 @Serializable
private data class ExtensionJsonObject( 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() val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use { service.contentResolver.openInputStream(entry.uri)!!.use {
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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 { } else {
"pm install-create -i ${service.packageName} -S $size" "pm install-create -r -i ${service.packageName} -S $size"
} }
val createResult = exec(createCommand) val createResult = exec(createCommand)
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value 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 Success(val extension: Extension.Installed) : LoadResult()
class Untrusted(val extension: Extension.Untrusted) : LoadResult() class Untrusted(val extension: Extension.Untrusted) : LoadResult()
class Error(val message: String? = null) : LoadResult() { object Error : LoadResult()
constructor(exception: Throwable) : this(exception.message)
}
} }

View File

@ -7,10 +7,12 @@ import android.content.IntentFilter
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.util.lang.launchNow import eu.kanade.tachiyomi.util.lang.launchNow
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import logcat.LogPriority
/** /**
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only * 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)) { when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionInstalled(result.extension) is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
is LoadResult.Untrusted -> listener.onExtensionUntrusted(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)) { when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension) is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
// Not needed as a package can't be upgraded if the signature is different // 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 { private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent) 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() 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) context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
} catch (error: PackageManager.NameNotFoundException) { } catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point // 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)) { 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) return loadExtension(context, pkgName, pkgInfo)
} }
@ -102,7 +104,8 @@ internal object ExtensionLoader {
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
} catch (error: PackageManager.NameNotFoundException) { } catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point // 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: ") val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
@ -112,7 +115,7 @@ internal object ExtensionLoader {
if (versionName.isNullOrEmpty()) { if (versionName.isNullOrEmpty()) {
val exception = Exception("Missing versionName for extension $extName") val exception = Exception("Missing versionName for extension $extName")
logcat(LogPriority.WARN, exception) logcat(LogPriority.WARN, exception)
return LoadResult.Error(exception) return LoadResult.Error
} }
// Validate lib version // Validate lib version
@ -123,13 +126,14 @@ internal object ExtensionLoader {
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed", "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
) )
logcat(LogPriority.WARN, exception) logcat(LogPriority.WARN, exception)
return LoadResult.Error(exception) return LoadResult.Error
} }
val signatureHash = getSignatureHash(pkgInfo) val signatureHash = getSignatureHash(pkgInfo)
if (signatureHash == null) { 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) { } else if (signatureHash !in trustedSignatures) {
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash) val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash)
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
@ -138,7 +142,8 @@ internal object ExtensionLoader {
val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1
if (!loadNsfwSource && isNsfw) { 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 val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
@ -165,7 +170,7 @@ internal object ExtensionLoader {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } 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_GOOGLE = 2
const val PREF_DOH_ADGUARD = 3 const val PREF_DOH_ADGUARD = 3
const val PREF_DOH_QUAD9 = 4 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( fun OkHttpClient.Builder.dohCloudflare() = dns(
DnsOverHttps.Builder().client(build()) DnsOverHttps.Builder().client(build())
@ -68,3 +72,51 @@ fun OkHttpClient.Builder.dohQuad9() = dns(
) )
.build(), .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_GOOGLE -> builder.dohGoogle()
PREF_DOH_ADGUARD -> builder.dohAdGuard() PREF_DOH_ADGUARD -> builder.dohAdGuard()
PREF_DOH_QUAD9 -> builder.dohQuad9() 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 return builder
@ -55,4 +59,8 @@ class NetworkHelper(context: Context) {
.addInterceptor(CloudflareInterceptor(context)) .addInterceptor(CloudflareInterceptor(context))
.build() .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() source(responseBody.source()).buffer()
} }
override fun contentType(): MediaType { override fun contentType(): MediaType? {
return responseBody.contentType()!! return responseBody.contentType()
} }
override fun contentLength(): Long { override fun contentLength(): Long {

View File

@ -9,7 +9,6 @@ import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.NetworkHelper 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.lang.launchUI
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewClientCompat 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 // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
webview.settings.userAgentString = request.header("User-Agent") webview.settings.userAgentString = request.header("User-Agent")
?: HttpSource.DEFAULT_USER_AGENT ?: networkHelper.defaultUserAgent
webview.webViewClient = object : WebViewClientCompat() { webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {

View File

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

View File

@ -5,6 +5,7 @@ import okhttp3.HttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@ -41,9 +42,13 @@ class SpecificHostRateLimitInterceptor(
private val host = httpUrl.host private val host = httpUrl.host
override fun intercept(chain: Interceptor.Chain): Response { 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()) return chain.proceed(chain.request())
} }
synchronized(requestQueue) { synchronized(requestQueue) {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val waitTime = if (requestQueue.size < permits) { val waitTime = if (requestQueue.size < permits) {
@ -59,6 +64,11 @@ class SpecificHostRateLimitInterceptor(
} }
} }
// Final check
if (chain.call().isCanceled()) {
throw IOException()
}
if (requestQueue.size == permits) { if (requestQueue.size == permits) {
requestQueue.removeAt(0) requestQueue.removeAt(0)
} }

View File

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

View File

@ -2,7 +2,9 @@ package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import com.github.junrar.Archive import com.github.junrar.Archive
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R 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.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
@ -30,6 +32,8 @@ import logcat.LogPriority
import rx.Observable import rx.Observable
import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -37,130 +41,104 @@ import java.io.InputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSource { class LocalSource(
private val context: Context,
companion object { private val coverCache: CoverCache = Injekt.get(),
const val ID = 0L ) : CatalogueSource, UnmeteredSource {
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) }
}
}
private val json: Json by injectLazy() private val json: Json by injectLazy()
override val id = ID override val name: String = context.getString(R.string.local_source)
override val name = context.getString(R.string.local_source)
override val lang = "other" override val id: Long = ID
override val supportsLatest = true
override val lang: String = "other"
override fun toString() = name override fun toString() = name
override val supportsLatest: Boolean = true
// Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
val baseDirs = getBaseDirectories(context)
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
var mangaDirs = baseDirs val baseDirsFiles = getBaseDirectoriesFiles(context)
.asSequence()
.mapNotNull { it.listFiles()?.toList() } var mangaDirs = baseDirsFiles
.flatten() // Filter out files that are hidden and is not a folder
.filter { it.isDirectory } .filter { it.isDirectory && !it.name.startsWith('.') }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name } .distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
when (state?.index) { // Filter by query or last modified
0 -> { mangaDirs = mangaDirs.filter {
mangaDirs = if (state.ascending) { if (lastModifiedLimit == 0L) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, { it.name })) it.name.contains(query, ignoreCase = true)
} else { } else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER, { it.name })) it.lastModified() >= lastModifiedLimit
}
}
1 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
} }
} }
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 -> val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply { SManga.create().apply {
title = mangaDir.name title = mangaDir.name
url = mangaDir.name url = mangaDir.name
// Try to find the cover // Try to find the cover
for (dir in baseDirs) { val cover = getCoverFile(mangaDir.name, baseDirsFiles)
val cover = getCoverFile(File("${dir.absolutePath}/$url")) if (cover != null && cover.exists()) {
if (cover != null && cover.exists()) { thumbnail_url = cover.absolutePath
thumbnail_url = cover.absolutePath
break
}
} }
}
}
val sManga = this // Fetch chapters of all the manga
val mangaInfo = this.toMangaInfo() mangas.forEach { manga ->
runBlocking { val mangaInfo = manga.toMangaInfo()
val chapters = getChapterList(mangaInfo) runBlocking {
if (chapters.isNotEmpty()) { val chapters = getChapterList(mangaInfo)
val chapter = chapters.last().toSChapter() if (chapters.isNotEmpty()) {
val format = getFormat(chapter) val chapter = chapters.last().toSChapter()
if (format is Format.Epub) { val format = getFormat(chapter)
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(sManga)
}
}
// Copy the cover from the first chapter found. if (format is Format.Epub) {
if (thumbnail_url == null) { EpubFile(format.file).use { epub ->
try { epub.fillMangaMetadata(manga)
val dest = updateCover(chapter, sManga)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
} }
} }
// 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)) 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 { override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo {
val localDetails = getBaseDirectories(context) var mangaInfo = manga
.asSequence()
.mapNotNull { File(it, manga.key).listFiles()?.toList() } val baseDirsFile = getBaseDirectoriesFiles(context)
.flatten()
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) } .firstOrNull { it.extension.equals("json", ignoreCase = true) }
return if (localDetails != null) { if (localDetails != null) {
val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream()) val obj = json.decodeFromStream<JsonObject>(localDetails.inputStream())
manga.copy( mangaInfo = mangaInfo.copy(
title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title, title = obj["title"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.title,
author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author, author = obj["author"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.author,
artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist, artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.artist,
description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description, description = obj["description"]?.jsonPrimitive?.contentOrNull ?: mangaInfo.description,
genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: manga.genres, genres = obj["genre"]?.jsonArray?.map { it.jsonPrimitive.content } ?: mangaInfo.genres,
status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status, status = obj["status"]?.jsonPrimitive?.intOrNull ?: mangaInfo.status,
) )
} else {
manga
} }
return mangaInfo
} }
// Chapters
override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> { override suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
val sManga = manga.toSManga() val sManga = manga.toSManga()
val chapters = getBaseDirectories(context) val baseDirsFile = getBaseDirectoriesFiles(context)
.asSequence() return getMangaDirsFiles(manga.key, baseDirsFile)
.mapNotNull { File(it, manga.key).listFiles()?.toList() } // Only keep supported formats
.flatten()
.filter { it.isDirectory || isSupportedFile(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
@ -211,14 +195,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
} }
date_upload = chapterFile.lastModified() date_upload = chapterFile.lastModified()
ChapterRecognition.parseChapterNumber(this, sManga)
val format = getFormat(chapterFile) val format = getFormat(chapterFile)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file).use { epub -> EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this) epub.fillChapterMetadata(this)
} }
} }
ChapterRecognition.parseChapterNumber(this, sManga)
} }
} }
.map { it.toChapterInfo() } .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 if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
} }
.toList() .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 { private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES 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? { private fun updateCover(chapter: SChapter, manga: SManga): File? {
return when (val format = getFormat(chapter)) { return try {
is Format.Directory -> { when (val format = getFormat(chapter)) {
val entry = format.file.listFiles() is Format.Directory -> {
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } val entry = format.file.listFiles()
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) } entry?.let { updateCover(context, manga, it.inputStream()) }
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file).use { zip ->
val entry = zip.entries().toList() val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it)) } entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
} }
} }
is Format.Rar -> { is Format.Rar -> {
Archive(format.file).use { archive -> Archive(format.file).use { archive ->
val entry = archive.fileHeaders val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it)) } entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
} }
} }
is Format.Epub -> { is Format.Epub -> {
EpubFile(format.file).use { epub -> EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages() val entry = epub.getImagesFromPages()
.firstOrNull() .firstOrNull()
?.let { epub.getEntry(it) } ?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(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 { sealed class Format {
data class Directory(val file: File) : Format() data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format() data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format() data class Rar(val file: File) : Format()
data class Epub(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") private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")

View File

@ -23,6 +23,11 @@ interface SManga : Serializable {
var initialized: Boolean 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) { fun copyFrom(other: SManga) {
if (other.author != null) { if (other.author != null) {
author = other.author author = other.author
@ -73,7 +78,7 @@ fun SManga.toMangaInfo(): MangaInfo {
artist = this.artist ?: "", artist = this.artist ?: "",
author = this.author ?: "", author = this.author ?: "",
description = this.description ?: "", description = this.description ?: "",
genres = this.genre?.split(", ") ?: emptyList(), genres = this.getGenres() ?: emptyList(),
status = this.status, status = this.status,
cover = this.thumbnail_url ?: "", cover = this.thumbnail_url ?: "",
) )

View File

@ -15,6 +15,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
@ -67,7 +68,7 @@ abstract class HttpSource : CatalogueSource {
* Headers builder for requests. Implementations can override this method for custom headers. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
protected open fun headersBuilder() = Headers.Builder().apply { 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. * Returns the list of filters for the source.
*/ */
override fun getFilterList() = FilterList() 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 * Dismiss the dialog and pop this controller
*/ */
private fun dismissDialog() { fun dismissDialog() {
if (dismissed) { if (dismissed) {
return return
} }

View File

@ -59,16 +59,17 @@ abstract class SearchableNucleusController<VB : ViewBinding, P : BasePresenter<*
val searchAutoComplete: SearchView.SearchAutoComplete = searchView.findViewById( val searchAutoComplete: SearchView.SearchAutoComplete = searchView.findViewById(
R.id.search_src_text, R.id.search_src_text,
) )
searchAutoComplete.addTextChangedListener(object : TextWatcher { searchAutoComplete.addTextChangedListener(
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 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) { override fun afterTextChanged(editable: Editable) {
editable.getSpans(0, editable.length, CharacterStyle::class.java) editable.getSpans(0, editable.length, CharacterStyle::class.java)
.forEach { editable.removeSpan(it) } .forEach { editable.removeSpan(it) }
} }
}, },
) )
searchView.queryTextEvents() searchView.queryTextEvents()
@ -134,12 +135,12 @@ abstract class SearchableNucleusController<VB : ViewBinding, P : BasePresenter<*
searchItem.setOnActionExpandListener( searchItem.setOnActionExpandListener(
object : MenuItem.OnActionExpandListener { object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
onSearchMenuItemActionExpand(item) onSearchMenuItemActionExpand(item)
return true return true
} }
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val localSearchView = searchItem.actionView as SearchView val localSearchView = searchItem.actionView as SearchView
// if it is blank the flow event won't trigger so we would stay in a COLLAPSING state // 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 { private fun isAppLocked(): Boolean {
if (!SecureActivityDelegate.locked) return false if (!SecureActivityDelegate.locked) return false
return preferences.lockAppAfter().get() <= 0 || 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 -> { PreferenceValues.AppTheme.GREEN_APPLE -> {
resIds += R.style.Theme_Tachiyomi_GreenApple resIds += R.style.Theme_Tachiyomi_GreenApple
} }
PreferenceValues.AppTheme.LAVENDER -> {
resIds += R.style.Theme_Tachiyomi_Lavender
}
PreferenceValues.AppTheme.MIDNIGHT_DUSK -> { PreferenceValues.AppTheme.MIDNIGHT_DUSK -> {
resIds += R.style.Theme_Tachiyomi_MidnightDusk 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 { private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
return when { return if (!pkgFactory.isNullOrEmpty()) {
!pkgFactory.isNullOrEmpty() -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory$path" when (path.isEmpty()) {
else -> "$url/src/${pkgName.replace(".", "/")}$path" 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 package eu.kanade.tachiyomi.ui.browse.migration
import eu.kanade.tachiyomi.R 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 { object MigrationFlags {
private const val CHAPTERS = 0b001 private const val CHAPTERS = 0b0001
private const val CATEGORIES = 0b010 private const val CATEGORIES = 0b0010
private const val TRACK = 0b100 private const val TRACK = 0b0100
private const val CUSTOM_COVER = 0b1000
private const val CHAPTERS2 = 0x1 private const val CHAPTERS2 = 0x1
private const val CATEGORIES2 = 0x2 private const val CATEGORIES2 = 0x2
private const val TRACK2 = 0x4 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 { fun hasChapters(value: Int): Boolean {
return value and CHAPTERS != 0 return value and CHAPTERS != 0
@ -28,11 +37,31 @@ object MigrationFlags {
return value and TRACK != 0 return value and TRACK != 0
} }
fun hasCustomCover(value: Int): Boolean {
return value and CUSTOM_COVER != 0
}
fun getEnabledFlagsPositions(value: Int): List<Int> { fun getEnabledFlagsPositions(value: Int): List<Int> {
return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null } return flags.mapIndexedNotNull { index, flag -> if (value and flag != 0) index else null }
} }
fun getFlagsFromPositions(positions: Array<Int>): Int { 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 { override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val prefValue = preferences.migrateFlags().get() val prefValue = preferences.migrateFlags().get()
val enabledFlagsPositions = MigrationFlags.getEnabledFlagsPositions(prefValue) val enabledFlagsPositions = MigrationFlags.getEnabledFlagsPositions(prefValue)
val items = MigrationFlags.titles val items = MigrationFlags.titles(manga)
.map { resources?.getString(it) } .map { resources?.getString(it) }
.toTypedArray() .toTypedArray()
val selected = items val selected = items
@ -129,7 +129,10 @@ class SearchController(
} }
(targetController as? SearchController)?.copyManga(manga, newManga) (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() .create()
} }
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.migration.search
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay 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.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.toMangaInfo 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.GlobalSearchItem
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchPresenter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource 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.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Date import java.util.Date
class SearchPresenter( class SearchPresenter(
@ -31,7 +34,7 @@ class SearchPresenter(
) : GlobalSearchPresenter(initialQuery) { ) : GlobalSearchPresenter(initialQuery) {
private val replacingMangaRelay = BehaviorRelay.create<Pair<Boolean, Manga?>>() 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>() } private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
@ -103,6 +106,10 @@ class SearchPresenter(
MigrationFlags.hasTracks( MigrationFlags.hasTracks(
flags, flags,
) )
val migrateCustomCover =
MigrationFlags.hasCustomCover(
flags,
)
db.inTransaction { db.inTransaction {
// Update chapters read // Update chapters read
@ -174,6 +181,11 @@ class SearchPresenter(
manga.date_added = Date().time 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, // SearchPresenter#networkToLocalManga may have updated the manga title,
// so ensure db gets updated title too // so ensure db gets updated title too
db.insertManga(manga).executeAsBlocking() 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.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.main.MainActivity 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.manga.MangaController
import eu.kanade.tachiyomi.ui.more.MoreController import eu.kanade.tachiyomi.ui.more.MoreController
import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.ui.webview.WebViewActivity
@ -343,19 +344,20 @@ open class BrowseSourceController(bundle: Bundle) :
* @param genreName the name of the genre * @param genreName the name of the genre
*/ */
fun searchWithGenre(genreName: String) { 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<*>) { if (sourceFilter is Filter.Group<*>) {
for (filter in sourceFilter.state) { for (filter in sourceFilter.state) {
if (filter is Filter<*> && filter.name.equals(genreName, true)) { if (filter is Filter<*> && filter.name.equals(genreName, true)) {
when (filter) { when (filter) {
is Filter.TriState -> filter.state = 1 is Filter.TriState -> filter.state = 1
is Filter.CheckBox -> filter.state = true is Filter.CheckBox -> filter.state = true
else -> {}
} }
filterList = presenter.sourceFilters genreExists = true
break@filter break@filter
} }
} }
@ -365,19 +367,20 @@ open class BrowseSourceController(bundle: Bundle) :
if (index != -1) { if (index != -1) {
sourceFilter.state = index sourceFilter.state = index
filterList = presenter.sourceFilters genreExists = true
break break
} }
} }
} }
if (filterList != null) { if (genreExists) {
presenter.sourceFilters = defaultFilters
filterSheet?.setFilters(presenter.filterItems) filterSheet?.setFilters(presenter.filterItems)
showProgressBar() showProgressBar()
adapter?.clear() adapter?.clear()
presenter.restartPager("", filterList) presenter.restartPager("", defaultFilters)
} else { } else {
searchWithQuery(genreName) searchWithQuery(genreName)
} }
@ -586,6 +589,7 @@ open class BrowseSourceController(bundle: Bundle) :
override fun onItemLongClick(position: Int) { override fun onItemLongClick(position: Int) {
val activity = activity ?: return val activity = activity ?: return
val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return val manga = (adapter?.getItem(position) as? SourceItem?)?.manga ?: return
val duplicateManga = presenter.getDuplicateLibraryManga(manga)
if (manga.favorite) { if (manga.favorite) {
MaterialAlertDialogBuilder(activity) MaterialAlertDialogBuilder(activity)
@ -601,43 +605,53 @@ open class BrowseSourceController(bundle: Bundle) :
} }
.show() .show()
} else { } else {
val categories = presenter.getCategories() if (duplicateManga != null) {
val defaultCategoryId = preferences.defaultCategory() AddDuplicateMangaDialog(this, duplicateManga) { addToLibrary(manga, position) }
val defaultCategory = categories.find { it.id == defaultCategoryId } .showDialog(router)
} else {
addToLibrary(manga, position)
}
}
}
when { private fun addToLibrary(newManga: Manga, position: Int) {
// Default category set val activity = activity ?: return
defaultCategory != null -> { val categories = presenter.getCategories()
presenter.moveMangaToCategory(manga, defaultCategory) val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
presenter.changeMangaFavorite(manga) when {
adapter?.notifyItemChanged(position) // Default category set
activity.toast(activity.getString(R.string.manga_added_library)) defaultCategory != null -> {
} presenter.moveMangaToCategory(newManga, defaultCategory)
// Automatic 'Default' or no categories presenter.changeMangaFavorite(newManga)
defaultCategoryId == 0 || categories.isEmpty() -> { adapter?.notifyItemChanged(position)
presenter.moveMangaToCategory(manga, null) activity.toast(activity.getString(R.string.manga_added_library))
}
presenter.changeMangaFavorite(manga) // Automatic 'Default' or no categories
adapter?.notifyItemChanged(position) defaultCategoryId == 0 || categories.isEmpty() -> {
activity.toast(activity.getString(R.string.manga_added_library)) presenter.moveMangaToCategory(newManga, null)
}
// Choose a category presenter.changeMangaFavorite(newManga)
else -> { adapter?.notifyItemChanged(position)
val ids = presenter.getMangaCategoryIds(manga) activity.toast(activity.getString(R.string.manga_added_library))
val preselected = categories.map { }
if (it.id in ids) {
QuadStateTextView.State.CHECKED.ordinal
} else {
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) // Choose a category
.showDialog(router) 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() 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. * 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") else -> throw Exception("Unknown state")
}, },
)?.apply { )?.apply {
val color = if (filter.state == Filter.TriState.STATE_INCLUDE) { val color = if (filter.state == Filter.TriState.STATE_IGNORE) {
view.context.getResourceColor(R.attr.colorAccent)
} else {
view.context.getResourceColor(R.attr.colorOnBackground, 0.38f) view.context.getResourceColor(R.attr.colorOnBackground, 0.38f)
} else {
view.context.getResourceColor(R.attr.colorPrimary)
} }
setTint(color) setTint(color)

View File

@ -28,8 +28,7 @@ class DownloadHeaderHolder(view: View, adapter: FlexibleAdapter<*>) : Expandable
override fun onItemReleased(position: Int) { override fun onItemReleased(position: Int) {
super.onItemReleased(position) super.onItemReleased(position)
binding.container.isDragged = false binding.container.isDragged = false
mAdapter as DownloadAdapter
mAdapter.expandAll() 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( view.popupMenu(
menuRes = R.menu.download_single, menuRes = R.menu.download_single,
initMenu = { 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 = findItem(R.id.move_to_bottom).isVisible =
bindingAdapterPosition != adapter.itemCount - 1 bindingAdapterPosition != adapter.itemCount - 1
}, },

View File

@ -8,7 +8,6 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.doOnAttach
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
@ -304,8 +303,10 @@ class LibraryController(
onTabsSettingsChanged(firstLaunch = true) onTabsSettingsChanged(firstLaunch = true)
// Delay the scroll position to allow the view to be properly measured. // Delay the scroll position to allow the view to be properly measured.
view.doOnAttach { view.post {
(activity as? MainActivity)?.binding?.tabs?.setScrollPosition(binding.libraryPager.currentItem, 0f, true) 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. // 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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search) 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. // 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) { fun search(query: String) {
@ -413,7 +414,7 @@ class LibraryController(
// Tint icon if there's a filter active // Tint icon if there's a filter active
if (settingsSheet.filters.hasActiveFilters()) { if (settingsSheet.filters.hasActiveFilters()) {
val filterColor = activity!!.getResourceColor(R.attr.colorFilterActive) 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)) unreadBadge -> preferences.unreadBadge().set((item.checked))
localBadge -> preferences.localBadge().set((item.checked)) localBadge -> preferences.localBadge().set((item.checked))
languageBadge -> preferences.languageBadge().set((item.checked)) languageBadge -> preferences.languageBadge().set((item.checked))
else -> {}
} }
adapter.notifyItemChanged(item) adapter.notifyItemChanged(item)
} }
@ -418,6 +419,7 @@ class LibrarySettingsSheet(
when (item) { when (item) {
showTabs -> preferences.categoryTabs().set(item.checked) showTabs -> preferences.categoryTabs().set(item.checked)
showNumberOfItems -> preferences.categoryNumberOfItems().set(item.checked) showNumberOfItems -> preferences.categoryNumberOfItems().set(item.checked)
else -> {}
} }
adapter.notifyItemChanged(item) 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 // Binding sometimes isn't actually instantiated yet somehow
nav?.setOnItemSelectedListener(null) nav?.setOnItemSelectedListener(null)
binding?.toolbar.setNavigationOnClickListener(null) binding?.toolbar?.setNavigationOnClickListener(null)
} }
override fun onBackPressed() { 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.Controller
import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter import dev.chrisbanes.insetter.applyInsetter
@ -542,18 +541,8 @@ class MangaController :
private fun showAddDuplicateDialog(newManga: Manga, libraryManga: Manga) { private fun showAddDuplicateDialog(newManga: Manga, libraryManga: Manga) {
activity?.let { activity?.let {
val source = sourceManager.getOrStub(libraryManga.source) AddDuplicateMangaDialog(this, libraryManga) { addToLibrary(newManga) }
MaterialAlertDialogBuilder(it).apply { .showDialog(router)
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()
} }
} }

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.History 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.source.model.Page
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader 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.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage 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. * that the user doesn't have to wait too long to continue reading.
*/ */
private fun preload(chapter: ReaderChapter) { 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) { if (chapter.state != ReaderChapter.State.Wait && chapter.state !is ReaderChapter.State.Error) {
return return
} }
@ -456,6 +464,10 @@ class ReaderPresenter(
} }
} }
fun saveProgress() {
getCurrentChapter()?.let { onChapterChanged(it) }
}
/** /**
* Called from the activity to preload the given [chapter]. * Called from the activity to preload the given [chapter].
*/ */
@ -662,20 +674,22 @@ class ReaderPresenter(
Observable Observable
.fromCallable { .fromCallable {
if (manga.isLocal()) { stream().use {
val context = Injekt.get<Application>() if (manga.isLocal()) {
LocalSource.updateCover(context, manga, stream()) val context = Injekt.get<Application>()
manga.updateCoverLastModified(db) LocalSource.updateCover(context, manga, it)
R.string.cover_updated
SetAsCoverResult.Success
} else {
if (manga.favorite) {
coverCache.setCustomCoverToCache(manga, stream())
manga.updateCoverLastModified(db) manga.updateCoverLastModified(db)
coverCache.clearMemoryCache() coverCache.clearMemoryCache()
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } 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 package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context import android.content.Context
import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@ -83,7 +84,11 @@ class ChapterLoader(
when (format) { when (format) {
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file) is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
is LocalSource.Format.Zip -> ZipPageLoader(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) is LocalSource.Format.Epub -> EpubPageLoader(format.file)
} }
} }

View File

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

View File

@ -34,27 +34,28 @@ class ReaderSettingsSheet(
behavior.halfExpandedRatio = 0.25f behavior.halfExpandedRatio = 0.25f
val filterTabIndex = getTabViews().indexOf(colorFilterSettings) val filterTabIndex = getTabViews().indexOf(colorFilterSettings)
binding.tabs.addOnTabSelectedListener(object : SimpleTabSelectedListener() { binding.tabs.addOnTabSelectedListener(
override fun onTabSelected(tab: TabLayout.Tab?) { object : SimpleTabSelectedListener() {
val isFilterTab = tab?.position == filterTabIndex override fun onTabSelected(tab: TabLayout.Tab?) {
val isFilterTab = tab?.position == filterTabIndex
// Remove dimmed backdrop so color filter changes can be previewed // Remove dimmed backdrop so color filter changes can be previewed
backgroundDimAnimator.run { backgroundDimAnimator.run {
if (isFilterTab) { if (isFilterTab) {
if (animatedFraction < 1f) { if (animatedFraction < 1f) {
start() 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) { if (showColorFilterSettings) {

View File

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

View File

@ -1,15 +1,24 @@
package eu.kanade.tachiyomi.ui.reader.viewer package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import androidx.core.text.bold import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isVisible import androidx.core.view.isVisible
import eu.kanade.tachiyomi.R 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.databinding.ReaderTransitionViewBinding
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition 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) : class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
LinearLayout(context, attrs) { LinearLayout(context, attrs) {
@ -21,32 +30,42 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
} }
fun bind(transition: ChapterTransition) { fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?) {
manga ?: return
when (transition) { when (transition) {
is ChapterTransition.Prev -> bindPrevChapterTransition(transition) is ChapterTransition.Prev -> bindPrevChapterTransition(transition, downloadManager, manga)
is ChapterTransition.Next -> bindNextChapterTransition(transition) is ChapterTransition.Next -> bindNextChapterTransition(transition, downloadManager, manga)
} }
missingChapterWarning(transition) missingChapterWarning(transition)
} }
/** /**
* Binds a previous chapter transition on this view and subscribes to the page load status. * Binds a previous chapter transition on this view and subscribes to the page load status.
*/ */
private fun bindPrevChapterTransition(transition: ChapterTransition) { private fun bindPrevChapterTransition(
val prevChapter = transition.to transition: ChapterTransition,
downloadManager: DownloadManager,
manga: Manga,
) {
val prevChapter = transition.to?.chapter
val hasPrevChapter = prevChapter != null binding.lowerText.isVisible = prevChapter != null
binding.lowerText.isVisible = hasPrevChapter if (prevChapter != null) {
if (hasPrevChapter) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
val isPrevDownloaded = downloadManager.isChapterDownloaded(
prevChapter,
manga,
)
val isCurrentDownloaded = transition.from.pageLoader is DownloadPageLoader
binding.upperText.text = buildSpannedString { binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_previous)) } bold { append(context.getString(R.string.transition_previous)) }
append("\n${prevChapter!!.chapter.name}") append("\n${prevChapter.name}")
if (isPrevDownloaded) addDLImageSpan()
} }
binding.lowerText.text = buildSpannedString { binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_current)) } bold { append(context.getString(R.string.transition_current)) }
append("\n${transition.from.chapter.name}") append("\n${transition.from.chapter.name}")
if (isCurrentDownloaded) addDLImageSpan()
} }
} else { } else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER 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. * Binds a next chapter transition on this view and subscribes to the load status.
*/ */
private fun bindNextChapterTransition(transition: ChapterTransition) { private fun bindNextChapterTransition(
val nextChapter = transition.to transition: ChapterTransition,
downloadManager: DownloadManager,
manga: Manga,
) {
val nextChapter = transition.to?.chapter
val hasNextChapter = nextChapter != null binding.lowerText.isVisible = nextChapter != null
binding.lowerText.isVisible = hasNextChapter if (nextChapter != null) {
if (hasNextChapter) {
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START
val isCurrentDownloaded = transition.from.pageLoader is DownloadPageLoader
val isNextDownloaded = downloadManager.isChapterDownloaded(
nextChapter,
manga,
)
binding.upperText.text = buildSpannedString { binding.upperText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_finished)) } bold { append(context.getString(R.string.transition_finished)) }
append("\n${transition.from.chapter.name}") append("\n${transition.from.chapter.name}")
if (isCurrentDownloaded) addDLImageSpan()
} }
binding.lowerText.text = buildSpannedString { binding.lowerText.text = buildSpannedString {
bold { append(context.getString(R.string.transition_next)) } bold { append(context.getString(R.string.transition_next)) }
append("\n${nextChapter!!.chapter.name}") append("\n${nextChapter.name}")
if (isNextDownloaded) addDLImageSpan()
} }
} else { } else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER 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) { private fun missingChapterWarning(transition: ChapterTransition) {
if (transition.to == null) { if (transition.to == null) {
binding.warning.isVisible = false binding.warning.isVisible = false

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.viewpager.widget.ViewPager import androidx.viewpager.widget.ViewPager
import eu.kanade.tachiyomi.R 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.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage 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 eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import uy.kohesive.injekt.injectLazy
import kotlin.math.min import kotlin.math.min
/** /**
@ -29,6 +31,8 @@ import kotlin.math.min
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
val downloadManager: DownloadManager by injectLazy()
private val scope = MainScope() private val scope = MainScope()
/** /**
@ -66,9 +70,14 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
set(value) { set(value) {
field = value field = value
if (value) { if (value) {
awaitingIdleViewerChapters?.let { awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersInternal(it) setChaptersInternal(viewerChapters)
awaitingIdleViewerChapters = null 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. * Scale listener used to delegate events to the recycler view.
*/ */
inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
recycler?.onScaleBegin() recycler?.onScaleBegin()
return true return true
} }
@ -63,13 +63,13 @@ class WebtoonFrame(context: Context) : FrameLayout(context) {
* Fling listener used to delegate events to the recycler view. * Fling listener used to delegate events to the recycler view.
*/ */
inner class FlingListener : GestureDetector.SimpleOnGestureListener() { inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent?): Boolean { override fun onDown(e: MotionEvent): Boolean {
return true return true
} }
override fun onFling( override fun onFling(
e1: MotionEvent?, e1: MotionEvent,
e2: MotionEvent?, e2: MotionEvent,
velocityX: Float, velocityX: Float,
velocityY: Float, velocityY: Float,
): Boolean { ): Boolean {

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.WebtoonLayoutManager import androidx.recyclerview.widget.WebtoonLayoutManager
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
@ -24,6 +25,7 @@ import kotlinx.coroutines.cancel
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -32,6 +34,8 @@ import kotlin.math.min
*/ */
class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer { class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer {
val downloadManager: DownloadManager by injectLazy()
private val scope = MainScope() private val scope = MainScope()
/** /**
@ -103,6 +107,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
activity.requestPreloadChapter(firstItem.to) 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) { if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" } logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter) 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" } logcat { "moveToPage" }
val position = adapter.items.indexOf(page) val position = adapter.items.indexOf(page)
if (position != -1) { if (position != -1) {
recycler.scrollToPosition(position) layoutManager.scrollToPositionWithOffset(position, 0)
if (layoutManager.findLastEndVisibleItemPosition() == -1) { if (layoutManager.findLastEndVisibleItemPosition() == -1) {
onScrolled(pos = position) 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.AuthenticatorUtil.startAuthentication
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority import logcat.LogPriority
import java.util.Date
/** /**
* Blank activity with a BiometricPrompt. * Blank activity with a BiometricPrompt.
@ -39,7 +38,6 @@ class UnlockActivity : BaseActivity() {
) { ) {
super.onAuthenticationSucceeded(activity, result) super.onAuthenticationSucceeded(activity, result)
SecureActivityDelegate.locked = false SecureActivityDelegate.locked = false
preferences.lastAppUnlock().set(Date().time)
finish() 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.library.LibraryUpdateService.Target
import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.network.NetworkHelper 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_ADGUARD
import eu.kanade.tachiyomi.network.PREF_DOH_ALIDNS
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE 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_GOOGLE
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101
import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.ui.base.controller.openInBrowser import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction 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.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.bindTo import eu.kanade.tachiyomi.util.preference.bindTo
import eu.kanade.tachiyomi.util.preference.defaultValue 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.entriesRes
import eu.kanade.tachiyomi.util.preference.intListPreference import eu.kanade.tachiyomi.util.preference.intListPreference
import eu.kanade.tachiyomi.util.preference.listPreference import eu.kanade.tachiyomi.util.preference.listPreference
@ -48,6 +53,7 @@ import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority import logcat.LogPriority
import rikka.sui.Sui import rikka.sui.Sui
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
class SettingsAdvancedController : SettingsController() { class SettingsAdvancedController : SettingsController() {
@ -143,12 +149,6 @@ class SettingsAdvancedController : SettingsController() {
titleRes = R.string.pref_auto_clear_chapter_cache titleRes = R.string.pref_auto_clear_chapter_cache
defaultValue = false defaultValue = false
} }
preference {
key = "pref_clear_webview_data"
titleRes = R.string.pref_clear_webview_data
onClick { clearWebViewData() }
}
preference { preference {
key = "pref_clear_database" key = "pref_clear_database"
titleRes = R.string.pref_clear_database titleRes = R.string.pref_clear_database
@ -172,6 +172,12 @@ class SettingsAdvancedController : SettingsController() {
activity?.toast(R.string.cookies_cleared) activity?.toast(R.string.cookies_cleared)
} }
} }
preference {
key = "pref_clear_webview_data"
titleRes = R.string.pref_clear_webview_data
onClick { clearWebViewData() }
}
intListPreference { intListPreference {
key = Keys.dohProvider key = Keys.dohProvider
titleRes = R.string.pref_dns_over_https titleRes = R.string.pref_dns_over_https
@ -181,6 +187,10 @@ class SettingsAdvancedController : SettingsController() {
"Google", "Google",
"AdGuard", "AdGuard",
"Quad9", "Quad9",
"AliDNS",
"DNSPod",
"360",
"Quad 101",
) )
entryValues = arrayOf( entryValues = arrayOf(
"-1", "-1",
@ -188,6 +198,10 @@ class SettingsAdvancedController : SettingsController() {
PREF_DOH_GOOGLE.toString(), PREF_DOH_GOOGLE.toString(),
PREF_DOH_ADGUARD.toString(), PREF_DOH_ADGUARD.toString(),
PREF_DOH_QUAD9.toString(), PREF_DOH_QUAD9.toString(),
PREF_DOH_ALIDNS.toString(),
PREF_DOH_DNSPOD.toString(),
PREF_DOH_360.toString(),
PREF_DOH_QUAD101.toString(),
) )
defaultValue = "-1" defaultValue = "-1"
summary = "%s" summary = "%s"
@ -197,6 +211,28 @@ class SettingsAdvancedController : SettingsController() {
true 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 { preferenceCategory {
@ -301,6 +337,7 @@ class SettingsAdvancedController : SettingsController() {
webview.clearHistory() webview.clearHistory()
webview.clearSslPreferences() webview.clearSslPreferences()
WebStorage.getInstance().deleteAllData() WebStorage.getInstance().deleteAllData()
activity?.applicationInfo?.dataDir?.let { File("$it/app_webview/").deleteRecursively() }
activity?.toast(R.string.webview_data_deleted) activity?.toast(R.string.webview_data_deleted)
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) 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.onClick
import eu.kanade.tachiyomi.util.preference.preference import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.preferenceCategory 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.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -72,6 +73,12 @@ class SettingsDownloadController : SettingsController() {
bindTo(preferences.saveChaptersAsCBZ()) bindTo(preferences.saveChaptersAsCBZ())
titleRes = R.string.save_chapter_as_cbz 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 { preferenceCategory {
titleRes = R.string.pref_category_delete_chapters titleRes = R.string.pref_category_delete_chapters
@ -125,20 +132,20 @@ class SettingsDownloadController : SettingsController() {
titleRes = R.string.pref_category_auto_download titleRes = R.string.pref_category_auto_download
switchPreference { switchPreference {
bindTo(preferences.downloadNew()) bindTo(preferences.downloadNewChapter())
titleRes = R.string.pref_download_new titleRes = R.string.pref_download_new
} }
preference { preference {
bindTo(preferences.downloadNewCategories()) bindTo(preferences.downloadNewChapterCategories())
titleRes = R.string.categories titleRes = R.string.categories
onClick { onClick {
DownloadCategoriesDialog().showDialog(router) DownloadCategoriesDialog().showDialog(router)
} }
visibleIf(preferences.downloadNew()) { it } visibleIf(preferences.downloadNewChapter()) { it }
fun updateSummary() { fun updateSummary() {
val selectedCategories = preferences.downloadNewCategories().get() val selectedCategories = preferences.downloadNewChapterCategories().get()
.mapNotNull { id -> categories.find { it.id == id.toInt() } } .mapNotNull { id -> categories.find { it.id == id.toInt() } }
.sortedBy { it.order } .sortedBy { it.order }
val includedItemsText = if (selectedCategories.isEmpty()) { val includedItemsText = if (selectedCategories.isEmpty()) {
@ -147,7 +154,7 @@ class SettingsDownloadController : SettingsController() {
selectedCategories.joinToString { it.name } selectedCategories.joinToString { it.name }
} }
val excludedCategories = preferences.downloadNewCategoriesExclude().get() val excludedCategories = preferences.downloadNewChapterCategoriesExclude().get()
.mapNotNull { id -> categories.find { it.id == id.toInt() } } .mapNotNull { id -> categories.find { it.id == id.toInt() } }
.sortedBy { it.order } .sortedBy { it.order }
val excludedItemsText = if (excludedCategories.isEmpty()) { val excludedItemsText = if (excludedCategories.isEmpty()) {
@ -163,10 +170,10 @@ class SettingsDownloadController : SettingsController() {
} }
} }
preferences.downloadNewCategories().asFlow() preferences.downloadNewChapterCategories().asFlow()
.onEach { updateSummary() } .onEach { updateSummary() }
.launchIn(viewScope) .launchIn(viewScope)
preferences.downloadNewCategoriesExclude().asFlow() preferences.downloadNewChapterCategoriesExclude().asFlow()
.onEach { updateSummary() } .onEach { updateSummary() }
.launchIn(viewScope) .launchIn(viewScope)
} }
@ -254,8 +261,8 @@ class SettingsDownloadController : SettingsController() {
var selected = categories var selected = categories
.map { .map {
when (it.id.toString()) { when (it.id.toString()) {
in preferences.downloadNewCategories().get() -> QuadStateTextView.State.CHECKED.ordinal in preferences.downloadNewChapterCategories().get() -> QuadStateTextView.State.CHECKED.ordinal
in preferences.downloadNewCategoriesExclude().get() -> QuadStateTextView.State.INVERSED.ordinal in preferences.downloadNewChapterCategoriesExclude().get() -> QuadStateTextView.State.INVERSED.ordinal
else -> QuadStateTextView.State.UNCHECKED.ordinal else -> QuadStateTextView.State.UNCHECKED.ordinal
} }
} }
@ -282,8 +289,8 @@ class SettingsDownloadController : SettingsController() {
.map { categories[it].id.toString() } .map { categories[it].id.toString() }
.toSet() .toSet()
preferences.downloadNewCategories().set(included) preferences.downloadNewChapterCategories().set(included)
preferences.downloadNewCategoriesExclude().set(excluded) preferences.downloadNewChapterCategoriesExclude().set(excluded)
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.create() .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.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING import eu.kanade.tachiyomi.data.preference.*
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.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.databinding.PrefLibraryColumnsBinding import eu.kanade.tachiyomi.databinding.PrefLibraryColumnsBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
@ -159,8 +154,8 @@ class SettingsLibraryController : SettingsController() {
multiSelectListPreference { multiSelectListPreference {
bindTo(preferences.libraryUpdateDeviceRestriction()) bindTo(preferences.libraryUpdateDeviceRestriction())
titleRes = R.string.pref_library_update_restriction titleRes = R.string.pref_library_update_restriction
entriesRes = arrayOf(R.string.connected_to_wifi, R.string.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_CHARGING) entryValues = arrayOf(DEVICE_ONLY_ON_WIFI, DEVICE_NETWORK_NOT_METERED, DEVICE_CHARGING, DEVICE_BATTERY_NOT_LOW)
visibleIf(preferences.libraryUpdateInterval()) { it > 0 } visibleIf(preferences.libraryUpdateInterval()) { it > 0 }
@ -176,7 +171,9 @@ class SettingsLibraryController : SettingsController() {
.map { .map {
when (it) { when (it) {
DEVICE_ONLY_ON_WIFI -> context.getString(R.string.connected_to_wifi) 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_CHARGING -> context.getString(R.string.charging)
DEVICE_BATTERY_NOT_LOW -> context.getString(R.string.battery_not_low)
else -> it else -> it
} }
} }

View File

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

View File

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

View File

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

View File

@ -166,12 +166,12 @@ class WebViewActivity : BaseActivity() {
menu.findItem(R.id.action_web_back).apply { menu.findItem(R.id.action_web_back).apply {
isEnabled = binding.webview.canGoBack() 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 { menu.findItem(R.id.action_web_forward).apply {
isEnabled = binding.webview.canGoForward() 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) return super.onPrepareOptionsMenu(menu)

View File

@ -56,14 +56,14 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
if (!favorite) return false if (!favorite) return false
// Boolean to determine if user wants to automatically download new chapters. // Boolean to determine if user wants to automatically download new chapters.
val downloadNew = prefs.downloadNew().get() val downloadNewChapter = prefs.downloadNewChapter().get()
if (!downloadNew) return false if (!downloadNewChapter) return false
val categoriesToDownload = prefs.downloadNewCategories().get().map(String::toInt) val includedCategories = prefs.downloadNewChapterCategories().get().map { it.toInt() }
val categoriesToExclude = prefs.downloadNewCategoriesExclude().get().map(String::toInt) val excludedCategories = prefs.downloadNewChapterCategoriesExclude().get().map { it.toInt() }
// Default: download from all categories // Default: Download from all categories
if (categoriesToDownload.isEmpty() && categoriesToExclude.isEmpty()) return true if (includedCategories.isEmpty() && excludedCategories.isEmpty()) return true
// Get all categories, else default category (0) // Get all categories, else default category (0)
val categoriesForManga = val categoriesForManga =
@ -72,8 +72,11 @@ fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper
.takeUnless { it.isEmpty() } ?: listOf(0) .takeUnless { it.isEmpty() } ?: listOf(0)
// In excluded category // 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 // 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 // Get chapter title with lower case
var name = chapter.name.lowercase() var name = chapter.name.lowercase()
// Remove comma's from chapter. // Remove comma's or hyphens.
name = name.replace(',', '.') name = name.replace(',', '.').replace('-', '.')
// Remove unwanted white spaces. // Remove unwanted white spaces.
unwantedWhiteSpace.findAll(name).let { 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.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga 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 { fun getChapterSort(manga: Manga, sortDescending: Boolean = manga.sortDescending()): (Chapter, Chapter) -> Int {
return when (manga.sorting) { 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) } false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
} }
Manga.CHAPTER_SORTING_NUMBER -> when (sortDescending) { Manga.CHAPTER_SORTING_NUMBER -> when (sortDescending) {
true -> { c1, c2 -> c2.chapter_number.toString().compareToCaseInsensitiveNaturalOrder(c1.chapter_number.toString()) } true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) }
false -> { c1, c2 -> c1.chapter_number.toString().compareToCaseInsensitiveNaturalOrder(c2.chapter_number.toString()) } false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
} }
Manga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) { Manga.CHAPTER_SORTING_UPLOAD_DATE -> when (sortDescending) {
true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) } true -> { c1, c2 -> c2.date_upload.compareTo(c1.date_upload) }
false -> { c1, c2 -> c1.date_upload.compareTo(c2.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 uy.kohesive.injekt.api.get
import java.util.Date import java.util.Date
import java.util.TreeSet 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. * 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) { for (sourceChapter in sourceChapters) {
// This forces metadata update for the main viewable things in the chapter list. // This forces metadata update for the main viewable things in the chapter list.
if (source is HttpSource) { if (source is HttpSource) {
@ -72,7 +76,9 @@ fun syncChaptersWithSource(
// Add the chapter if not in db already, or update if the metadata changed. // Add the chapter if not in db already, or update if the metadata changed.
if (dbChapter == null) { if (dbChapter == null) {
if (sourceChapter.date_upload == 0L) { 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) toAdd.add(sourceChapter)
} else { } else {
@ -97,6 +103,7 @@ fun syncChaptersWithSource(
return Pair(emptyList(), emptyList()) return Pair(emptyList(), emptyList())
} }
// Keep it a List instead of a Set. See #6372.
val readded = mutableListOf<Chapter>() val readded = mutableListOf<Chapter>()
db.inTransaction { db.inTransaction {
@ -154,6 +161,7 @@ fun syncChaptersWithSource(
db.updateLastUpdated(manga).executeAsBlocking() db.updateLastUpdated(manga).executeAsBlocking()
} }
@Suppress("ConvertArgumentToSet")
return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList()) return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList())
} }

View File

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

View File

@ -47,6 +47,7 @@ import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720 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>()!! val clipboard = getSystemService<ClipboardManager>()!!
clipboard.setPrimaryClip(ClipData.newPlainText(label, content)) 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) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
toast(R.string.clipboard_copy_error) 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. * Converts to dp.
*/ */
@ -254,7 +262,7 @@ fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) {
} }
fun Context.defaultBrowserPackageName(): String? { 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) return packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo?.packageName ?.activityInfo?.packageName
?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers } ?.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 * 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 { fun Context.createReaderThemeContext(): Context {
val prefs = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()
val isDarkBackground = when (prefs.readerTheme().get()) { val isDarkBackground = when (preferences.readerTheme().get()) {
1, 2 -> true // Black, Gray 1, 2 -> true // Black, Gray
3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default 3 -> applicationContext.isNightMode() // Automatic bg uses activity background by default
else -> false // White else -> false // White
@ -325,7 +333,7 @@ fun Context.createReaderThemeContext(): Context {
val wrappedContext = ContextThemeWrapper(this, R.style.Theme_Tachiyomi) val wrappedContext = ContextThemeWrapper(this, R.style.Theme_Tachiyomi)
wrappedContext.applyOverrideConfiguration(overrideConf) 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) } .forEach { wrappedContext.theme.applyStyle(it, true) }
return wrappedContext return wrappedContext
} }

View File

@ -4,25 +4,35 @@ import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha import androidx.core.graphics.alpha
import androidx.core.graphics.applyCanvas import androidx.core.graphics.applyCanvas
import androidx.core.graphics.blue import androidx.core.graphics.blue
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.get
import androidx.core.graphics.green import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import com.hippo.unifile.UniFile
import logcat.LogPriority
import tachiyomi.decoder.Format import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.net.URLConnection import java.net.URLConnection
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min
object ImageUtil { object ImageUtil {
@ -56,6 +66,12 @@ object ImageUtil {
return null return null
} }
fun getExtensionFromMimeType(mime: String?): String {
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
?: SUPPLEMENTARY_MIMETYPE_MAPPING[mime]
?: "jpg"
}
fun isAnimatedAndSupported(stream: InputStream): Boolean { fun isAnimatedAndSupported(stream: InputStream): Boolean {
try { try {
val type = getImageType(stream) ?: return false val type = getImageType(stream) ?: return false
@ -66,8 +82,7 @@ object ImageUtil {
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
else -> false else -> false
} }
} catch (e: Exception) { } catch (e: Exception) { /* Do Nothing */ }
}
return false 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 * @return true if the width is greater than the height
*/ */
fun isDoublePage(imageStream: InputStream): Boolean { fun isWideImage(imageStream: BufferedInputStream): Boolean {
imageStream.mark(imageStream.available() + 1) val options = extractImageOptions(imageStream)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
imageStream.reset()
return options.outWidth > options.outHeight return options.outWidth > options.outHeight
} }
@ -178,6 +186,111 @@ object ImageUtil {
RIGHT, LEFT 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 * Algorithm for determining what background to accompany a comic/manga page
*/ */
@ -202,14 +315,14 @@ object ImageUtil {
val leftOffsetX = left - offsetX val leftOffsetX = left - offsetX
val rightOffsetX = right + offsetX val rightOffsetX = right + offsetX
val topLeftPixel = image.getPixel(left, top) val topLeftPixel = image[left, top]
val topRightPixel = image.getPixel(right, top) val topRightPixel = image[right, top]
val midLeftPixel = image.getPixel(left, midY) val midLeftPixel = image[left, midY]
val midRightPixel = image.getPixel(right, midY) val midRightPixel = image[right, midY]
val topCenterPixel = image.getPixel(midX, top) val topCenterPixel = image[midX, top]
val botLeftPixel = image.getPixel(left, bot) val botLeftPixel = image[left, bot]
val bottomCenterPixel = image.getPixel(midX, bot) val bottomCenterPixel = image[midX, bot]
val botRightPixel = image.getPixel(right, bot) val botRightPixel = image[right, bot]
val topLeftIsDark = topLeftPixel.isDark() val topLeftIsDark = topLeftPixel.isDark()
val topRightIsDark = topRightPixel.isDark() val topRightIsDark = topRightPixel.isDark()
@ -262,8 +375,8 @@ object ImageUtil {
var whiteStreak = false var whiteStreak = false
val notOffset = x == left || x == right val notOffset = x == left || x == right
inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) { inner@ for ((index, y) in (0 until image.height step image.height / 25).withIndex()) {
val pixel = image.getPixel(x, y) val pixel = image[x, y]
val pixelOff = image.getPixel(x + (if (x < image.width / 2) -offsetX else offsetX), y) val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
if (pixel.isWhite()) { if (pixel.isWhite()) {
whitePixelsStreak++ whitePixelsStreak++
whitePixels++ whitePixels++
@ -354,8 +467,8 @@ object ImageUtil {
val topCornersIsDark = topLeftIsDark && topRightIsDark val topCornersIsDark = topLeftIsDark && topRightIsDark
val botCornersIsDark = botLeftIsDark && botRightIsDark val botCornersIsDark = botLeftIsDark && botRightIsDark
val topOffsetCornersIsDark = image.getPixel(leftOffsetX, top).isDark() && image.getPixel(rightOffsetX, top).isDark() val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
val botOffsetCornersIsDark = image.getPixel(leftOffsetX, bot).isDark() && image.getPixel(rightOffsetX, bot).isDark() val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
val gradient = when { val gradient = when {
darkBG && botCornersIsWhite -> { 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 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 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 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 context any context.
* @param resId the vector resource to load and tint * @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 { return AppCompatResources.getDrawable(context, resId)!!.apply {
setTint(context.getResourceColor(if (enabled) colorAttrRes else R.attr.colorControlNormal)) setTint(context.getResourceColor(if (enabled) colorAttrRes else R.attr.colorControlNormal))
} }

View File

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

View File

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

View File

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

View File

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

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