Compare commits

...

47 Commits

Author SHA1 Message Date
e863e8c64b Adjust Wi-Fi connection check (related to #6038) 2021-10-04 17:06:24 -04:00
f5b591430c Release v0.12.3 2021-10-04 15:55:06 -04:00
8cfaf8eb51 Weblate translations (#5913)
Co-authored-by: AHmed HarBy <themagic1093@gmail.com>
Co-authored-by: Ainārs Lapkovskis <ainarslapkovskis@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Albedo <Illiator27@gmail.com>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alifian Caesar <alifiancaesar@gmail.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Blue <bluestuffish@gmail.com>
Co-authored-by: Christian Elbrianno <christian.elbrianno41@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Druvvaldis <druvvaldisr@gmail.com>
Co-authored-by: Emerson Nunes <emerson.nunes.ds@gmail.com>
Co-authored-by: Emma Jane Bonestell <EmmaJaneBonestell@gmail.com>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Fernando Sanchez <cheeze.sprinkels@gmail.com>
Co-authored-by: Forqen <krecio555@gmail.com>
Co-authored-by: Francesco Zanella <franzghosts@gmail.com>
Co-authored-by: Giorgio Sanna <sannagiorgio1997@gmail.com>
Co-authored-by: Hassay Ádám Tamás <hassayadam@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jakub Fabijan <jakubfabijan@tuta.io>
Co-authored-by: Junan Chk <junanchakma2000@gmail.com>
Co-authored-by: K. Sz. Bence <tudi20@protonmail.com>
Co-authored-by: Kaleb <kalebcarvalho1@gmail.com>
Co-authored-by: Krishna Chand <krishna_chand67@naver.com>
Co-authored-by: LoneHash <sameepsk2@gmail.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matyáš Caras <contact@hernikplays.cz>
Co-authored-by: Miguel Alexandro Manzano Guerra <kuro_eis@hotmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nishant Bodkhe <nishantbodkhe44@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Pedro <pedro-mediavilla@hotmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redy Apriyadi <redy.apriyadi@gmail.com>
Co-authored-by: Ric <rikku.debec@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Sieg Jaeger <zekerett@gmail.com>
Co-authored-by: Steven Pedroza <stevenpedroza56@gmail.com>
Co-authored-by: Temporary Person <TemporaryPerson@protonmail.com>
Co-authored-by: Zero O <godarms2010@live.com>
Co-authored-by: ZiomaleQ <r.partyka30@gmail.com>
Co-authored-by: crackheadakira <lasn.mine@gmail.com>
Co-authored-by: dmswd <Bmswad1@gmail.com>
Co-authored-by: julptk <julptk8@gmail.com>
Co-authored-by: monolifed <monolifed@protonmail.com>
Co-authored-by: phannhanhn201 <phannhanhn201@gmail.com>
Co-authored-by: rytis sertvytis <knysliukas2002@gmail.com>
Co-authored-by: soplatnik <jestapom@protonmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: พรหมชัย ชูแสง <promchai2sin@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/aii/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/eo/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es_419/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/jv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/lt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/lv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/mr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/th/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/vi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: AHmed HarBy <themagic1093@gmail.com>
Co-authored-by: Ainārs Lapkovskis <ainarslapkovskis@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Albedo <Illiator27@gmail.com>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alifian Caesar <alifiancaesar@gmail.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Blue <bluestuffish@gmail.com>
Co-authored-by: Christian Elbrianno <christian.elbrianno41@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Druvvaldis <druvvaldisr@gmail.com>
Co-authored-by: Emerson Nunes <emerson.nunes.ds@gmail.com>
Co-authored-by: Emma Jane Bonestell <EmmaJaneBonestell@gmail.com>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Fernando Sanchez <cheeze.sprinkels@gmail.com>
Co-authored-by: Forqen <krecio555@gmail.com>
Co-authored-by: Francesco Zanella <franzghosts@gmail.com>
Co-authored-by: Giorgio Sanna <sannagiorgio1997@gmail.com>
Co-authored-by: Hassay Ádám Tamás <hassayadam@gmail.com>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jakub Fabijan <jakubfabijan@tuta.io>
Co-authored-by: Junan Chk <junanchakma2000@gmail.com>
Co-authored-by: K. Sz. Bence <tudi20@protonmail.com>
Co-authored-by: Kaleb <kalebcarvalho1@gmail.com>
Co-authored-by: Krishna Chand <krishna_chand67@naver.com>
Co-authored-by: LoneHash <sameepsk2@gmail.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matyáš Caras <contact@hernikplays.cz>
Co-authored-by: Miguel Alexandro Manzano Guerra <kuro_eis@hotmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nishant Bodkhe <nishantbodkhe44@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Pedro <pedro-mediavilla@hotmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redy Apriyadi <redy.apriyadi@gmail.com>
Co-authored-by: Ric <rikku.debec@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Sieg Jaeger <zekerett@gmail.com>
Co-authored-by: Steven Pedroza <stevenpedroza56@gmail.com>
Co-authored-by: Temporary Person <TemporaryPerson@protonmail.com>
Co-authored-by: Zero O <godarms2010@live.com>
Co-authored-by: ZiomaleQ <r.partyka30@gmail.com>
Co-authored-by: crackheadakira <lasn.mine@gmail.com>
Co-authored-by: dmswd <Bmswad1@gmail.com>
Co-authored-by: julptk <julptk8@gmail.com>
Co-authored-by: monolifed <monolifed@protonmail.com>
Co-authored-by: phannhanhn201 <phannhanhn201@gmail.com>
Co-authored-by: rytis sertvytis <knysliukas2002@gmail.com>
Co-authored-by: soplatnik <jestapom@protonmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: พรหมชัย ชูแสง <promchai2sin@gmail.com>
2021-10-04 15:41:54 -04:00
675c0cefc3 Fix crash in single-page chapters 2021-10-04 11:06:23 -04:00
1a52385b78 Formatting 2021-10-04 10:50:13 -04:00
372e500590 Remove extra padding when using list with Per Category setting (#5997)
* Remove padding when using list with Per Category setting (fixes #5636)

* Add view type to RecyclerViewPagerAdapter

Correctly this time (ノ◕ヮ◕)ノ*:・゚✧

* Minor tweaks
2021-10-04 10:41:20 -04:00
cc1a317439 enable "ALL" in Browse by default (#6023)
some extensions, including self-hosted ones, have the "ALL" label and
sometimes users get confused with not having enabled "ALL" after
installing new extensions
2021-10-03 15:40:51 -04:00
6d650518a1 App-wide typography adjustments (#5931)
* Manga detail

Also adjust chapter item layout to accommodate bigger
display/font size

* Library

* Updates

* History

* Browse

* Preferences

* Button

* Navigation view

* category-download

* Google Sans

* Reader

* Chips

* Revert "Google Sans"

This reverts commit 5dd4c41f

* Misc

* Cleanups

* Section header text appearance

* Increase library manga title size

* Revert "Increase library manga title size"

This reverts commit 474be913

* Increase section header letter spacing

* Derps
2021-10-03 12:32:04 -04:00
7940117577 Sort and remove duplicates in genres (#6021)
* Sort and remove duplicates in genres

Co-authored-by: ivaniskandar <12537387+ivaniskandar@users.noreply.github.com>

* Remove Sort and filter out blank genre

Co-authored-by: ivaniskandar <12537387+ivaniskandar@users.noreply.github.com>
2021-10-03 12:19:37 -04:00
b0f87fdd21 LicensesController: Move item init to IO thread (#6020) 2021-10-03 12:00:00 -04:00
dc92ffed87 Switch to Material Slider in color filter settings 2021-10-03 11:58:52 -04:00
4af578e310 Apply navigation bar insets to fast scroller and settings search list (#6015) 2021-10-03 11:28:20 -04:00
e22825d818 Check if wifi is connected rather than enabled while downloading. (#5967)
* Fixxy Wixxy

* Downgrade check from Android S to Android Q
2021-10-03 11:27:56 -04:00
e2da6259e7 Update AboutLib plugin 2021-10-03 11:14:56 -04:00
d149017c60 Switch to Material Slider for reader seekbar
Co-authored-by: Jays2Kings <Jays2Kings@users.noreply.github.com>
2021-10-03 11:14:49 -04:00
afc400121b Update dependencies 2021-10-01 18:28:02 -04:00
ef993515c6 Fix MangaController toolbar title showing when editing category (#6005) 2021-10-01 17:52:06 -04:00
edb1d21ddc Don't bury sort menu in overflow in Migrate screen 2021-10-01 17:41:14 -04:00
ba8abd94a8 Ability to order sources by library count when migrating (#6000)
* order sources by library count when migrating (closes #4703)

* Use plain menu instead of full-on sheet
2021-10-01 17:37:43 -04:00
c6d4e4c15f Move extensions enabled languages on top (closes #5694) (#5998) 2021-10-01 09:15:04 -04:00
09f0ac866f Fix incorrect appbar lift state when opening MangaController in hidden state (#5990) 2021-10-01 09:13:00 -04:00
7ed25704d6 Add chapter bookmarking feature to Updates screen (#5984) 2021-10-01 08:11:31 -04:00
2196dac63e Fix variable name in isOnline (#5991) 2021-10-01 08:09:46 -04:00
c8f70efded ReaderActivity: Block focus on viewer (#5996) 2021-10-01 08:09:36 -04:00
ea97488670 Revert parseAs inline function change
Some people sometimes get compile issues?
2021-09-30 17:52:07 -04:00
c2255b0a0f Mark installer names as non-translatable 2021-09-25 21:08:31 -04:00
f754b081ce Use data class to parse extensions list 2021-09-25 14:57:54 -04:00
07771cb5e4 Update kotlinx.serialization 2021-09-25 14:41:48 -04:00
690d8e43ae Show message in migrate screen if library is empty 2021-09-25 14:41:35 -04:00
82f14a7d59 Hide soft keyboard after submitting search query throughout app (#5837)
* Clear focus from SearchView when submitting a search query in BrowseSourceController

* Revert "Clear focus from SearchView when submitting a search query"

* Implement SearchView focus clearing in Tachiyomi's subclass to enable feature throughout app

* Add support for keyboard Enter key

Pressing enter on a keyboard (when using the emulator for example) now also submits the query
2021-09-25 14:32:19 -04:00
b284384f0a Implement new extension install methods (#5904)
* Implement new extension install methods

* Fixes

* Resolve feedback

* Keep pending status when waiting to install

* Cancellable installation

* Remove auto error now that we have cancellable job
2021-09-25 14:31:52 -04:00
1ae0d1b5d0 Reattach after slight delay instead on every db update (#5956) 2021-09-23 18:45:55 -04:00
9de08c8166 Update dependencies 2021-09-20 14:33:35 -04:00
a2d007f2a9 Toolbar and bottom nav scroll snap (#5915) 2021-09-18 16:41:23 -04:00
774f818bbb Fix setting search re-animating on activity recreation (fixes #5882) 2021-09-18 16:28:58 -04:00
0ec7121b8f Adjust snackbar durations (closes #5932) 2021-09-18 16:17:07 -04:00
d7d46f4447 Minor cleanup 2021-09-18 16:13:14 -04:00
45fad147bf Remove spaces at end of line before removing multiple new lines (#5928) 2021-09-18 15:16:03 -04:00
3664195c71 rewrite getFormat the kotlin way (#5930) 2021-09-18 15:15:38 -04:00
fce3cd00a1 Remove setting to disable update error notifications and split out notification channel
Users can exclude things from updating if needed, or disable the notification channel from system settings.
2021-09-17 19:14:30 -04:00
33b3be0d0e Move extension app info button
Aligns with TachiyomiJ2K.
2021-09-16 17:57:41 -04:00
cfd1b4a6c6 Fix toolbar title alpha (#5910) 2021-09-16 17:39:13 -04:00
d45fefd6f0 handle maxNumberSort from API (#5917) 2021-09-16 17:37:42 -04:00
f125ab01ee Change how the bottom navigation is hidden (#5823)
* Change how the bottom navigation is hidden

Modifies the translationY instead of the height.

* Cleanups
2021-09-16 17:37:17 -04:00
be001d090c [skip ci] Update issue closer to ignore myanimelist (#5911)
Not sure if there's any limitation for the regex but this will ignore myanimelist strings, in practice.
2021-09-14 11:50:21 -04:00
971d8a7e40 Allow preferences to multi-line (#5905) 2021-09-13 18:39:14 -04:00
a2cf210a52 Unify NSFW flagging for sources/extensions
Since multisource extensions are no longer a thing, we now simply rely on the flag at the extension level, i.e. the per-Source/SourceFactory `@Nsfw` annotation is no longer checked.
We'll have to remove all of the annotation usages from the existing sources, which will also effectively break the setting for older versions of the app.
2021-09-13 17:49:58 -04:00
175 changed files with 3498 additions and 1761 deletions

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ jobs:
},
{
"type": "both",
"regex": ".*(aniyomi|anime).*",
"regex": "^(?!.*myanimelist.*).*(aniyomi|anime).*$",
"ignoreCase": true,
"message": "Tachiyomi does not support anime, and has no plans to support anime. In addition Tachiyomi is not affiliated with Aniyomi https://github.com/jmir1/aniyomi"
}

View File

@ -29,8 +29,8 @@ android {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = 68
versionName = "0.12.2"
versionCode = 69
versionName = "0.12.3"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@ -138,19 +138,20 @@ dependencies {
implementation("org.tachiyomi:source-api:1.1")
// AndroidX libraries
implementation("androidx.annotation:annotation:1.3.0-alpha01")
implementation("androidx.annotation:annotation:1.3.0-beta01")
implementation("androidx.appcompat:appcompat:1.4.0-alpha03")
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha03")
implementation("androidx.browser:browser:1.3.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.0")
implementation("androidx.browser:browser:1.4.0-beta01")
implementation("androidx.constraintlayout:constraintlayout:2.1.1")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
implementation("androidx.core:core-ktx:1.7.0-alpha02")
implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
implementation("androidx.recyclerview:recyclerview:1.2.1")
implementation("androidx.core:core-ktx:1.7.0-beta02")
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
implementation("androidx.recyclerview:recyclerview:1.3.0-alpha01")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
implementation("androidx.viewpager:viewpager:1.1.0-alpha01")
val lifecycleVersion = "2.4.0-alpha01"
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
val lifecycleVersion = "2.4.0-beta01"
implementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
@ -174,7 +175,7 @@ dependencies {
implementation("org.conscrypt:conscrypt-android:2.5.2")
// Data serialization (JSON, protobuf)
val kotlinSerializationVersion = "1.3.0-RC"
val kotlinSerializationVersion = "1.3.0"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
@ -225,13 +226,15 @@ dependencies {
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// UI libraries
implementation("com.google.android.material:material:1.5.0-alpha03")
implementation("com.google.android.material:material:1.5.0-alpha04")
implementation("com.github.dmytrodanylyk.android-process-button:library:1.0.4")
implementation("eu.davidea:flexible-adapter:5.1.0")
implementation("eu.davidea:flexible-adapter-ui:1.0.0")
implementation("com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0")
implementation("com.github.chrisbanes:PhotoView:2.3.0")
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0")
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0") {
exclude(group = "androidx.viewpager", module = "viewpager")
}
implementation("dev.chrisbanes.insetter:insetter:0.6.0")
// Conductor
@ -258,6 +261,11 @@ dependencies {
// Licenses
implementation("com.mikepenz:aboutlibraries-core:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
// Shizuku
val shizukuVersion = "12.0.0"
implementation("dev.rikka.shizuku:api:$shizukuVersion")
implementation("dev.rikka.shizuku:provider:$shizukuVersion")
// Tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.16.1")

View File

@ -18,6 +18,7 @@
<!-- For managing extensions -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- To view extension packages in API 30+ -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
@ -188,6 +189,9 @@
android:name=".data.backup.BackupRestoreService"
android:exported="false" />
<service android:name=".extension.util.ExtensionInstallService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@ -198,6 +202,14 @@
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing"
android:value="false" />
<meta-data android:name="android.webkit.WebView.MetricsOptOut"

View File

@ -0,0 +1,100 @@
package com.google.android.material.appbar
import android.animation.ValueAnimator
import android.view.View
import android.view.animation.DecelerateInterpolator
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.animation.doOnEnd
import androidx.core.view.ViewCompat
import androidx.core.view.marginTop
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.findChild
import eu.kanade.tachiyomi.widget.ElevationAppBarLayout
import kotlin.math.roundToLong
/**
* Hide toolbar on scroll behavior for [AppBarLayout].
*
* Inside this package to access some package-private methods.
*/
class HideToolbarOnScrollBehavior : AppBarLayout.Behavior() {
@ViewCompat.NestedScrollType
private var lastStartedType: Int = 0
private var offsetAnimator: ValueAnimator? = null
private var toolbarHeight: Int = 0
override fun onStartNestedScroll(
parent: CoordinatorLayout,
child: AppBarLayout,
directTargetChild: View,
target: View,
nestedScrollAxes: Int,
type: Int
): Boolean {
lastStartedType = type
offsetAnimator?.cancel()
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
}
override fun onStopNestedScroll(
parent: CoordinatorLayout,
layout: AppBarLayout,
target: View,
type: Int
) {
super.onStopNestedScroll(parent, layout, target, type)
if (toolbarHeight == 0) {
toolbarHeight = layout.findChild<Toolbar>()?.height ?: 0
}
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
animateToolbarVisibility(
parent,
layout,
getTopBottomOffsetForScrollingSibling(layout) > -toolbarHeight / 2
)
}
}
override fun onFlingFinished(parent: CoordinatorLayout, layout: AppBarLayout) {
super.onFlingFinished(parent, layout)
animateToolbarVisibility(
parent,
layout,
getTopBottomOffsetForScrollingSibling(layout) > -toolbarHeight / 2
)
}
private fun getTopBottomOffsetForScrollingSibling(abl: AppBarLayout): Int {
return topBottomOffsetForScrollingSibling - abl.marginTop
}
private fun animateToolbarVisibility(
coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
isVisible: Boolean
) {
val current = getTopBottomOffsetForScrollingSibling(child)
val target = if (isVisible) 0 else -toolbarHeight
if (current == target) return
offsetAnimator?.cancel()
offsetAnimator = ValueAnimator().apply {
interpolator = DecelerateInterpolator()
duration = (150 * child.context.animatorDurationScale).roundToLong()
addUpdateListener {
setHeaderTopBottomOffset(coordinatorLayout, child, it.animatedValue as Int)
}
doOnEnd {
if ((child as? ElevationAppBarLayout)?.isTransparentWhenNotLifted == true) {
child.isLifted = !isVisible
}
}
setIntValues(current, target)
start()
}
}
}

View File

@ -12,9 +12,8 @@ import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
@ -45,14 +44,14 @@ import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.Security
open class App : Application(), LifecycleObserver, ImageLoaderFactory {
open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
private val preferences: PreferencesHelper by injectLazy()
private val disableIncognitoReceiver = DisableIncognitoReceiver()
override fun onCreate() {
super.onCreate()
super<Application>.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// TLS 1.3 support for Android < 10
@ -131,9 +130,7 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory {
}.build()
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
@Suppress("unused")
fun onAppBackgrounded() {
override fun onStop(owner: LifecycleOwner) {
if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) {
SecureActivityDelegate.locked = true
}

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.annotations
// TODO: remove this when no longer used in extensions
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class Nsfw

View File

@ -36,7 +36,8 @@ interface Manga : SManga {
}
fun getGenres(): List<String>? {
return genre?.split(", ")?.map { it.trim() }
if (genre.isNullOrBlank()) return null
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
}
private fun setChapterFlags(flag: Int, mask: Int) {

View File

@ -15,11 +15,11 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.lang.plusAssign
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
import eu.kanade.tachiyomi.util.system.isOnline
import eu.kanade.tachiyomi.util.system.isServiceRunning
import eu.kanade.tachiyomi.util.system.notification
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.wifiManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import ru.beryukhov.reactivenetwork.ReactiveNetwork
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/**
@ -140,8 +141,9 @@ class DownloadService : Service() {
onNetworkStateChanged()
}
}
.catch {
.catch { error ->
withUIContext {
Timber.e(error)
toast(R.string.download_queue_error)
stopSelf()
}
@ -154,7 +156,7 @@ class DownloadService : Service() {
*/
private fun onNetworkStateChanged() {
if (isOnline()) {
if (preferences.downloadOnlyOverWifi() && !wifiManager.isWifiEnabled) {
if (preferences.downloadOnlyOverWifi() && !isConnectedToWifi()) {
stopDownloads(R.string.download_notifier_text_only_wifi)
} else {
val started = downloadManager.startDownloads()

View File

@ -51,7 +51,7 @@ class LibraryUpdateNotifier(private val context: Context) {
* Cached progress notification to avoid creating a lot.
*/
val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) {
setContentTitle(context.getString(R.string.app_name))
setSmallIcon(R.drawable.ic_refresh_24dp)
setLargeIcon(notificationBitmap)
@ -101,7 +101,7 @@ class LibraryUpdateNotifier(private val context: Context) {
context.notificationManager.notify(
Notifications.ID_LIBRARY_ERROR,
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
context.notificationBuilder(Notifications.CHANNEL_LIBRARY_ERROR) {
setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_error, errors.size, errors.size))
setStyle(
NotificationCompat.BigTextStyle().bigText(

View File

@ -350,7 +350,7 @@ class LibraryUpdateService(
}
}
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
if (failedUpdates.isNotEmpty()) {
val errorFile = writeErrorFile(failedUpdates)
notifier.showUpdateErrorNotification(
failedUpdates.map { it.first.title },

View File

@ -24,8 +24,10 @@ object Notifications {
/**
* Notification channel and ids used by the library updater.
*/
const val CHANNEL_LIBRARY = "library_channel"
private const val GROUP_LIBRARY = "group_library"
const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel"
const val ID_LIBRARY_PROGRESS = -101
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
const val ID_LIBRARY_ERROR = -102
/**
@ -51,6 +53,7 @@ object Notifications {
*/
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401
const val ID_EXTENSION_INSTALLER = -402
/**
* Notification channel and ids used by the backup/restore system.
@ -77,7 +80,8 @@ object Notifications {
private val deprecatedChannels = listOf(
"downloader_channel",
"backup_restore_complete_channel"
"backup_restore_complete_channel",
"library_channel",
)
/**
@ -89,64 +93,75 @@ object Notifications {
fun createChannels(context: Context) {
val notificationService = NotificationManagerCompat.from(context)
val channelGroupList = listOf(
buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) {
setName(context.getString(R.string.group_backup_restore))
},
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
setName(context.getString(R.string.group_downloader))
}
notificationService.createNotificationChannelGroupsCompat(
listOf(
buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) {
setName(context.getString(R.string.label_backup))
},
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
setName(context.getString(R.string.download_notifier_downloader_title))
},
buildNotificationChannelGroup(GROUP_LIBRARY) {
setName(context.getString(R.string.label_library))
},
)
)
notificationService.createNotificationChannelGroupsCompat(channelGroupList)
val channelList = listOf(
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_common))
},
buildNotificationChannel(CHANNEL_LIBRARY, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_library))
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_DOWNLOADER_PROGRESS, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_progress))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_DOWNLOADER_COMPLETE, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_complete))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_DOWNLOADER_ERROR, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_errors))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_NEW_CHAPTERS, IMPORTANCE_DEFAULT) {
setName(context.getString(R.string.channel_new_chapters))
},
buildNotificationChannel(CHANNEL_UPDATES_TO_EXTS, IMPORTANCE_DEFAULT) {
setName(context.getString(R.string.channel_ext_updates))
},
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_progress))
setGroup(GROUP_BACKUP_RESTORE)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) {
setName(context.getString(R.string.channel_complete))
setGroup(GROUP_BACKUP_RESTORE)
setShowBadge(false)
setSound(null, null)
},
buildNotificationChannel(CHANNEL_CRASH_LOGS, IMPORTANCE_HIGH) {
setName(context.getString(R.string.channel_crash_logs))
},
buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
setName(context.getString(R.string.pref_incognito_mode))
},
notificationService.createNotificationChannelsCompat(
listOf(
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_common))
},
buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_progress))
setGroup(GROUP_LIBRARY)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_errors))
setGroup(GROUP_LIBRARY)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_NEW_CHAPTERS, IMPORTANCE_DEFAULT) {
setName(context.getString(R.string.channel_new_chapters))
},
buildNotificationChannel(CHANNEL_DOWNLOADER_PROGRESS, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_progress))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_DOWNLOADER_COMPLETE, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_complete))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_DOWNLOADER_ERROR, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_errors))
setGroup(GROUP_DOWNLOADER)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) {
setName(context.getString(R.string.channel_progress))
setGroup(GROUP_BACKUP_RESTORE)
setShowBadge(false)
},
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) {
setName(context.getString(R.string.channel_complete))
setGroup(GROUP_BACKUP_RESTORE)
setShowBadge(false)
setSound(null, null)
},
buildNotificationChannel(CHANNEL_CRASH_LOGS, IMPORTANCE_HIGH) {
setName(context.getString(R.string.channel_crash_logs))
},
buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
setName(context.getString(R.string.pref_incognito_mode))
},
buildNotificationChannel(CHANNEL_UPDATES_TO_EXTS, IMPORTANCE_DEFAULT) {
setName(context.getString(R.string.channel_ext_updates))
},
)
)
notificationService.createNotificationChannelsCompat(channelList)
// Delete old notification channels
deprecatedChannels.forEach(notificationService::deleteNotificationChannel)

View File

@ -151,11 +151,12 @@ object PreferenceKeys {
const val librarySortingMode = "library_sorting_mode"
const val librarySortingDirection = "library_sorting_ascending"
const val migrationSortingMode = "pref_migration_sorting"
const val migrationSortingDirection = "pref_migration_direction"
const val automaticExtUpdates = "automatic_ext_updates"
const val showNsfwSource = "show_nsfw_source"
const val showNsfwExtension = "show_nsfw_extension"
const val labelNsfwExtension = "label_nsfw_extension"
const val startScreen = "start_screen"
@ -173,8 +174,6 @@ object PreferenceKeys {
const val autoUpdateTrackers = "auto_update_trackers"
const val showLibraryUpdateErrors = "show_library_update_errors"
const val downloadNew = "download_new"
const val downloadNewCategories = "download_new_categories"
@ -226,6 +225,8 @@ object PreferenceKeys {
const val tabletUiMode = "tablet_ui_mode"
const val extensionInstaller = "extension_installer"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

View File

@ -57,4 +57,10 @@ object PreferenceValues {
LANDSCAPE,
NEVER,
}
enum class ExtensionInstaller {
LEGACY,
PACKAGEINSTALLER,
SHIZUKU
}
}

View File

@ -12,11 +12,13 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferenceValues.ThemeMode.system
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesController
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.system.MiuiUtil
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import kotlinx.coroutines.flow.Flow
@ -86,8 +88,6 @@ class PreferencesHelper(val context: Context) {
fun autoUpdateTrackers() = prefs.getBoolean(Keys.autoUpdateTrackers, false)
fun showLibraryUpdateErrors() = prefs.getBoolean(Keys.showLibraryUpdateErrors, true)
fun themeMode() = flowPrefs.getEnum(Keys.themeMode, system)
fun appTheme() = flowPrefs.getEnum(Keys.appTheme, Values.AppTheme.DEFAULT)
@ -190,7 +190,7 @@ class PreferencesHelper(val context: Context) {
fun sourceDisplayMode() = flowPrefs.getEnum(Keys.sourceDisplayMode, DisplayModeSetting.COMPACT_GRID)
fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("en", Locale.getDefault().language))
fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("all", "en", Locale.getDefault().language))
fun trackUsername(sync: TrackService) = prefs.getString(Keys.trackUsername(sync.id), "")
@ -268,11 +268,12 @@ class PreferencesHelper(val context: Context) {
fun librarySortingMode() = flowPrefs.getEnum(Keys.librarySortingMode, SortModeSetting.ALPHABETICAL)
fun librarySortingAscending() = flowPrefs.getEnum(Keys.librarySortingDirection, SortDirectionSetting.ASCENDING)
fun migrationSortingMode() = flowPrefs.getEnum(Keys.migrationSortingMode, MigrationSourcesController.SortSetting.ALPHABETICAL)
fun migrationSortingDirection() = flowPrefs.getEnum(Keys.migrationSortingDirection, MigrationSourcesController.DirectionSetting.ASCENDING)
fun automaticExtUpdates() = flowPrefs.getBoolean(Keys.automaticExtUpdates, true)
fun showNsfwSource() = flowPrefs.getBoolean(Keys.showNsfwSource, true)
fun showNsfwExtension() = flowPrefs.getBoolean(Keys.showNsfwExtension, true)
fun labelNsfwExtension() = prefs.getBoolean(Keys.labelNsfwExtension, true)
fun extensionUpdatesCount() = flowPrefs.getInt("ext_updates_count", 0)
@ -325,6 +326,11 @@ class PreferencesHelper(val context: Context) {
if (context.applicationContext.isTablet()) Values.TabletUiMode.ALWAYS else Values.TabletUiMode.NEVER
)
fun extensionInstaller() = flowPrefs.getEnum(
Keys.extensionInstaller,
if (MiuiUtil.isMiui()) Values.ExtensionInstaller.LEGACY else Values.ExtensionInstaller.PACKAGEINSTALLER
)
fun setChapterSettingsDefault(manga: Manga) {
prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

View File

@ -47,7 +47,7 @@ class KomgaApi(private val client: OkHttpClient) {
track.apply {
cover_url = "$url/thumbnail"
tracking_url = url
total_chapters = progress.booksCount
total_chapters = progress.maxNumberSort.toInt()
status = when (progress.booksCount) {
progress.booksUnreadCount -> Komga.UNREAD
progress.booksReadCount -> Komga.COMPLETED

View File

@ -91,7 +91,8 @@ data class ReadProgressDto(
booksReadCount,
booksUnreadCount,
booksInProgressCount,
lastReadContinuousIndex.toFloat()
lastReadContinuousIndex.toFloat(),
booksCount.toFloat(),
)
}
@ -102,4 +103,5 @@ data class ReadProgressV2Dto(
val booksUnreadCount: Int,
val booksInProgressCount: Int,
val lastReadContinuousNumberSort: Float,
val maxNumberSort: Float,
)

View File

@ -227,14 +227,26 @@ class ExtensionManager(
return installExtension(availableExt)
}
fun cancelInstallUpdateExtension(extension: Extension) {
installer.cancelInstall(extension.pkgName)
}
/**
* Sets the result of the installation of an extension.
* Sets to "installing" status of an extension installation.
*
* @param downloadId The id of the download.
* @param result Whether the extension was installed or not.
*/
fun setInstalling(downloadId: Long) {
installer.updateInstallStep(downloadId, InstallStep.Installing)
}
fun setInstallationResult(downloadId: Long, result: Boolean) {
installer.setInstallationResult(downloadId, result)
val step = if (result) InstallStep.Installed else InstallStep.Error
installer.updateInstallStep(downloadId, step)
}
fun updateInstallStep(downloadId: Long, step: InstallStep) {
installer.updateInstallStep(downloadId, step)
}
/**

View File

@ -10,11 +10,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.Serializable
import uy.kohesive.injekt.injectLazy
import java.util.Date
@ -28,8 +24,8 @@ internal class ExtensionGithubApi {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
.parseAs<JsonArray>()
.let { parseResponse(it) }
.parseAs<List<ExtensionJsonObject>>()
.toExtensions()
}
}
@ -56,24 +52,23 @@ internal class ExtensionGithubApi {
return extensionsWithUpdate
}
private fun parseResponse(json: JsonArray): List<Extension.Available> {
return json
.filter { element ->
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
val libVersion = versionName.substringBeforeLast('.').toDouble()
private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
return this
.filter {
val libVersion = it.version.substringBeforeLast('.').toDouble()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
}
.map { element ->
val name = element.jsonObject["name"]!!.jsonPrimitive.content.substringAfter("Tachiyomi: ")
val pkgName = element.jsonObject["pkg"]!!.jsonPrimitive.content
val apkName = element.jsonObject["apk"]!!.jsonPrimitive.content
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.long
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
.map {
Extension.Available(
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
lang = it.lang,
isNsfw = it.nsfw == 1,
apkName = it.apk,
iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}"
)
}
}
@ -83,3 +78,14 @@ internal class ExtensionGithubApi {
}
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val version: String,
val code: Long,
val lang: String,
val nsfw: Int,
)

View File

@ -0,0 +1,170 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import androidx.annotation.CallSuper
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep
import uy.kohesive.injekt.injectLazy
import java.util.Collections
import java.util.concurrent.atomic.AtomicReference
/**
* Base implementation class for extension installer. To be used inside a foreground [Service].
*/
abstract class Installer(private val service: Service) {
private val extensionManager: ExtensionManager by injectLazy()
private var waitingInstall = AtomicReference<Entry>(null)
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
private val cancelReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
cancelQueue(downloadId)
}
}
/**
* Installer readiness. If false, queue check will not run.
*
* @see checkQueue
*/
abstract var ready: Boolean
/**
* Add an item to install queue.
*
* @param downloadId Download ID as known by [ExtensionManager]
* @param uri Uri of APK to install
*/
fun addToQueue(downloadId: Long, uri: Uri) {
queue.add(Entry(downloadId, uri))
checkQueue()
}
/**
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
* when the install process for this entry is finished to continue the queue.
*
* @param entry The [Entry] of item to process
* @see continueQueue
*/
@CallSuper
open fun processEntry(entry: Entry) {
extensionManager.setInstalling(entry.downloadId)
}
/**
* Called before queue continues. Override this to handle when the removed entry is
* currently being processed.
*
* @return true if this entry can be removed from queue.
*/
open fun cancelEntry(entry: Entry): Boolean {
return true
}
/**
* Tells the queue to continue processing the next entry and updates the install step
* of the completed entry ([waitingInstall]) to [ExtensionManager].
*
* @param resultStep new install step for the processed entry.
* @see waitingInstall
*/
fun continueQueue(resultStep: InstallStep) {
val completedEntry = waitingInstall.getAndSet(null)
if (completedEntry != null) {
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
checkQueue()
}
}
/**
* Checks the queue. The provided service will be stopped if the queue is empty.
* Will not be run when not ready.
*
* @see ready
*/
fun checkQueue() {
if (!ready) {
return
}
if (queue.isEmpty()) {
service.stopSelf()
return
}
val nextEntry = queue.first()
if (waitingInstall.compareAndSet(null, nextEntry)) {
queue.removeFirst()
processEntry(nextEntry)
}
}
/**
* Call this method when the provided service is destroyed.
*/
@CallSuper
open fun onDestroy() {
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
queue.clear()
waitingInstall.set(null)
}
protected fun getActiveEntry(): Entry? = waitingInstall.get()
/**
* Cancels queue for the provided download ID if exists.
*
* @param downloadId Download ID as known by [ExtensionManager]
*/
private fun cancelQueue(downloadId: Long) {
val waitingInstall = this.waitingInstall.get()
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
if (cancelEntry(toCancel)) {
queue.remove(toCancel)
if (waitingInstall == toCancel) {
// Currently processing removed entry, continue queue
this.waitingInstall.set(null)
checkQueue()
}
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
}
}
/**
* Install item to queue.
*
* @param downloadId Download ID as known by [ExtensionManager]
* @param uri Uri of APK to install
*/
data class Entry(val downloadId: Long, val uri: Uri)
init {
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
}
companion object {
private const val ACTION_CANCEL_QUEUE = "Installer.action.CANCEL_QUEUE"
private const val EXTRA_DOWNLOAD_ID = "Installer.extra.DOWNLOAD_ID"
/**
* Attempts to cancel the installation entry for the provided download ID.
*
* @param downloadId Download ID as known by [ExtensionManager]
*/
fun cancelInstallQueue(context: Context, downloadId: Long) {
val intent = Intent(ACTION_CANCEL_QUEUE)
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
}
}

View File

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.lang.use
import eu.kanade.tachiyomi.util.system.getUriSize
import timber.log.Timber
class PackageInstallerInstaller(private val service: Service) : Installer(service) {
private val packageInstaller = service.packageManager.packageInstaller
private val packageActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (userAction == null) {
Timber.e("Fatal error for $intent")
continueQueue(InstallStep.Error)
return
}
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
service.startActivity(userAction)
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
continueQueue(InstallStep.Idle)
}
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
else -> continueQueue(InstallStep.Error)
}
}
}
private var activeSession: Pair<Entry, Int>? = null
// Always ready
override var ready = true
override fun processEntry(entry: Entry) {
super.processEntry(entry)
activeSession = null
try {
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
activeSession = entry to packageInstaller.createSession(installParams)
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
installParams.setSize(fileSize)
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
val session = packageInstaller.openSession(activeSession!!.second)
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
session.use {
arrayOf(inputStream, outputStream).use {
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
val intentSender = PendingIntent.getBroadcast(
service,
activeSession!!.second,
Intent(INSTALL_ACTION),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
).intentSender
session.commit(intentSender)
}
} catch (e: Exception) {
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
activeSession?.let { (_, sessionId) ->
packageInstaller.abandonSession(sessionId)
}
continueQueue(InstallStep.Error)
}
}
override fun cancelEntry(entry: Entry): Boolean {
activeSession?.let { (activeEntry, sessionId) ->
if (activeEntry == entry) {
packageInstaller.abandonSession(sessionId)
return false
}
}
return true
}
override fun onDestroy() {
service.unregisterReceiver(packageActionReceiver)
super.onDestroy()
}
init {
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
}
}
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"

View File

@ -0,0 +1,127 @@
package eu.kanade.tachiyomi.extension.installer
import android.app.Service
import android.content.pm.PackageManager
import android.os.Build
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.getUriSize
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStream
class ShizukuInstaller(private val service: Service) : Installer(service) {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
Timber.e("Shizuku was killed prematurely")
service.stopSelf()
}
private val shizukuPermissionListener = object : Shizuku.OnRequestPermissionResultListener {
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
if (grantResult == PackageManager.PERMISSION_GRANTED) {
ready = true
checkQueue()
} else {
service.stopSelf()
}
Shizuku.removeRequestPermissionResultListener(this)
}
}
}
override var ready = false
@Suppress("BlockingMethodInNonBlockingContext")
override fun processEntry(entry: Entry) {
super.processEntry(entry)
ioScope.launch {
var sessionId: String? = null
try {
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
service.contentResolver.openInputStream(entry.uri)!!.use {
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"pm install-create --user current -i ${service.packageName} -S $size"
} else {
"pm install-create -i ${service.packageName} -S $size"
}
val createResult = exec(createCommand)
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
?: throw RuntimeException("Failed to create install session")
val writeResult = exec("pm install-write -S $size $sessionId base -", it)
if (writeResult.resultCode != 0) {
throw RuntimeException("Failed to write APK to session $sessionId")
}
val commitResult = exec("pm install-commit $sessionId")
if (commitResult.resultCode != 0) {
throw RuntimeException("Failed to commit install session $sessionId")
}
continueQueue(InstallStep.Installed)
}
} catch (e: Exception) {
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
if (sessionId != null) {
exec("pm install-abandon $sessionId")
}
continueQueue(InstallStep.Error)
}
}
}
// Don't cancel if entry is already started installing
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
override fun onDestroy() {
Shizuku.removeBinderDeadListener(shizukuDeadListener)
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
ioScope.cancel()
super.onDestroy()
}
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
@Suppress("DEPRECATION")
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
if (stdin != null) {
process.outputStream.use { stdin.copyTo(it) }
}
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
val resultCode = process.waitFor()
return ShellResult(resultCode, output)
}
private data class ShellResult(val resultCode: Int, val out: String)
init {
Shizuku.addBinderDeadListener(shizukuDeadListener)
ready = if (Shizuku.pingBinder()) {
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
true
} else {
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
false
}
} else {
Timber.e("Shizuku is not ready to use.")
service.toast(R.string.ext_installer_shizuku_stopped)
service.stopSelf()
false
}
}
}
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.extension.model
enum class InstallStep {
Pending, Downloading, Installing, Installed, Error;
Idle, Pending, Downloading, Installing, Installed, Error;
fun isCompleted(): Boolean {
return this == Installed || this == Error
return this == Installed || this == Error || this == Idle
}
}

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.toast
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -40,10 +41,13 @@ class ExtensionInstallActivity : Activity() {
private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val success = resultCode == RESULT_OK
val extensionManager = Injekt.get<ExtensionManager>()
extensionManager.setInstallationResult(downloadId, success)
val newStep = when (resultCode) {
RESULT_OK -> InstallStep.Installed
RESULT_CANCELED -> InstallStep.Idle
else -> InstallStep.Error
}
extensionManager.updateInstallStep(downloadId, newStep)
}
}

View File

@ -0,0 +1,82 @@
package eu.kanade.tachiyomi.extension.util
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.IBinder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.installer.PackageInstallerInstaller
import eu.kanade.tachiyomi.extension.installer.ShizukuInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
import eu.kanade.tachiyomi.util.system.notificationBuilder
import timber.log.Timber
class ExtensionInstallService : Service() {
private var installer: Installer? = null
override fun onCreate() {
super.onCreate()
val notification = notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
setSmallIcon(R.drawable.ic_tachi)
setAutoCancel(false)
setOngoing(true)
setShowWhen(false)
setContentTitle(getString(R.string.ext_install_service_notif))
setProgress(100, 100, true)
}.build()
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val uri = intent?.data
val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
val installerUsed = intent?.getSerializableExtra(EXTRA_INSTALLER) as? PreferenceValues.ExtensionInstaller
if (uri == null || id == null || installerUsed == null) {
stopSelf()
return START_NOT_STICKY
}
if (installer == null) {
installer = when (installerUsed) {
PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
PreferenceValues.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
else -> {
Timber.e("Not implemented for installer $installerUsed")
stopSelf()
return START_NOT_STICKY
}
}
}
installer!!.addToQueue(id, uri)
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
installer?.onDestroy()
installer = null
}
override fun onBind(i: Intent?): IBinder? = null
companion object {
private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
fun getIntent(
context: Context,
downloadId: Long,
uri: Uri,
installer: PreferenceValues.ExtensionInstaller
): Intent {
return Intent(context, ExtensionInstallService::class.java)
.setDataAndType(uri, ExtensionInstaller.APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.putExtra(EXTRA_INSTALLER, installer)
}
}
}

View File

@ -7,15 +7,21 @@ import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.util.concurrent.TimeUnit
@ -47,6 +53,8 @@ internal class ExtensionInstaller(private val context: Context) {
*/
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
private val installerPref = Injekt.get<PreferencesHelper>().extensionInstaller()
/**
* Adds the given extension to the downloads queue and returns an observable containing its
* step in the installation process.
@ -79,8 +87,6 @@ internal class ExtensionInstaller(private val context: Context) {
.map { it.second }
// Poll download status
.mergeWith(pollStatus(id))
// Force an error if the download takes more than 3 minutes
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
// Stop when the application is installed or errors
.takeUntil { it.isCompleted() }
// Always notify on main thread
@ -126,12 +132,29 @@ internal class ExtensionInstaller(private val context: Context) {
* @param uri The uri of the extension to install.
*/
fun installApk(downloadId: Long, uri: Uri) {
val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
when (val installer = installerPref.get()) {
PreferenceValues.ExtensionInstaller.LEGACY -> {
val intent = Intent(context, ExtensionInstallActivity::class.java)
.setDataAndType(uri, APK_MIME)
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(intent)
context.startActivity(intent)
}
else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
ContextCompat.startForegroundService(context, intent)
}
}
}
/**
* Cancels extension install and remove from download manager and installer.
*/
fun cancelInstall(pkgName: String) {
val downloadId = activeDownloads.remove(pkgName) ?: return
downloadManager.remove(downloadId)
Installer.cancelInstallQueue(context, downloadId)
}
/**
@ -147,13 +170,12 @@ internal class ExtensionInstaller(private val context: Context) {
}
/**
* Sets the result of the installation of an extension.
* Sets the step of the installation of an extension.
*
* @param downloadId The id of the download.
* @param result Whether the extension was installed or not.
* @param step New install step.
*/
fun setInstallationResult(downloadId: Long, result: Boolean) {
val step = if (result) InstallStep.Installed else InstallStep.Error
fun updateInstallStep(downloadId: Long, step: InstallStep) {
downloadsRelay.call(downloadId to step)
}
@ -216,9 +238,7 @@ internal class ExtensionInstaller(private val context: Context) {
val uri = downloadManager.getUriForDownloadedFile(id)
// Set next installation step
if (uri != null) {
downloadsRelay.call(id to InstallStep.Installing)
} else {
if (uri == null) {
Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error)
return

View File

@ -6,7 +6,6 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.annotations.Nsfw
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
@ -154,13 +153,7 @@ internal object ExtensionLoader {
try {
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
is Source -> listOf(obj)
is SourceFactory -> {
if (isSourceNsfw(obj)) {
emptyList()
} else {
obj.createSources()
}
}
is SourceFactory -> obj.createSources()
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
}
} catch (e: Throwable) {
@ -168,7 +161,6 @@ internal object ExtensionLoader {
return LoadResult.Error(e)
}
}
.filter { !isSourceNsfw(it) }
val langs = sources.filterIsInstance<CatalogueSource>()
.map { it.lang }
@ -215,22 +207,4 @@ internal object ExtensionLoader {
null
}
}
/**
* Checks whether a Source or SourceFactory is annotated with @Nsfw.
*/
private fun isSourceNsfw(clazz: Any): Boolean {
if (loadNsfwSource) {
return false
}
if (clazz !is Source && clazz !is SourceFactory) {
return false
}
// Annotations are proxied, hence this janky way of checking for them
return clazz.javaClass.annotations
.flatMap { it.javaClass.interfaces.map { it.simpleName } }
.firstOrNull { it == Nsfw::class.java.simpleName } != null
}
}

View File

@ -271,18 +271,13 @@ class LocalSource(private val context: Context) : CatalogueSource {
throw Exception(context.getString(R.string.chapter_not_found))
}
private fun getFormat(file: File): Format {
val extension = file.extension
return if (file.isDirectory) {
Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
Format.Zip(file)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Epub(file)
} else {
throw Exception(context.getString(R.string.local_invalid_format))
private fun getFormat(file: File) = with(file) {
when {
isDirectory -> Format.Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
extension.equals("epub", true) -> Format.Epub(this)
else -> throw Exception(context.getString(R.string.local_invalid_format))
}
}

View File

@ -11,7 +11,6 @@ import rx.Observable
open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init {

View File

@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
interface OnButtonClickListener {
fun onButtonClick(position: Int)
fun onCancelButtonClick(position: Int)
}
}

View File

@ -119,6 +119,11 @@ open class ExtensionController :
}
}
override fun onCancelButtonClick(position: Int) {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
presenter.cancelInstallUpdateExtension(extension)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.browse_extensions, menu)

View File

@ -1,31 +1,28 @@
package eu.kanade.tachiyomi.ui.browse.extension
import android.view.View
import androidx.core.view.isVisible
import coil.clear
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ExtensionCardItemBinding
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.system.LocaleHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = ExtensionCardItemBinding.bind(view)
private val shouldLabelNsfw by lazy {
Injekt.get<PreferencesHelper>().labelNsfwExtension()
}
init {
binding.extButton.setOnClickListener {
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
}
binding.cancelButton.setOnClickListener {
adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
}
}
fun bind(item: ExtensionItem) {
@ -38,7 +35,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted)
extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete)
extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
else -> ""
}.uppercase()
@ -48,44 +45,40 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
} else {
extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) }
}
bindButton(item)
bindButtons(item)
}
@Suppress("ResourceType")
fun bindButton(item: ExtensionItem) = with(binding.extButton) {
isEnabled = true
isClickable = true
fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
val extension = item.extension
val installStep = item.installStep
if (installStep != null) {
setText(
when (installStep) {
InstallStep.Pending -> R.string.ext_pending
InstallStep.Downloading -> R.string.ext_downloading
InstallStep.Installing -> R.string.ext_installing
InstallStep.Installed -> R.string.ext_installed
InstallStep.Error -> R.string.action_retry
}
)
if (installStep != InstallStep.Error) {
isEnabled = false
isClickable = false
}
} else if (extension is Extension.Installed) {
when {
extension.hasUpdate -> {
setText(R.string.ext_update)
}
else -> {
setText(R.string.action_settings)
setText(
when (installStep) {
InstallStep.Pending -> R.string.ext_pending
InstallStep.Downloading -> R.string.ext_downloading
InstallStep.Installing -> R.string.ext_installing
InstallStep.Installed -> R.string.ext_installed
InstallStep.Error -> R.string.action_retry
InstallStep.Idle -> {
when (extension) {
is Extension.Installed -> {
if (extension.hasUpdate) {
R.string.ext_update
} else {
R.string.action_settings
}
}
is Extension.Untrusted -> R.string.ext_trust
is Extension.Available -> R.string.ext_install
}
}
}
} else if (extension is Extension.Untrusted) {
setText(R.string.ext_trust)
} else {
setText(R.string.ext_install)
}
)
val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
binding.cancelButton.isVisible = !isIdle
isEnabled = isIdle
isClickable = isIdle
}
}

View File

@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
data class ExtensionItem(
val extension: Extension,
val header: ExtensionGroupItem? = null,
val installStep: InstallStep? = null
val installStep: InstallStep = InstallStep.Idle
) :
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
@ -49,7 +49,7 @@ data class ExtensionItem(
if (payloads == null || payloads.isEmpty()) {
holder.bind(this)
} else {
holder.bindButton(this)
holder.bindButtons(this)
}
}

View File

@ -55,14 +55,14 @@ open class ExtensionPresenter(
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
val context = Injekt.get<Application>()
val activeLangs = preferences.enabledLanguages().get()
val showNsfwExtensions = preferences.showNsfwExtension().get()
val showNsfwSources = preferences.showNsfwSource().get()
val (installed, untrusted, available) = tuple
val items = mutableListOf<ExtensionItem>()
val updatesSorted = installed.filter { it.hasUpdate && (showNsfwExtensions || !it.isNsfw) }.sortedBy { it.name }
val installedSorted = installed.filter { !it.hasUpdate && (showNsfwExtensions || !it.isNsfw) }.sortedWith(compareBy({ !it.isObsolete }, { it.name }))
val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }.sortedBy { it.name }
val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }.sortedWith(compareBy({ !it.isObsolete }, { it.name }))
val untrustedSorted = untrusted.sortedBy { it.name }
val availableSorted = available
// Filter out already installed extensions and disabled languages
@ -70,21 +70,21 @@ open class ExtensionPresenter(
installed.none { it.pkgName == avail.pkgName } &&
untrusted.none { it.pkgName == avail.pkgName } &&
(avail.lang in activeLangs || avail.lang == "all") &&
(showNsfwExtensions || !avail.isNsfw)
(showNsfwSources || !avail.isNsfw)
}
.sortedBy { it.name }
if (updatesSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
items += updatesSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
}
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
items += untrustedSorted.map { extension ->
@ -100,7 +100,7 @@ open class ExtensionPresenter(
.forEach {
val header = ExtensionGroupItem(it.key, it.value.size)
items += it.value.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
}
}
}
@ -133,6 +133,10 @@ open class ExtensionPresenter(
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
}
fun cancelInstallUpdateExtension(extension: Extension) {
extensionManager.cancelInstallUpdateExtension(extension)
}
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
this.doOnNext { currentDownloads[extension.pkgName] = it }
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }

View File

@ -2,10 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.extension.details
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.Menu
@ -14,7 +11,6 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.os.bundleOf
import androidx.preference.Preference
import androidx.preference.PreferenceGroupAdapter
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen
@ -68,7 +64,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
}
override fun createPresenter(): ExtensionDetailsPresenter {
return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!)
return ExtensionDetailsPresenter(this, args.getString(PKGNAME_KEY)!!)
}
override fun getTitle(): String? {
@ -106,72 +102,87 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1
with(screen) {
extension.sources
.groupBy { (it as CatalogueSource).lang }
.toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) })
.forEach {
val preferenceBlock = {
it.value
.sortedWith(compareBy({ !it.isEnabled() }, { it.name.lowercase() }))
.forEach { source ->
val sourcePrefs = mutableListOf<Preference>()
val block: (@DSL SwitchPreferenceCompat).() -> Unit = {
key = source.getPreferenceKey()
title = when {
isMultiSource && !isMultiLangSingleSource -> source.toString()
else -> LocaleHelper.getSourceDisplayName(it.key, context)
}
isPersistent = false
isChecked = source.isEnabled()
onChange { newValue ->
val checked = newValue as Boolean
toggleSource(source, checked)
true
}
// React to enable/disable all changes
preferences.disabledSources().asFlow()
.onEach {
val enabled = source.isEnabled()
isChecked = enabled
sourcePrefs.forEach { pref -> pref.isVisible = enabled }
}
.launchIn(viewScope)
}
// Source enable/disable
if (source is ConfigurableSource) {
switchSettingsPreference {
block()
onSettingsClick = View.OnClickListener {
router.pushController(
SourcePreferencesController(source.id).withFadeTransaction()
)
}
}
} else {
switchPreference(block)
}
}
}
if (isMultiSource && !isMultiLangSingleSource) {
preferenceCategory {
title = LocaleHelper.getSourceDisplayName(it.key, context)
preferenceBlock()
}
} else {
preferenceBlock()
}
}
if (isMultiSource && isMultiLangSingleSource.not()) {
multiLanguagePreference(context, extension.sources)
} else {
singleLanguagePreference(context, extension.sources)
}
}
return PreferenceGroupAdapter(screen)
}
private fun PreferenceScreen.singleLanguagePreference(context: Context, sources: List<Source>) {
sources
.map { source -> LocaleHelper.getSourceDisplayName(source.lang, context) to source }
.sortedWith(compareBy({ (_, source) -> !source.isEnabled() }, { (lang, _) -> lang.lowercase() }))
.forEach { (lang, source) ->
val preferenceBlock = {
sourceSwitchPreference(source, LocaleHelper.getSourceDisplayName(lang, context))
}
preferenceBlock()
}
}
private fun PreferenceScreen.multiLanguagePreference(context: Context, sources: List<Source>) {
sources
.groupBy { (it as CatalogueSource).lang }
.toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) })
.forEach { entry ->
val preferenceBlock = {
entry.value
.sortedWith(compareBy({ source -> !source.isEnabled() }, { source -> source.name.lowercase() }))
.forEach { source ->
sourceSwitchPreference(source, source.toString())
}
}
preferenceCategory {
title = LocaleHelper.getSourceDisplayName(entry.key, context)
preferenceBlock()
}
}
}
private fun PreferenceScreen.sourceSwitchPreference(source: Source, name: String) {
val block: (@DSL SwitchPreferenceCompat).() -> Unit = {
key = source.getPreferenceKey()
title = name
isPersistent = false
isChecked = source.isEnabled()
onChange { newValue ->
val checked = newValue as Boolean
toggleSource(source, checked)
true
}
// React to enable/disable all changes
preferences.disabledSources().asFlow()
.onEach {
val enabled = source.isEnabled()
isChecked = enabled
}
.launchIn(viewScope)
}
// Source enable/disable
if (source is ConfigurableSource) {
switchSettingsPreference {
block()
onSettingsClick = View.OnClickListener {
router.pushController(
SourcePreferencesController(source.id).withFadeTransaction()
)
}
}
} else {
switchPreference(block)
}
}
override fun onDestroyView(view: View) {
preferenceScreen = null
super.onDestroyView(view)
@ -188,7 +199,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
R.id.action_history -> openCommitHistory()
R.id.action_enable_all -> toggleAllSources(true)
R.id.action_disable_all -> toggleAllSources(false)
R.id.action_open_in_settings -> openInSettings()
}
return super.onOptionsItemSelected(item)
}
@ -219,13 +229,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
openInBrowser(url)
}
private fun openInSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", presenter.pkgName, null)
}
startActivity(intent)
}
private fun Source.isEnabled(): Boolean {
return id.toString() !in preferences.disabledSources().get()
}

View File

@ -44,6 +44,9 @@ class ExtensionDetailsHeaderAdapter(private val presenter: ExtensionDetailsPrese
binding.btnUninstall.clicks()
.onEach { presenter.uninstallExtension() }
.launchIn(presenter.presenterScope)
binding.btnAppInfo.clicks()
.onEach { presenter.openInSettings() }
.launchIn(presenter.presenterScope)
if (extension.isObsolete) {
binding.warningBanner.isVisible = true

View File

@ -1,17 +1,21 @@
package eu.kanade.tachiyomi.ui.browse.extension.details
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class ExtensionDetailsPresenter(
val pkgName: String,
private val extensionManager: ExtensionManager = Injekt.get()
private val controller: ExtensionDetailsController,
private val pkgName: String,
) : BasePresenter<ExtensionDetailsController>() {
private val extensionManager: ExtensionManager by injectLazy()
val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName }
override fun onCreate(savedState: Bundle?) {
@ -36,4 +40,11 @@ class ExtensionDetailsPresenter(
val extension = extension ?: return
extensionManager.uninstallExtension(extension.pkgName)
}
fun openInSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", pkgName, null)
}
controller.startActivity(intent)
}
}

View File

@ -9,16 +9,20 @@ import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MigrationSourcesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrationMangaController
import eu.kanade.tachiyomi.util.system.openInBrowser
import uy.kohesive.injekt.injectLazy
class MigrationSourcesController :
NucleusController<MigrationSourcesControllerBinding, MigrationSourcesPresenter>(),
FlexibleAdapter.OnItemClickListener {
private val preferences: PreferencesHelper by injectLazy()
private var adapter: SourceAdapter? = null
init {
@ -56,13 +60,39 @@ class MigrationSourcesController :
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
when (val itemId = item.itemId) {
R.id.action_source_migration_help -> activity?.openInBrowser(HELP_URL)
R.id.asc_alphabetical, R.id.desc_alphabetical -> {
setSortingDirection(SortSetting.ALPHABETICAL, itemId == R.id.asc_alphabetical)
}
R.id.asc_count, R.id.desc_count -> {
setSortingDirection(SortSetting.TOTAL, itemId == R.id.asc_count)
}
}
return super.onOptionsItemSelected(item)
}
private fun setSortingDirection(sortSetting: SortSetting, isAscending: Boolean) {
val direction = if (isAscending) {
DirectionSetting.ASCENDING
} else {
DirectionSetting.DESCENDING
}
preferences.migrationSortingDirection().set(direction)
preferences.migrationSortingMode().set(sortSetting)
presenter.requestSortUpdate()
}
fun setSources(sourcesWithManga: List<SourceItem>) {
// Show empty view if needed
if (sourcesWithManga.isNotEmpty()) {
binding.emptyView.hide()
} else {
binding.emptyView.show(R.string.information_empty_library)
}
adapter?.updateDataSet(sourcesWithManga)
}
@ -72,6 +102,16 @@ class MigrationSourcesController :
parentController!!.router.pushController(controller.withFadeTransaction())
return false
}
enum class DirectionSetting {
ASCENDING,
DESCENDING;
}
enum class SortSetting {
ALPHABETICAL,
TOTAL;
}
}
private const val HELP_URL = "https://tachiyomi.org/help/guides/source-migration/"

View File

@ -1,25 +1,38 @@
package eu.kanade.tachiyomi.ui.browse.migration.sources
import android.os.Bundle
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.combineLatest
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.Collator
import java.util.Collections
import java.util.Locale
class MigrationSourcesPresenter(
private val sourceManager: SourceManager = Injekt.get(),
private val db: DatabaseHelper = Injekt.get()
) : BasePresenter<MigrationSourcesController>() {
private val preferences: PreferencesHelper by injectLazy()
private val sortRelay = BehaviorRelay.create(Unit)
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
db.getFavoriteMangas()
.asRxObservable()
.combineLatest(sortRelay.observeOn(Schedulers.io())) { sources, _ -> sources }
.observeOn(AndroidSchedulers.mainThread())
.map { findSourcesWithManga(it) }
.subscribeLatestCache(MigrationSourcesController::setSources)
@ -34,7 +47,36 @@ class MigrationSourcesPresenter(
val source = sourceManager.getOrStub(it.key)
SourceItem(source, it.value.size, header)
}
.sortedBy { it.source.name.lowercase() }
.sortedWith(sortFn())
.toList()
}
fun sortFn(): java.util.Comparator<SourceItem> {
val sort by lazy {
preferences.migrationSortingMode().get()
}
val direction by lazy {
preferences.migrationSortingDirection().get()
}
val locale = Locale.getDefault()
val collator = Collator.getInstance(locale).apply {
strength = Collator.PRIMARY
}
val sortFn: (SourceItem, SourceItem) -> Int = { a, b ->
when (sort) {
MigrationSourcesController.SortSetting.ALPHABETICAL -> collator.compare(a.source.name.lowercase(locale), b.source.name.lowercase(locale))
MigrationSourcesController.SortSetting.TOTAL -> a.mangaCount.compareTo(b.mangaCount)
}
}
return when (direction) {
MigrationSourcesController.DirectionSetting.ASCENDING -> Comparator(sortFn)
MigrationSourcesController.DirectionSetting.DESCENDING -> Collections.reverseOrder(sortFn)
}
}
fun requestSortUpdate() {
sortRelay.call(Unit)
}
}

View File

@ -195,7 +195,7 @@ class CategoryController :
(activity as? MainActivity)?.binding?.rootCoordinator!!,
R.string.snack_categories_deleted,
R.string.action_undo,
3000
4000
)
mode.finish()

View File

@ -6,6 +6,7 @@ import android.view.ViewGroup
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -45,15 +46,18 @@ class LibraryAdapter(
private var boundViews = arrayListOf<View>()
private val isPerCategory by lazy { preferences.categorisedDisplaySettings().get() }
private val currentDisplayMode by lazy { preferences.libraryDisplayMode().get() }
/**
* Creates a new view for this adapter.
*
* @return a new view.
*/
override fun createView(container: ViewGroup): View {
override fun inflateView(container: ViewGroup, viewType: Int): View {
val binding = LibraryCategoryBinding.inflate(LayoutInflater.from(container.context), container, false)
val view: LibraryCategoryView = binding.root
view.onCreate(controller, binding)
view.onCreate(controller, binding, viewType)
return view
}
@ -120,4 +124,26 @@ class LibraryAdapter(
}
}
}
override fun getViewType(position: Int): Int {
val category = categories[position]
return if (isPerCategory && category.id != 0) {
if (DisplayModeSetting.fromFlag(category.displayMode) == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
} else {
if (currentDisplayMode == DisplayModeSetting.LIST) {
LIST_DISPLAY_MODE
} else {
GRID_DISPLAY_MODE
}
}
}
companion object {
const val LIST_DISPLAY_MODE = 1
const val GRID_DISPLAY_MODE = 2
}
}

View File

@ -5,14 +5,14 @@ import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import dev.chrisbanes.insetter.Insetter
import dev.chrisbanes.insetter.windowInsetTypesOf
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.LibraryCategoryBinding
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.lang.plusAssign
@ -27,9 +27,7 @@ import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
import java.util.ArrayDeque
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting as DisplayMode
/**
* Fragment containing the library manga for a certain category.
@ -41,8 +39,6 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
private val scope = MainScope()
private val preferences: PreferencesHelper by injectLazy()
/**
* The fragment containing this view.
*/
@ -71,12 +67,10 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
private var lastClickPositionStack = ArrayDeque(listOf(-1))
fun onCreate(controller: LibraryController, binding: LibraryCategoryBinding) {
fun onCreate(controller: LibraryController, binding: LibraryCategoryBinding, viewType: Int) {
this.controller = controller
recycler = if (preferences.libraryDisplayMode().get() == DisplayMode.LIST &&
!preferences.categorisedDisplaySettings().get()
) {
recycler = if (viewType == LibraryAdapter.LIST_DISPLAY_MODE) {
(binding.swipeRefresh.inflate(R.layout.library_list_recycler) as AutofitRecyclerView).apply {
spanCount = 1
}
@ -86,11 +80,9 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
}
}
recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
Insetter.builder()
.paddingBottom(windowInsetTypesOf(navigationBars = true))
.applyToView(recycler)
adapter = LibraryCategoryAdapter(this)
@ -129,15 +121,6 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
fun onBind(category: Category) {
this.category = category
// If displayMode should be set from category adjust manga count per row
if (preferences.categorisedDisplaySettings().get()) {
recycler.spanCount = if (DisplayMode.fromFlag(category.displayMode) == DisplayMode.LIST || (preferences.libraryDisplayMode().get() == DisplayMode.LIST && category.id == 0)) {
1
} else {
controller.mangaPerRow
}
}
adapter.mode = if (controller.selectedMangas.isNotEmpty()) {
SelectableAdapter.Mode.MULTI
} else {

View File

@ -42,9 +42,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import reactivecircus.flowbinding.viewpager.pageSelections
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class LibraryController(
bundle: Bundle? = null,
@ -199,10 +202,12 @@ class LibraryController(
is LibrarySettingsSheet.Filter.FilterGroup -> onFilterChanged()
is LibrarySettingsSheet.Sort.SortGroup -> onSortChanged()
is LibrarySettingsSheet.Display.DisplayGroup -> {
if (!preferences.categorisedDisplaySettings().get() || activeCategory == 0) {
// Reattach adapter when flow preference change
reattachAdapter()
}
val delay = if (preferences.categorisedDisplaySettings().get()) 125L else 0L
Observable.timer(delay, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe {
reattachAdapter()
}
}
is LibrarySettingsSheet.Display.BadgeGroup -> onBadgeSettingChanged()
is LibrarySettingsSheet.Display.TabsGroup -> onTabsSettingsChanged()
@ -299,11 +304,6 @@ class LibraryController(
.map { (it.id ?: -1) to (mangaMap[it.id]?.size ?: 0) }
.toMap()
if (preferences.categorisedDisplaySettings().get()) {
// Reattach adapter so it doesn't get de-synced
reattachAdapter()
}
// Restore active category.
binding.libraryPager.setCurrentItem(activeCat, false)
@ -383,7 +383,7 @@ class LibraryController(
actionMode!!,
R.menu.library_selection
) { onActionItemClicked(it!!) }
(activity as? MainActivity)?.showBottomNav(visible = false, expand = true)
(activity as? MainActivity)?.showBottomNav(false)
}
}
@ -492,7 +492,7 @@ class LibraryController(
selectionRelay.call(LibrarySelectionEvent.Cleared())
binding.actionToolbar.hide()
(activity as? MainActivity)?.showBottomNav(visible = true, expand = true)
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
}

View File

@ -10,7 +10,6 @@ import android.view.Gravity
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -64,7 +63,6 @@ import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat
import eu.kanade.tachiyomi.widget.HideBottomNavigationOnScrollBehavior
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
@ -86,8 +84,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
}
}
private var bottomNavAnimator: ViewHeightAnimator? = null
private var isConfirmingExit: Boolean = false
private var isHandlingShortcut: Boolean = false
@ -138,15 +134,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
}
setSplashScreenExitAnimation(splashScreen)
if (binding.bottomNav != null) {
bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!)
// Set behavior of bottom nav
preferences.hideBottomBarOnScroll()
.asImmediateFlow { setBottomNavBehaviorOnScroll() }
.launchIn(lifecycleScope)
}
if (binding.sideNav != null) {
preferences.sideNavIconAlignment()
.asImmediateFlow {
@ -532,11 +519,11 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
binding.appbar.setExpanded(true)
if ((from == null || from is RootController) && to !is RootController) {
showNav(visible = false, expand = true)
showNav(false)
}
if (to is RootController) {
// Always show bottom nav again when returning to a RootController
showNav(visible = true, expand = from !is RootController)
showNav(true)
}
if (from is TabbedController) {
@ -587,27 +574,22 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
}
}
private fun showNav(visible: Boolean, expand: Boolean = false) {
showBottomNav(visible, expand)
private fun showNav(visible: Boolean) {
showBottomNav(visible)
showSideNav(visible)
}
// Also used from some controllers to swap bottom nav with action toolbar
fun showBottomNav(visible: Boolean, expand: Boolean = false) {
fun showBottomNav(visible: Boolean) {
if (visible) {
binding.bottomNav?.translationY = 0F
if (expand) {
bottomNavAnimator?.expand()
}
binding.bottomNav?.slideUp()
} else {
bottomNavAnimator?.collapse()
binding.bottomNav?.slideDown()
}
}
private fun showSideNav(visible: Boolean) {
binding.sideNav?.let {
it.isVisible = visible
}
binding.sideNav?.isVisible = visible
}
/**
@ -622,18 +604,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
}
}
private fun setBottomNavBehaviorOnScroll() {
showNav(visible = true)
binding.bottomNav?.updateLayoutParams<CoordinatorLayout.LayoutParams> {
behavior = when {
preferences.hideBottomBarOnScroll().get() -> HideBottomNavigationOnScrollBehavior()
else -> null
}
}
binding.bottomNav?.translationY = 0F
}
private val nav: NavigationBarView
get() = binding.bottomNav ?: binding.sideNav!!

View File

@ -1,107 +0,0 @@
package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator
import android.view.View
import android.view.ViewTreeObserver
import android.view.animation.DecelerateInterpolator
import androidx.annotation.Keep
class ViewHeightAnimator(val view: View, val duration: Long = 250L) {
/**
* The default height of the view. It's unknown until the view is layout.
*/
private var height = 0
/**
* Whether the last state of the view is shown or hidden.
*/
private var isLastStateShown = true
/**
* Animation used to expand and collapse the view.
*/
private val animation by lazy {
ObjectAnimator.ofInt(this, "height", height).apply {
duration = this@ViewHeightAnimator.duration
interpolator = DecelerateInterpolator()
}
}
init {
view.viewTreeObserver.addOnGlobalLayoutListener(
object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (view.height > 0) {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
// Save the tabs default height.
height = view.height
// Now that we know the height, set the initial height.
if (isLastStateShown) {
setHeight(height)
} else {
setHeight(0)
}
}
}
}
)
}
/**
* Sets the height of the tab layout.
*
* @param newHeight The new height of the tab layout.
*/
@Keep
fun setHeight(newHeight: Int) {
view.layoutParams.height = newHeight
view.requestLayout()
}
/**
* Returns the height of the tab layout. This method is also called from the animator through
* reflection.
*/
fun getHeight(): Int {
return view.layoutParams.height
}
/**
* Expands the tab layout with an animation.
*/
fun expand() {
if (isMeasured) {
if (getHeight() != height) {
animation.setIntValues(height)
animation.start()
} else {
animation.cancel()
}
}
isLastStateShown = true
}
/**
* Collapse the tab layout with an animation.
*/
fun collapse() {
if (isMeasured) {
if (getHeight() != 0) {
animation.setIntValues(0)
animation.start()
} else {
animation.cancel()
}
}
isLastStateShown = false
}
/**
* Returns whether the tab layout has a known height.
*/
private val isMeasured: Boolean
get() = height > 0
}

View File

@ -53,6 +53,7 @@ import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
@ -92,10 +93,8 @@ import eu.kanade.tachiyomi.util.view.getCoordinates
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollEvents
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber
@ -179,7 +178,17 @@ class MangaController :
private var trackSheet: TrackSheet? = null
private var dialog: MangaFullCoverDialog? = null
private var dialog: DialogController? = null
/**
* For [recyclerViewUpdatesToolbarTitleAlpha]
*/
private var recyclerViewToolbarTitleAlphaUpdaterAdded = false
private val recyclerViewToolbarTitleAlphaUpdater = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateToolbarTitleAlpha()
}
}
init {
setHasOptionsMenu(true)
@ -191,15 +200,12 @@ class MangaController :
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
// Hide toolbar title on enter
if (type.isEnter) {
updateToolbarTitleAlpha()
} else if (!type.isPush) {
// Cancel listeners early
viewScope.cancel()
updateToolbarTitleAlpha(1F)
// No need to update alpha for cover dialog
if (dialog == null) {
updateToolbarTitleAlpha(if (type.isEnter) 0F else 1F)
}
recyclerViewUpdatesToolbarTitleAlpha(type.isEnter)
}
override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) {
@ -250,19 +256,15 @@ class MangaController :
binding.fullRecycler?.let {
it.adapter = ConcatAdapter(mangaInfoAdapter, chaptersHeaderAdapter, chaptersAdapter)
it.scrollEvents()
.onEach { updateToolbarTitleAlpha() }
.launchIn(viewScope)
// Skips directly to chapters list if navigated to from the library
it.post {
if (!fromSource && preferences.jumpToChapters()) {
(it.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(1, 0)
}
// Delayed in case we need to jump to chapters
it.post {
updateToolbarTitleAlpha()
val mainActivityAppBar = (activity as? MainActivity)?.binding?.appbar
(it.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
1,
mainActivityAppBar?.height ?: 0
)
mainActivityAppBar?.isLifted = true
}
}
@ -279,11 +281,6 @@ class MangaController :
scroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = getMainAppBarHeight()
}
scroller.applyInsetter {
type(navigationBars = true) {
margin()
}
}
}
binding.swipeRefresh.doOnLayout { swipeRefresh ->
@ -299,22 +296,10 @@ class MangaController :
}
}
}
// Tablet layout
binding.infoRecycler?.let {
it.adapter = mangaInfoAdapter
it.scrollEvents()
.onEach { updateToolbarTitleAlpha() }
.launchIn(viewScope)
// Delayed in case we need to jump to chapters
it.post {
updateToolbarTitleAlpha()
}
}
binding.chaptersRecycler?.let {
it.adapter = ConcatAdapter(chaptersHeaderAdapter, chaptersAdapter)
}
binding.infoRecycler?.adapter = mangaInfoAdapter
binding.chaptersRecycler?.adapter = ConcatAdapter(chaptersHeaderAdapter, chaptersAdapter)
chaptersAdapter?.fastScroller = binding.fastScroller
@ -339,6 +324,20 @@ class MangaController :
trackSheet = TrackSheet(this, manga!!, (activity as MainActivity).supportFragmentManager)
updateFilterIconState()
recyclerViewUpdatesToolbarTitleAlpha(true)
}
private fun recyclerViewUpdatesToolbarTitleAlpha(enable: Boolean) {
val recycler = binding.fullRecycler ?: binding.infoRecycler ?: return
if (enable) {
if (!recyclerViewToolbarTitleAlphaUpdaterAdded) {
recycler.addOnScrollListener(recyclerViewToolbarTitleAlphaUpdater)
recyclerViewToolbarTitleAlphaUpdaterAdded = true
}
} else if (recyclerViewToolbarTitleAlphaUpdaterAdded) {
recycler.removeOnScrollListener(recyclerViewToolbarTitleAlphaUpdater)
recyclerViewToolbarTitleAlphaUpdaterAdded = false
}
}
private fun updateToolbarTitleAlpha(@FloatRange(from = 0.0, to = 1.0) alpha: Float? = null) {
@ -399,6 +398,7 @@ class MangaController :
}
override fun onDestroyView(view: View) {
recyclerViewUpdatesToolbarTitleAlpha(false)
destroyActionModeIfNeeded()
binding.actionToolbar.destroy()
mangaInfoAdapter = null
@ -579,8 +579,7 @@ class MangaController :
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
showChangeCategoryDialog(manga, categories, preselected)
}
}
@ -608,6 +607,9 @@ class MangaController :
*/
private fun toggleFavorite() {
val isNowFavorite = presenter.toggleFavorite()
if (isNowFavorite) {
addSnackbar?.dismiss()
}
if (activity != null && !isNowFavorite && presenter.hasDownloads()) {
(activity as? MainActivity)?.binding?.rootCoordinator?.snack(activity!!.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
@ -615,7 +617,6 @@ class MangaController :
}
}
}
mangaInfoAdapter?.notifyDataSetChanged()
}
@ -631,8 +632,21 @@ class MangaController :
QuadStateTextView.State.UNCHECKED.ordinal
}
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
showChangeCategoryDialog(manga, categories, preselected)
}
private fun showChangeCategoryDialog(manga: Manga, categories: List<Category>, preselected: Array<Int>) {
if (dialog != null) return
dialog = ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
dialog?.addLifecycleListener(
object : LifecycleListener() {
override fun postDestroy(controller: Controller) {
super.postDestroy(controller)
dialog = null
}
}
)
dialog?.showDialog(router)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, addCategories: List<Category>, removeCategories: List<Category>) {
@ -816,7 +830,7 @@ class MangaController :
fun onSetCoverSuccess() {
mangaInfoAdapter?.notifyDataSetChanged()
dialog?.setImage(manga)
(dialog as? MangaFullCoverDialog)?.setImage(manga)
activity?.toast(R.string.cover_updated)
}
@ -1111,7 +1125,7 @@ class MangaController :
val manga = presenter.manga
presenter.downloadChapters(chapters)
if (view != null && !manga.favorite) {
addSnackbar = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
addSnackbar = (activity as? MainActivity)?.binding?.rootCoordinator?.snack(view.context.getString(R.string.snack_add_to_library)) {
setAction(R.string.action_add) {
if (!manga.favorite) {
addToLibrary(manga)

View File

@ -365,10 +365,13 @@ class MangaInfoHeaderAdapter(
}
}
private fun updateDescription(description: String?, isCurrentlyExpanded: Boolean): CharSequence? {
private fun updateDescription(description: String?, isCurrentlyExpanded: Boolean): CharSequence {
return when {
description.isNullOrBlank() -> view.context.getString(R.string.unknown)
isCurrentlyExpanded -> description.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
isCurrentlyExpanded ->
description
.replace(Regex(" +\$", setOf(RegexOption.MULTILINE)), "")
.replace(Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)), "\n")
else -> description
}
}

View File

@ -9,6 +9,8 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.LicensesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.openInBrowser
class LicensesController :
@ -30,15 +32,25 @@ class LicensesController :
padding()
}
}
binding.progress.applyInsetter {
type(navigationBars = true) {
padding()
}
}
binding.recycler.layoutManager = LinearLayoutManager(view.context)
adapter = LicensesAdapter(this)
binding.recycler.adapter = adapter
val licenseItems = Libs(view.context).libraries
.sortedBy { it.libraryName.lowercase() }
.map { LicensesItem(it) }
adapter?.updateDataSet(licenseItems)
viewScope.launchUI {
val licenseItems = withIOContext {
Libs(view.context).libraries
.sortedBy { it.libraryName.lowercase() }
.map { LicensesItem(it) }
}
binding.progress.hide()
adapter?.updateDataSet(licenseItems)
}
}
override fun onDestroyView(view: View) {

View File

@ -27,7 +27,6 @@ import android.view.WindowManager
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.FrameLayout
import android.widget.SeekBar
import android.widget.Toast
import androidx.core.graphics.ColorUtils
import androidx.core.view.WindowCompat
@ -39,6 +38,7 @@ import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.slider.Slider
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
@ -74,7 +74,6 @@ import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.popupMenu
import eu.kanade.tachiyomi.util.view.setTooltip
import eu.kanade.tachiyomi.widget.listener.SimpleAnimationListener
import eu.kanade.tachiyomi.widget.listener.SimpleSeekBarListener
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
@ -85,6 +84,7 @@ import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import kotlin.math.abs
import kotlin.math.max
/**
* Activity containing the reader of Tachiyomi. This activity is mostly a container of the
@ -328,26 +328,22 @@ class ReaderActivity : BaseRxActivity<ReaderActivityBinding, ReaderPresenter>()
}
// Init listeners on bottom menu
binding.pageSeekbar.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onStartTrackingTouch(seekBar: SeekBar) {
super.onStartTrackingTouch(seekBar)
isScrollingThroughPages = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
super.onStopTrackingTouch(seekBar)
isScrollingThroughPages = false
}
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (viewer != null && fromUser) {
moveToPageIndex(value)
binding.pageSeekbar.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
}
}
binding.pageSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
isScrollingThroughPages = true
}
)
override fun onStopTrackingTouch(slider: Slider) {
isScrollingThroughPages = false
}
})
binding.pageSlider.addOnChangeListener { slider, value, fromUser ->
if (viewer != null && fromUser) {
isScrollingThroughPages = true
moveToPageIndex(value.toInt())
slider.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
}
}
binding.leftChapter.setOnClickListener {
if (viewer != null) {
if (viewer is R2LPagerViewer) {
@ -600,7 +596,7 @@ class ReaderActivity : BaseRxActivity<ReaderActivityBinding, ReaderPresenter>()
binding.toolbar.title = manga.title
binding.pageSeekbar.isRTL = newViewer is R2LPagerViewer
binding.pageSlider.isRTL = newViewer is R2LPagerViewer
if (newViewer is R2LPagerViewer) {
binding.leftChapter.setTooltip(R.string.action_next_chapter)
binding.rightChapter.setTooltip(R.string.action_previous_chapter)
@ -724,7 +720,7 @@ class ReaderActivity : BaseRxActivity<ReaderActivityBinding, ReaderPresenter>()
// Set bottom page number
binding.pageNumber.text = "${page.number}/${pages.size}"
// Set seekbar page number
// Set page numbers
if (viewer !is R2LPagerViewer) {
binding.leftPageText.text = "${page.number}"
binding.rightPageText.text = "${pages.size}"
@ -733,9 +729,10 @@ class ReaderActivity : BaseRxActivity<ReaderActivityBinding, ReaderPresenter>()
binding.leftPageText.text = "${pages.size}"
}
// Set seekbar progress
binding.pageSeekbar.max = pages.lastIndex
binding.pageSeekbar.progress = page.index
// Set slider progress
binding.pageSlider.isEnabled = pages.size > 1
binding.pageSlider.valueTo = max(pages.lastIndex.toFloat(), 1f)
binding.pageSlider.value = page.index.toFloat()
}
/**

View File

@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatSeekBar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getThemeColor
/**
* Seekbar to show current chapter progress.
*/
class ReaderSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatSeekBar(context, attrs) {
/**
* Whether the seekbar should draw from right to left.
*/
var isRTL = false
/**
* Draws the seekbar, translating the canvas if using a right to left reader.
*/
override fun draw(canvas: Canvas) {
if (isRTL) {
val px = width / 2f
val py = height / 2f
canvas.scale(-1f, 1f, px, py)
}
super.draw(canvas)
}
/**
* Handles touch events, translating coordinates if using a right to left reader.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isRTL) {
event.setLocation(width - event.x, event.y)
}
return super.onTouchEvent(event)
}
init {
// Set color to onPrimary when ColoredBars theme is applied
if (context.getThemeColor(R.attr.colorToolbar) == context.getThemeColor(R.attr.colorPrimary)) {
thumbTintList = ColorStateList.valueOf(context.getThemeColor(R.attr.colorOnPrimary))
progressTintList = thumbTintList
}
}
}

View File

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.util.AttributeSet
import com.google.android.material.slider.Slider
/**
* Slider to show current chapter progress.
*/
class ReaderSlider @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : Slider(context, attrs) {
init {
isTickVisible = false
stepSize = 1f
setLabelFormatter { value ->
(value.toInt() + 1).toString()
}
}
/**
* Whether the slider should draw from right to left.
*/
var isRTL: Boolean
set(value) {
layoutDirection = if (value) LAYOUT_DIRECTION_RTL else LAYOUT_DIRECTION_LTR
}
get() = layoutDirection == LAYOUT_DIRECTION_RTL
}

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.SeekBar
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.blue
@ -15,7 +14,6 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ReaderColorFilterSettingsBinding
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.preference.bindToPreference
import eu.kanade.tachiyomi.widget.listener.SimpleSeekBarListener
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
@ -54,13 +52,13 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
// Set brightness value
binding.txtBrightnessSeekbarValue.text = brightness.toString()
binding.brightnessSeekbar.progress = brightness
binding.sliderBrightness.value = brightness.toFloat()
// Initialize seekBar progress
binding.seekbarColorFilterAlpha.progress = argb[0]
binding.seekbarColorFilterRed.progress = argb[1]
binding.seekbarColorFilterGreen.progress = argb[2]
binding.seekbarColorFilterBlue.progress = argb[3]
binding.sliderColorFilterAlpha.value = argb[0].toFloat()
binding.sliderColorFilterRed.value = argb[1].toFloat()
binding.sliderColorFilterGreen.value = argb[2].toFloat()
binding.sliderColorFilterBlue.value = argb[3].toFloat()
// Set listeners
binding.switchColorFilter.bindToPreference(preferences.colorFilter())
@ -69,55 +67,32 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
binding.grayscale.bindToPreference(preferences.grayscale())
binding.invertedColors.bindToPreference(preferences.invertedColors())
binding.seekbarColorFilterAlpha.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, ALPHA_MASK, 24)
}
}
binding.sliderColorFilterAlpha.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
setColorValue(value.toInt(), ALPHA_MASK, 24)
}
)
}
binding.sliderColorFilterRed.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
setColorValue(value.toInt(), RED_MASK, 16)
}
}
binding.sliderColorFilterGreen.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
setColorValue(value.toInt(), GREEN_MASK, 8)
}
}
binding.sliderColorFilterBlue.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
setColorValue(value.toInt(), BLUE_MASK, 0)
}
}
binding.seekbarColorFilterRed.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, RED_MASK, 16)
}
}
binding.sliderBrightness.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
preferences.customBrightnessValue().set(value.toInt())
}
)
binding.seekbarColorFilterGreen.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, GREEN_MASK, 8)
}
}
}
)
binding.seekbarColorFilterBlue.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
setColorValue(value, BLUE_MASK, 0)
}
}
}
)
binding.brightnessSeekbar.setOnSeekBarChangeListener(
object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
preferences.customBrightnessValue().set(value)
}
}
}
)
}
}
/**
@ -125,10 +100,10 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
* @param enabled determines if seekBar gets enabled
*/
private fun setColorFilterSeekBar(enabled: Boolean) {
binding.seekbarColorFilterRed.isEnabled = enabled
binding.seekbarColorFilterGreen.isEnabled = enabled
binding.seekbarColorFilterBlue.isEnabled = enabled
binding.seekbarColorFilterAlpha.isEnabled = enabled
binding.sliderColorFilterRed.isEnabled = enabled
binding.sliderColorFilterGreen.isEnabled = enabled
binding.sliderColorFilterBlue.isEnabled = enabled
binding.sliderColorFilterAlpha.isEnabled = enabled
}
/**
@ -136,14 +111,14 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
* @param enabled value which determines if seekBar gets enabled
*/
private fun setCustomBrightnessSeekBar(enabled: Boolean) {
binding.brightnessSeekbar.isEnabled = enabled
binding.sliderBrightness.isEnabled = enabled
}
/**
* Set the text value's of color filter
* @param color integer containing color information
*/
fun setValues(color: Int): Array<Int> {
private fun setValues(color: Int): Array<Int> {
val alpha = color.alpha
val red = color.red
val green = color.green
@ -214,21 +189,14 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
* @param mask contains hex mask of chosen color
* @param bitShift amounts of bits that gets shifted to receive value
*/
fun setColorValue(color: Int, mask: Long, bitShift: Int) {
private fun setColorValue(color: Int, mask: Long, bitShift: Int) {
val currentColor = preferences.colorFilterValue().get()
val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt())
preferences.colorFilterValue().set(updatedColor)
}
}
/** Integer mask of alpha value **/
private const val ALPHA_MASK: Long = 0xFF000000
/** Integer mask of red value **/
private const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
private const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
private const val BLUE_MASK: Long = 0x000000FF

View File

@ -13,6 +13,8 @@ class UpdatesAdapter(
var readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
var unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
var bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val coverClickListener: OnCoverClickListener = controller

View File

@ -180,7 +180,7 @@ class UpdatesController :
actionMode!!,
R.menu.updates_chapter_selection
) { onActionItemClicked(it!!) }
(activity as? MainActivity)?.showBottomNav(visible = false, expand = true)
(activity as? MainActivity)?.showBottomNav(false)
}
toggleSelection(position)
@ -324,6 +324,11 @@ class UpdatesController :
presenter.startDownloadingNow(chapter)
}
private fun bookmarkChapters(chapters: List<UpdatesItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
/**
* Called when ActionMode created.
* @param mode the ActionMode object
@ -346,6 +351,8 @@ class UpdatesController :
val chapters = getSelectedChapters()
binding.actionToolbar.findItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded }
binding.actionToolbar.findItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.bookmark }
binding.actionToolbar.findItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.bookmark }
binding.actionToolbar.findItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
binding.actionToolbar.findItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
}
@ -370,6 +377,8 @@ class UpdatesController :
R.id.action_delete ->
ConfirmDeleteChaptersDialog(this, getSelectedChapters())
.showDialog(router)
R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true)
R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false)
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
else -> return false
@ -386,7 +395,7 @@ class UpdatesController :
adapter?.clearSelection()
binding.actionToolbar.hide()
(activity as? MainActivity)?.showBottomNav(visible = true, expand = true)
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
}

View File

@ -39,15 +39,20 @@ class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter)
// Set manga title
binding.mangaTitle.text = item.manga.title
// Check if chapter is read and set correct color
// Check if chapter is read and/or bookmarked and set correct color
if (item.chapter.read) {
binding.chapterTitle.setTextColor(adapter.readColor)
binding.mangaTitle.setTextColor(adapter.readColor)
} else {
binding.chapterTitle.setTextColor(adapter.unreadColor)
binding.mangaTitle.setTextColor(adapter.unreadColor)
binding.chapterTitle.setTextColor(
if (item.bookmark) adapter.bookmarkedColor else adapter.unreadColorSecondary
)
}
// Set bookmark status
binding.bookmarkIcon.isVisible = item.bookmark
// Set chapter status
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)

View File

@ -180,6 +180,22 @@ class UpdatesPresenter : BasePresenter<UpdatesController>() {
)
}
/**
* Mark selected chapters as bookmarked
* @param items list of selected chapters
* @param bookmarked bookmark status
*/
fun bookmarkChapters(items: List<UpdatesItem>, bookmarked: Boolean) {
val chapters = items.map { it.chapter }
chapters.forEach {
it.bookmark = bookmarked
}
Observable.fromCallable { db.updateChaptersProgress(chapters).executeAsBlocking() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
* Download selected chapters
* @param items list of recent chapters seleted.

View File

@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import eu.kanade.tachiyomi.util.system.MiuiUtil
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.util.system.powerManager
import eu.kanade.tachiyomi.util.system.toast
@ -187,6 +189,45 @@ class SettingsAdvancedController : SettingsController() {
}
}
preferenceCategory {
titleRes = R.string.label_extensions
listPreference {
key = Keys.extensionInstaller
titleRes = R.string.ext_installer_pref
summary = "%s"
entriesRes = arrayOf(
R.string.ext_installer_legacy,
R.string.ext_installer_packageinstaller,
R.string.ext_installer_shizuku,
)
entryValues = PreferenceValues.ExtensionInstaller.values().map { it.name }.toTypedArray()
defaultValue = if (MiuiUtil.isMiui()) {
PreferenceValues.ExtensionInstaller.LEGACY
} else {
PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER
}.name
onChange {
if (it == PreferenceValues.ExtensionInstaller.SHIZUKU.name &&
!context.isPackageInstalled("moe.shizuku.privileged.api")
) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ext_installer_shizuku)
.setMessage(R.string.ext_installer_shizuku_unavailable_dialog)
.setPositiveButton(android.R.string.ok) { _, _ ->
openInBrowser("https://shizuku.rikka.app/download")
}
.setNegativeButton(android.R.string.cancel, null)
.show()
false
} else {
true
}
}
}
}
preferenceCategory {
titleRes = R.string.pref_category_display

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.setting
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.infoPreference
@ -11,7 +10,6 @@ import eu.kanade.tachiyomi.util.preference.preferenceCategory
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
import kotlinx.coroutines.flow.launchIn
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
class SettingsBrowseController : SettingsController() {
@ -54,18 +52,6 @@ class SettingsBrowseController : SettingsController() {
summaryRes = R.string.requires_app_restart
defaultValue = true
}
switchPreference {
key = Keys.showNsfwExtension
titleRes = R.string.pref_show_nsfw_extension
defaultValue = true
}
switchPreference {
key = Keys.labelNsfwExtension
titleRes = R.string.pref_label_nsfw_extension
defaultValue = true
preferences.showNsfwExtension().asImmediateFlow { isVisible = it }.launchIn(viewScope)
}
infoPreference(R.string.parental_controls_info)
}

View File

@ -68,6 +68,9 @@ abstract class SettingsController : PreferenceController() {
animatePreferenceHighlight(it.itemView)
}
}
// Explicitly clear it to avoid re-scrolling/animating on activity recreations
preferenceKey = null
}
}
}

View File

@ -282,11 +282,6 @@ class SettingsLibraryController : SettingsController() {
defaultValue = false
}
}
switchPreference {
key = Keys.showLibraryUpdateErrors
titleRes = R.string.pref_library_update_error_notification
defaultValue = true
}
}
}

View File

@ -8,6 +8,7 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SettingsSearchControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
@ -25,7 +26,7 @@ class SettingsSearchController :
/**
* Adapter containing search results grouped by lang.
*/
protected var adapter: SettingsSearchAdapter? = null
private var adapter: SettingsSearchAdapter? = null
private lateinit var searchView: SearchView
init {
@ -54,15 +55,18 @@ class SettingsSearchController :
* @param inflater used to load the menu xml.
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// Inflate menu.
inflater.inflate(R.menu.settings_main, menu)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
// Initialize search menu
val searchItem = menu.findItem(R.id.action_search)
searchView = searchItem.actionView as SearchView
searchView.maxWidth = Int.MAX_VALUE
// Change hint to show "search settings."
searchView.queryHint = applicationContext?.getString(R.string.action_search_settings)
searchItem.expandActionView()
@ -102,8 +106,6 @@ class SettingsSearchController :
super.onViewCreated(view)
adapter = SettingsSearchAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter

View File

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.util.lang
import java.io.Closeable
/**
* Executes the given block function on this resources and then closes it down correctly whether an exception is
* thrown or not.
*
* @param block a function to process with given Closeable resources.
* @return the result of block function invoked on this resource.
*/
inline fun <T : Closeable?> Array<T>.use(block: () -> Unit) {
var blockException: Throwable? = null
try {
return block()
} catch (e: Throwable) {
blockException = e
throw e
} finally {
when (blockException) {
null -> forEach { it?.close() }
else -> forEach {
try {
it?.close()
} catch (closeException: Throwable) {
blockException.addSuppressed(closeException)
}
}
}
}
}

View File

@ -94,6 +94,7 @@ fun initDialog(dialogPreference: DialogPreference) {
inline fun <P : Preference> PreferenceGroup.add(p: P): P {
return p.apply {
this.isIconSpaceReserved = false
this.isSingleLineTitle = false
addPreference(this)
}
}
@ -102,6 +103,7 @@ inline fun <P : Preference> PreferenceGroup.initThenAdd(p: P, block: P.() -> Uni
return p.apply {
block()
this.isIconSpaceReserved = false
this.isSingleLineTitle = false
addPreference(this)
}
}
@ -109,6 +111,7 @@ inline fun <P : Preference> PreferenceGroup.initThenAdd(p: P, block: P.() -> Uni
inline fun <P : Preference> PreferenceGroup.addThenInit(p: P, block: P.() -> Unit): P {
return p.apply {
this.isIconSpaceReserved = false
this.isSingleLineTitle = false
addPreference(this)
block()
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.util.system
import android.content.Context
import android.view.ViewPropertyAnimator
import android.view.animation.Animation
import androidx.constraintlayout.motion.widget.MotionScene.Transition
@ -14,3 +15,8 @@ fun Transition.applySystemAnimatorScale(context: Context) {
// End layout of cover expanding animation tends to break when the transition is less than ~25ms
this.duration = (this.duration * context.animatorDurationScale).toInt().coerceAtLeast(25)
}
/** Scale the duration of this [ViewPropertyAnimator] by [Context.animatorDurationScale] */
fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply {
this.duration = (this.duration * context.animatorDurationScale).toLong()
}

View File

@ -41,6 +41,7 @@ import androidx.core.graphics.green
import androidx.core.graphics.red
import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -368,12 +369,51 @@ fun Context.createReaderThemeContext(): Context {
}
fun Context.isOnline(): Boolean {
val networkCapabilities = connectivityManager.activeNetwork ?: return false
val actNw = connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false
val activeNetwork = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
val maxTransport = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> NetworkCapabilities.TRANSPORT_LOWPAN
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> NetworkCapabilities.TRANSPORT_WIFI_AWARE
else -> NetworkCapabilities.TRANSPORT_VPN
}
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(actNw::hasTransport)
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport)
}
/**
* Returns true if device is connected to Wifi.
*/
fun Context.isConnectedToWifi(): Boolean {
if (!wifiManager.isWifiEnabled) return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
wifiManager.connectionInfo.bssid != null
}
}
/**
* Gets document size of provided [Uri]
*
* @return document size of [uri] or null if size can't be obtained
*/
fun Context.getUriSize(uri: Uri): Long? {
return UniFile.fromUri(this, uri).length().takeIf { it >= 0 }
}
/**
* Returns true if [packageName] is installed.
*/
fun Context.isPackageInstalled(packageName: String): Boolean {
return try {
packageManager.getApplicationInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}

View File

@ -49,7 +49,7 @@ fun View.getCoordinates() = Point((left + right) / 2, (top + bottom) / 2)
*/
inline fun View.snack(
message: String,
length: Int = Snackbar.LENGTH_LONG,
length: Int = 10_000,
f: Snackbar.() -> Unit = {}
): Snackbar {
val snack = Snackbar.make(this, message, length)

View File

@ -5,10 +5,12 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.annotation.FloatRange
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.findViewTreeLifecycleOwner
import com.google.android.material.animation.AnimationUtils
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.HideToolbarOnScrollBehavior
import com.google.android.material.appbar.MaterialToolbar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.view.findChild
@ -51,6 +53,8 @@ class ElevationAppBarLayout @JvmOverloads constructor(
}
}
override fun getBehavior(): CoordinatorLayout.Behavior<AppBarLayout> = HideToolbarOnScrollBehavior()
/**
* Disabled. Lift on scroll is handled manually with [TachiyomiCoordinatorLayout]
*/

View File

@ -1,11 +1,19 @@
package eu.kanade.tachiyomi.widget
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.bottomnavigation.BottomNavigationView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.findChild
import kotlin.math.roundToLong
/**
* Hide behavior similar to app bar for [BottomNavigationView]
@ -15,6 +23,31 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
attrs: AttributeSet? = null
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) {
@ViewCompat.NestedScrollType
private var lastStartedType: Int = 0
private var offsetAnimator: ValueAnimator? = null
private var dyRatio = 1F
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
return dependency is AppBarLayout
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: BottomNavigationView,
dependency: View
): Boolean {
val toolbarSize = (dependency as ViewGroup).findChild<Toolbar>()?.height ?: 0
dyRatio = if (toolbarSize > 0) {
child.height.toFloat() / toolbarSize
} else {
1F
}
return false
}
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
@ -23,7 +56,12 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) {
return false
}
lastStartedType = type
offsetAnimator?.cancel()
return true
}
override fun onNestedPreScroll(
@ -36,6 +74,33 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
type: Int
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
child.translationY = (child.translationY + dy).coerceIn(0F, child.height.toFloat())
child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat())
}
override fun onStopNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
target: View,
type: Int
) {
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
animateBottomNavigationVisibility(child, child.translationY < child.height / 2)
}
}
private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
offsetAnimator?.cancel()
offsetAnimator = ValueAnimator().apply {
interpolator = DecelerateInterpolator()
duration = (150 * child.context.animatorDurationScale).roundToLong()
addUpdateListener {
child.translationY = it.animatedValue as Float
}
}
offsetAnimator?.setFloatValues(
child.translationY,
if (isVisible) 0F else child.height.toFloat()
)
offsetAnimator?.start()
}
}

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.core.view.ViewCompat
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.fastscroller.FastScroller
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.dpToPxEnd
@ -21,6 +22,12 @@ class MaterialFastScroll @JvmOverloads constructor(context: Context, attrs: Attr
)
autoHideEnabled = true
ignoreTouchesOutsideHandle = true
applyInsetter {
type(navigationBars = true) {
margin()
}
}
}
// Overridden to handle RTL

View File

@ -1,74 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet
import android.widget.SeekBar
import androidx.appcompat.widget.AppCompatSeekBar
import eu.kanade.tachiyomi.R
import kotlin.math.abs
class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
AppCompatSeekBar(context, attrs) {
private var minValue: Int = 0
private var maxValue: Int = 0
private var listener: OnSeekBarChangeListener? = null
init {
val styledAttributes = context.obtainStyledAttributes(
attrs,
R.styleable.NegativeSeekBar,
0,
0
)
try {
setMinSeek(styledAttributes.getInt(R.styleable.NegativeSeekBar_min_seek, 0))
setMaxSeek(styledAttributes.getInt(R.styleable.NegativeSeekBar_max_seek, 0))
} finally {
styledAttributes.recycle()
}
super.setOnSeekBarChangeListener(
object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, value: Int, fromUser: Boolean) {
listener?.onProgressChanged(seekBar, minValue + value, fromUser)
}
override fun onStartTrackingTouch(p0: SeekBar?) {
listener?.onStartTrackingTouch(p0)
}
override fun onStopTrackingTouch(p0: SeekBar?) {
listener?.onStopTrackingTouch(p0)
}
}
)
}
override fun setProgress(progress: Int) {
super.setProgress(abs(minValue) + progress)
}
fun setMinSeek(minValue: Int) {
this.minValue = minValue
max = (this.maxValue - this.minValue)
}
fun setMaxSeek(maxValue: Int) {
this.maxValue = maxValue
max = (this.maxValue - this.minValue)
}
override fun setOnSeekBarChangeListener(listener: OnSeekBarChangeListener?) {
this.listener = listener
}
override fun onRestoreInstanceState(state: Parcelable?) {
// We can't restore the progress from the saved state because it gets shifted.
val origProgress = progress
super.onRestoreInstanceState(state)
super.setProgress(origProgress)
}
}

View File

@ -8,7 +8,7 @@ import java.util.Stack
abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
private val pool = Stack<View>()
private val pool = HashMap<Int, Stack<View>>()
var recycle = true
set(value) {
@ -16,17 +16,20 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
field = value
}
protected abstract fun createView(container: ViewGroup): View
protected abstract fun getViewType(position: Int): Int
protected abstract fun inflateView(container: ViewGroup, viewType: Int): View
protected abstract fun bindView(view: View, position: Int)
protected open fun recycleView(view: View, position: Int) {}
override fun createView(container: ViewGroup, position: Int): View {
val view = if (pool.isNotEmpty()) {
pool.pop().setViewPagerPositionParam(position)
val viewType = getViewType(position)
val view = if (pool[viewType] != null && pool[viewType]!!.isNotEmpty()) {
pool[viewType]!!.pop().setViewPagerPositionParam(position)
} else {
createView(container)
inflateView(container, viewType)
}
bindView(view, position)
return view
@ -34,7 +37,9 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
override fun destroyView(container: ViewGroup, position: Int, view: View) {
recycleView(view, position)
if (recycle) pool.push(view)
val viewType = getViewType(position)
if (pool[viewType] == null) pool[viewType] = Stack<View>()
if (recycle) pool[viewType]!!.push(view)
}
/**

View File

@ -0,0 +1,174 @@
package eu.kanade.tachiyomi.widget
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.TimeInterpolator
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.ViewPropertyAnimator
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.updateLayoutParams
import androidx.customview.view.AbsSavedState
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale
import kotlinx.coroutines.flow.launchIn
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class TachiyomiBottomNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.bottomNavigationStyle,
defStyleRes: Int = R.style.Widget_Design_BottomNavigationView
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes) {
private var currentAnimator: ViewPropertyAnimator? = null
private var currentState = STATE_UP
init {
// Hide on scroll
doOnLayout {
findViewTreeLifecycleOwner()?.lifecycleScope?.let { scope ->
Injekt.get<PreferencesHelper>().hideBottomBarOnScroll()
.asImmediateFlow {
updateLayoutParams<CoordinatorLayout.LayoutParams> {
behavior = if (it) {
HideBottomNavigationOnScrollBehavior()
} else {
null
}
}
}
.launchIn(scope)
}
}
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return SavedState(superState).also {
it.currentState = currentState
}
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
super.onRestoreInstanceState(state.superState)
doOnNextLayout {
if (state.currentState == STATE_UP) {
slideUp(animate = false)
} else if (state.currentState == STATE_DOWN) {
slideDown(animate = false)
}
}
} else {
super.onRestoreInstanceState(state)
}
}
override fun setTranslationY(translationY: Float) {
// Disallow translation change when state down
if (currentState == STATE_DOWN) return
super.setTranslationY(translationY)
}
/**
* Shows this view up.
*
* @param animate True if slide up should be animated
*/
fun slideUp(animate: Boolean = true) {
currentAnimator?.cancel()
clearAnimation()
currentState = STATE_UP
animateTranslation(
0F,
if (animate) SLIDE_UP_ANIMATION_DURATION else 0,
LinearOutSlowInInterpolator()
)
}
/**
* Hides this view down. [setTranslationY] won't work until [slideUp] is called.
*
* @param animate True if slide down should be animated
*/
fun slideDown(animate: Boolean = true) {
currentAnimator?.cancel()
clearAnimation()
currentState = STATE_DOWN
animateTranslation(
height.toFloat(),
if (animate) SLIDE_DOWN_ANIMATION_DURATION else 0,
FastOutLinearInInterpolator()
)
}
private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
currentAnimator = animate()
.translationY(targetY)
.setInterpolator(interpolator)
.setDuration(duration)
.applySystemAnimatorScale(context)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
currentAnimator = null
postInvalidate()
}
})
}
internal class SavedState : AbsSavedState {
var currentState = STATE_UP
constructor(superState: Parcelable) : super(superState)
constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
currentState = source.readByte().toInt()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeByte(currentState.toByte())
}
companion object {
@JvmField
val CREATOR: Parcelable.ClassLoaderCreator<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
return SavedState(source, loader)
}
override fun createFromParcel(source: Parcel): SavedState {
return SavedState(source, null)
}
override fun newArray(size: Int): Array<SavedState> {
return newArray(size)
}
}
}
}
companion object {
private const val STATE_DOWN = 1
private const val STATE_UP = 2
private const val SLIDE_UP_ANIMATION_DURATION = 225L
private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget
import android.content.Context
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import androidx.appcompat.widget.SearchView
import androidx.core.view.inputmethod.EditorInfoCompat
import eu.kanade.tachiyomi.R
@ -39,6 +40,18 @@ class TachiyomiSearchView @JvmOverloads constructor(
}.launchIn(scope!!)
}
override fun setOnQueryTextListener(listener: OnQueryTextListener?) {
super.setOnQueryTextListener(listener)
val searchAutoComplete: SearchAutoComplete = findViewById(R.id.search_src_text)
searchAutoComplete.setOnEditorActionListener { _, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_SEARCH || actionID == EditorInfo.IME_NULL) {
clearFocus()
listener?.onQueryTextSubmit(query.toString())
true
} else false
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scope?.cancel()

View File

@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.widget.listener
import android.widget.SeekBar
open class SimpleSeekBarListener : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
}
}

View File

@ -64,29 +64,31 @@
<TextView
android:id="@+id/manga_full_title"
style="@style/TextAppearance.Medium.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:gravity="center"
android:text="@string/manga_info_full_title_label"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textIsSelectable="false" />
<TextView
android:id="@+id/manga_author"
style="@style/TextAppearance.Regular.Body1.Secondary.Bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textColor="?android:attr/textColorSecondary"
android:textAlignment="center"
android:textIsSelectable="false"
tools:text="Author" />
<TextView
android:id="@+id/manga_artist"
style="@style/TextAppearance.Regular.Body1.Secondary.Bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Artist" />
@ -106,31 +108,34 @@
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Status" />
<TextView
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="•"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Source" />
@ -194,7 +199,6 @@
<TextView
android:id="@+id/manga_summary_text"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -203,6 +207,8 @@
android:ellipsize="end"
android:focusable="true"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -33,7 +33,7 @@
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.SubHeading"
android:textAppearance="?attr/textAppearanceBody2"
tools:text="Category Title" />
</LinearLayout>

View File

@ -3,8 +3,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="fill_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:layout_height="wrap_content"
android:background="@drawable/list_item_selector_background"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingVertical="10dp"
android:paddingStart="16dp"
android:paddingEnd="5dp">
@ -12,41 +14,44 @@
android:id="@+id/bookmark_icon"
android:layout_width="16dp"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_filter_bookmarked"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/chapter_title"
app:layout_constraintEnd_toStartOf="@id/chapter_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintTop_toTopOf="@+id/chapter_title"
app:srcCompat="@drawable/ic_bookmark_24dp"
app:tint="?attr/colorAccent"
tools:visibility="visible" />
<TextView
android:id="@+id/chapter_title"
style="@style/TextAppearance.Regular.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
app:layout_constraintBottom_toTopOf="@+id/chapter_description"
app:layout_constraintEnd_toStartOf="@+id/download"
app:layout_constraintStart_toEndOf="@+id/bookmark_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Title" />
<TextView
android:id="@+id/chapter_description"
style="@style/TextAppearance.Regular.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBody2"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/download"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chapter_title"
tools:text="22/02/2016 • Scanlator • Page: 45" />
<eu.kanade.tachiyomi.ui.manga.chapter.ChapterDownloadView

View File

@ -10,7 +10,7 @@
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Regular.Body1" />
android:textAppearance="?attr/textAppearanceBody2" />
<CheckBox
android:id="@+id/checkbox_option"

View File

@ -9,20 +9,22 @@
<TextView
android:id="@+id/text_face"
style="@style/TextAppearance.Medium.Body2.Hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textSize="48sp"
tools:text="-_-" />
<TextView
android:id="@+id/text_label"
style="@style/TextAppearance.Medium.Body2.Hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="Label" />
<LinearLayout

View File

@ -37,7 +37,7 @@
android:layout_toEndOf="@id/reorder"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.Body1"
android:textAppearance="?attr/textAppearanceBody2"
app:layout_constraintEnd_toStartOf="@+id/download_progress_text"
app:layout_constraintStart_toEndOf="@+id/reorder"
app:layout_constraintTop_toTopOf="parent"
@ -52,7 +52,8 @@
android:layout_toEndOf="@id/reorder"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.Caption"
android:textAppearance="?attr/textAppearanceBody2"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@+id/manga_source"
app:layout_constraintStart_toStartOf="@+id/manga_full_title"
app:layout_constraintTop_toBottomOf="@+id/manga_full_title"
@ -75,7 +76,9 @@
android:layout_height="wrap_content"
android:layout_toEndOf="@id/manga_full_title"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.Caption.Hint"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:textColorSecondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/manga_full_title"
app:layout_constraintEnd_toStartOf="@+id/menu"
app:layout_constraintTop_toTopOf="@+id/manga_full_title"
@ -87,7 +90,9 @@
android:layout_height="wrap_content"
android:layout_toEndOf="@id/chapter_title"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.Caption.Hint"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:textColorSecondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/chapter_title"
app:layout_constraintEnd_toStartOf="@+id/menu"
app:layout_constraintTop_toTopOf="@+id/chapter_title"

View File

@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginEnd="16dp"
android:background="@drawable/list_item_selector_background">
<ImageView
@ -21,14 +22,12 @@
<TextView
android:id="@+id/ext_title"
style="@style/TextAppearance.Regular"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Regular.SubHeading"
android:textSize="14sp"
android:textAppearance="?attr/textAppearanceBody2"
app:layout_constraintBottom_toTopOf="@id/lang"
app:layout_constraintEnd_toStartOf="@id/ext_button"
app:layout_constraintStart_toEndOf="@id/image"
@ -38,11 +37,10 @@
<TextView
android:id="@+id/lang"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="12sp"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@+id/ext_title"
@ -51,25 +49,23 @@
<TextView
android:id="@+id/version"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:maxLines="1"
android:textSize="12sp"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintStart_toEndOf="@id/lang"
app:layout_constraintTop_toBottomOf="@+id/ext_title"
tools:text="Version" />
<TextView
android:id="@+id/warning"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="?attr/colorError"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@id/version"
app:layout_constraintTop_toBottomOf="@+id/ext_title"
tools:text="Warning" />
@ -79,10 +75,24 @@
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@+id/cancel_button"
app:layout_constraintTop_toTopOf="parent"
tools:text="Details" />
<ImageButton
android:id="@+id/cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@android:string/cancel"
android:padding="12dp"
android:src="@drawable/ic_close_24dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?android:attr/textColorPrimary"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -36,11 +36,11 @@
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:elevation="3dp"
android:textAppearance="?attr/textAppearanceSubtitle1"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Tachiyomi: Extension" />
@ -53,6 +53,7 @@
android:layout_weight="1"
android:elevation="3dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="Version: 1.0.0" />
@ -65,6 +66,7 @@
android:layout_weight="1"
android:elevation="3dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@id/version"
tools:text="Language: English" />
@ -78,6 +80,7 @@
android:elevation="3dp"
android:gravity="center"
android:text="@string/ext_nsfw_warning"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="?attr/colorError"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/title"
@ -91,6 +94,7 @@
android:elevation="3dp"
android:ellipsize="middle"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceCaption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@id/nsfw"
@ -98,11 +102,24 @@
<Button
android:id="@+id/btn_uninstall"
style="@style/Widget.Tachiyomi.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="4dp"
android:text="@string/ext_uninstall"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_app_info"
app:layout_constraintTop_toBottomOf="@id/pkgname" />
<Button
android:id="@+id/btn_app_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="4dp"
android:text="@string/ext_app_info"
app:layout_constraintStart_toEndOf="@+id/btn_uninstall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/pkgname" />

View File

@ -14,10 +14,10 @@
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textAppearance="?attr/textAppearanceSubtitle2"
app:layout_constraintBottom_toTopOf="@+id/subtitle"
app:layout_constraintEnd_toStartOf="@+id/title_more_icon"
app:layout_constraintStart_toStartOf="parent"
@ -27,11 +27,11 @@
<TextView
android:id="@+id/subtitle"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"

View File

@ -46,7 +46,6 @@
<TextView
android:id="@+id/favorite_text"
style="@style/TextAppearance.Regular.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/colorSecondary"
@ -56,6 +55,7 @@
android:paddingEnd="3dp"
android:paddingBottom="1dp"
android:text="@string/in_library"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="?attr/colorOnSecondary"
android:visibility="gone"
tools:visibility="visible" />
@ -66,14 +66,13 @@
<TextView
android:id="@+id/title"
style="@style/TextAppearance.Regular.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/ptsans_narrow_bold"
android:lineSpacingExtra="-4dp"
android:maxLines="2"
android:padding="4dp"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="@+id/card"
app:layout_constraintStart_toStartOf="@+id/card"
app:layout_constraintTop_toBottomOf="@+id/card"

View File

@ -42,14 +42,16 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Medium"
android:textAppearance="?attr/textAppearanceSubtitle2"
tools:text="Title" />
<TextView
android:id="@+id/manga_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="Subtitle" />
</LinearLayout>

View File

@ -1,7 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/licenses_item" />
android:layout_height="wrap_content">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
tools:listitem="@layout/licenses_item" />
</RelativeLayout>

View File

@ -10,21 +10,25 @@
<TextView
android:id="@+id/name"
style="@style/TextAppearance.Regular.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceSubtitle2"
tools:text="Library name" />
<TextView
android:id="@+id/artifact_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="artifact:id:1.0" />
<TextView
android:id="@+id/license"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="Apache Version 2.0" />
</LinearLayout>

View File

@ -75,7 +75,7 @@
android:id="@+id/fab_layout"
layout="@layout/main_activity_fab" />
<com.google.android.material.bottomnavigation.BottomNavigationView
<eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -13,12 +13,11 @@
<TextView
android:id="@+id/chapters_label"
style="@style/TextAppearance.Regular.SubHeading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/chapters"
android:textAppearance="?attr/textAppearanceSubtitle1"
android:textIsSelectable="false"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btn_chapters_filter"
app:layout_constraintStart_toStartOf="parent"

View File

@ -70,27 +70,29 @@
<TextView
android:id="@+id/manga_full_title"
style="@style/TextAppearance.Medium.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/manga_info_full_title_label"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textIsSelectable="false" />
<TextView
android:id="@+id/manga_author"
style="@style/TextAppearance.Regular.Body1.Secondary.Bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Author" />
<TextView
android:id="@+id/manga_artist"
style="@style/TextAppearance.Regular.Body1.Secondary.Bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceSubtitle2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Artist" />
@ -111,31 +113,34 @@
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Status" />
<TextView
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="•"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
tools:text="Source" />
@ -205,7 +210,6 @@
<TextView
android:id="@+id/manga_summary_text"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -214,6 +218,8 @@
android:ellipsize="end"
android:focusable="true"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
android:textIsSelectable="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -21,4 +21,11 @@
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>

View File

@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
xmlns:tools="http://schemas.android.com/tools"
android:background="?attr/selectableItemBackground"
android:focusable="true"
android:paddingStart="?attr/listPreferredItemPaddingStart"
@ -17,6 +18,7 @@
android:gravity="center_vertical|start"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
android:textAppearance="?attr/textAppearanceBody2"
tools:text="Title" />
</LinearLayout>

View File

@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
xmlns:tools="http://schemas.android.com/tools"
android:background="?attr/selectableItemBackground"
android:focusable="true"
android:paddingStart="?attr/listPreferredItemPaddingStart"
@ -15,6 +16,7 @@
android:drawablePadding="16dp"
android:gravity="center_vertical|start"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
android:textAppearance="?attr/textAppearanceBody2"
tools:text="Title" />
</LinearLayout>

View File

@ -17,7 +17,7 @@
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Medium.SubHeading"
android:textAppearance="@style/TextAppearance.Tachiyomi.SectionHeader"
tools:text="Header" />
<ImageView

View File

@ -17,6 +17,6 @@
android:gravity="center_vertical|start"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
android:textAppearance="?attr/textAppearanceBody2" />
</LinearLayout>

View File

@ -17,6 +17,8 @@
android:ellipsize="end"
android:maxLines="1"
android:paddingEnd="8dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="Filter:" />
<Spinner

View File

@ -23,7 +23,7 @@
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
android:textAppearance="?attr/textAppearanceBody2" />
</com.google.android.material.textfield.TextInputLayout>

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