Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5436c65b76 | ||
|
|
c590813a1a | ||
|
|
1cf36e1b41 | ||
|
|
5895a20af1 | ||
|
|
fd5fd43b72 |
@@ -13,7 +13,6 @@ disabled_rules = no-wildcard-imports, no-unused-imports
|
|||||||
|
|
||||||
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
|
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
|
||||||
ij_continuation_indent_size = 4
|
ij_continuation_indent_size = 4
|
||||||
ij_xml_attribute_wrap = on_every_item
|
|
||||||
|
|
||||||
[{*.kt,*.kts}]
|
[{*.kt,*.kts}]
|
||||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ko_fi: xtimms
|
||||||
|
custom: ["https://yoomoney.ru/to/410012543938752"]
|
||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||||
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
||||||
6
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,5 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
options:
|
||||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
|
||||||
required: true
|
|
||||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
|||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
options:
|
options:
|
||||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
BIN
.github/assets/vtuber.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
5
.gitignore
vendored
@@ -10,13 +10,11 @@
|
|||||||
/.idea/compiler.xml
|
/.idea/compiler.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/ktlint-plugin.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
/.idea/kotlinScripting.xml
|
/.idea/kotlinScripting.xml
|
||||||
/.idea/kotlinc.xml
|
/.idea/kotlinc.xml
|
||||||
/.idea/deploymentTargetDropDown.xml
|
/.idea/deploymentTargetDropDown.xml
|
||||||
/.idea/androidTestResultsUserPreferences.xml
|
/.idea/androidTestResultsUserPreferences.xml
|
||||||
/.idea/deploymentTargetSelector.xml
|
|
||||||
/.idea/render.experimental.xml
|
/.idea/render.experimental.xml
|
||||||
/.idea/inspectionProfiles/
|
/.idea/inspectionProfiles/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,6 +22,3 @@
|
|||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
|
||||||
/.kotlin/
|
|
||||||
/.idea/AndroidProjectSystem.xml
|
|
||||||
2
.idea/.gitignore
generated
vendored
@@ -1,5 +1,3 @@
|
|||||||
# Default ignored files
|
# Default ignored files
|
||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
|
||||||
/runConfigurations.xml
|
|
||||||
|
|||||||
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AndroidProjectSystem">
|
|
||||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
5
.idea/gradle.xml
generated
@@ -4,9 +4,10 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="GRADLE" />
|
||||||
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="jbr-21" />
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
3
.weblate
@@ -1,3 +0,0 @@
|
|||||||
[weblate]
|
|
||||||
url = https://hosted.weblate.org/api/
|
|
||||||
translation = kotatsu/strings
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
## Kotatsu contribution guidelines
|
|
||||||
|
|
||||||
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
|
|
||||||
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
|
|
||||||
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
|
||||||
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
|
||||||
|
|
||||||
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
|
||||||
|
|
||||||
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
|
||||||
+ Please, **do not modify readme and other information files** (except for typos).
|
|
||||||
+ **Avoid adding new dependencies** unless required. APK size is important.
|
|
||||||
53
LICENSE
@@ -619,3 +619,56 @@ Program, unless a warranty or assumption of liability accompanies a
|
|||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|||||||
115
README.md
@@ -1,107 +1,58 @@
|
|||||||
<div align="center">
|
# Kotatsu
|
||||||
|
|
||||||
<a href="https://kotatsu.app">
|
Kotatsu is a free and open source manga reader for Android.
|
||||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
# [Kotatsu](https://kotatsu.app)
|
   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
|
||||||
|
|
||||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
|
||||||
|
|
||||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
<div align="left">
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||||
|
alt="Get it on F-Droid"
|
||||||
|
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
|
||||||
|
|
||||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
Download APK directly from GitHub:
|
||||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
|
||||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
|
||||||
|
|
||||||
</div>
|
- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)**
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
<div align="left">
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||||
|
* Search manga by name and genres
|
||||||
|
* Reading history and bookmarks
|
||||||
|
* Favourites organized by user-defined categories
|
||||||
|
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||||
|
* Tablet-optimized Material You UI
|
||||||
|
* Standard and Webtoon-optimized reader
|
||||||
|
* Notifications about new chapters with updates feed
|
||||||
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
|
||||||
|
* Password/fingerprint protect access to the app
|
||||||
|
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
### Screenshots
|
||||||
* Search manga by name, genres, and more filters
|
|
||||||
* Favorites organized by user-defined categories
|
|
||||||
* Reading history, bookmarks, and incognito mode support
|
|
||||||
* Download manga and read it offline. Third-party CBZ archives are also supported
|
|
||||||
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
|
||||||
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
|
||||||
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
|
||||||
* Password / fingerprint-protected access to the app
|
|
||||||
* Automatically sync app data with other devices on the same account
|
|
||||||
* Support for older devices running Android 5+
|
|
||||||
|
|
||||||
</div>
|
|  |  |  |
|
||||||
|
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
### In-App Screenshots
|
|  |  |
|
||||||
|
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||||
<div align="center">
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
|
||||||
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
|
||||||
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
||||||
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
||||||
**📌 If you would like to help improve these or add new languages,
|
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
||||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
|
||||||
|
|
||||||
### Contributing
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<a href="https://github.com/KotatsuApp/Kotatsu">
|
|
||||||
<picture>
|
|
||||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
|
||||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
|
||||||
<picture>
|
|
||||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
|
||||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
|
||||||
</picture>
|
|
||||||
</a><br></br>
|
|
||||||
|
|
||||||
</br>
|
|
||||||
|
|
||||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
<div align="left">
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||||
|
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
install instructions.
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
<div align="left">
|
The developers of this application does not have any affiliation with the content available in the app.
|
||||||
|
It is collecting from the sources freely available through any web browser.
|
||||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
261
app/build.gradle
@@ -1,154 +1,66 @@
|
|||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
id 'kotlin-kapt'
|
id 'kotlin-kapt'
|
||||||
id 'com.google.devtools.ksp'
|
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'dagger.hilt.android.plugin'
|
id 'dagger.hilt.android.plugin'
|
||||||
id 'androidx.room'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 35
|
compileSdk = 33
|
||||||
buildToolsVersion = '35.0.0'
|
buildToolsVersion = '33.0.2'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdkVersion 21
|
||||||
targetSdk = 35
|
targetSdkVersion 33
|
||||||
versionCode = 1012
|
versionCode 525
|
||||||
versionName = '8.1.6'
|
versionName '4.4.9'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ksp {
|
|
||||||
arg('room.generateKotlin', 'true')
|
kapt {
|
||||||
|
arguments {
|
||||||
|
arg 'room.schemaLocation', "$projectDir/schemas".toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
|
||||||
generateLocaleConfig false
|
|
||||||
}
|
|
||||||
resourceConfigurations += [
|
|
||||||
"en",
|
|
||||||
"ab",
|
|
||||||
"ar",
|
|
||||||
"arq",
|
|
||||||
"as",
|
|
||||||
"be",
|
|
||||||
"bn",
|
|
||||||
"ca",
|
|
||||||
"cs",
|
|
||||||
"de",
|
|
||||||
"el",
|
|
||||||
"en-rGB",
|
|
||||||
"enm",
|
|
||||||
"es",
|
|
||||||
"et",
|
|
||||||
"eu",
|
|
||||||
"fa",
|
|
||||||
"fi",
|
|
||||||
"fil",
|
|
||||||
"fr",
|
|
||||||
"frp",
|
|
||||||
"gu",
|
|
||||||
"hi",
|
|
||||||
"hr",
|
|
||||||
"hu",
|
|
||||||
"in",
|
|
||||||
"it",
|
|
||||||
"iw",
|
|
||||||
"ja",
|
|
||||||
"kk",
|
|
||||||
"km",
|
|
||||||
"ko",
|
|
||||||
"lt",
|
|
||||||
"lv",
|
|
||||||
"lzh",
|
|
||||||
"ml",
|
|
||||||
"ms",
|
|
||||||
"my",
|
|
||||||
"nb-rNO",
|
|
||||||
"ne",
|
|
||||||
"nn",
|
|
||||||
"or",
|
|
||||||
"pa",
|
|
||||||
"pa-rPK",
|
|
||||||
"pl",
|
|
||||||
"pt",
|
|
||||||
"pt-rBR",
|
|
||||||
"ro",
|
|
||||||
"ru",
|
|
||||||
"si",
|
|
||||||
"sr",
|
|
||||||
"sv",
|
|
||||||
"ta",
|
|
||||||
"th",
|
|
||||||
"tr",
|
|
||||||
"uk",
|
|
||||||
"vi",
|
|
||||||
"zh-rCN",
|
|
||||||
"zh-rTW",
|
|
||||||
// Specific BCP 47 locales
|
|
||||||
"b+zh+Hans+MO",
|
|
||||||
"b+zh+Hant+MO"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = '.debug'
|
applicationIdSuffix = '.debug'
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
|
multiDexEnabled false
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
nightly {
|
|
||||||
initWith release
|
|
||||||
applicationIdSuffix = '.nightly'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
buildConfig true
|
|
||||||
}
|
|
||||||
packagingOptions {
|
|
||||||
resources {
|
|
||||||
excludes += [
|
|
||||||
'META-INF/README.md',
|
|
||||||
'META-INF/NOTICE.md'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
main.java.srcDirs += 'src/main/kotlin/'
|
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
'-opt-in=com.google.android.material.badge.ExperimentalBadgeUtils',
|
||||||
'-Xjspecify-annotations=strict',
|
|
||||||
'-Xtype-enhancement-improvements-strict-mode',
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
room {
|
|
||||||
schemaDirectory "$projectDir/schemas"
|
|
||||||
}
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -157,15 +69,6 @@ android {
|
|||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applicationVariants.configureEach { variant ->
|
|
||||||
if (variant.name == 'nightly') {
|
|
||||||
variant.outputs.each { output ->
|
|
||||||
def now = LocalDateTime.now()
|
|
||||||
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
|
||||||
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
compileDebugKotlin {
|
compileDebugKotlin {
|
||||||
@@ -175,92 +78,74 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
def parsersVersion = libs.versions.parsers.get()
|
//noinspection GradleDependency
|
||||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:1b6d1456f3') {
|
||||||
// usage:
|
|
||||||
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
|
||||||
parsersVersion = System.getProperty('parsersVersionOverride')
|
|
||||||
}
|
|
||||||
//noinspection UseTomlInstead
|
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10'
|
||||||
implementation libs.kotlin.stdlib
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||||
implementation libs.kotlinx.coroutines.android
|
|
||||||
implementation libs.kotlinx.coroutines.guava
|
|
||||||
|
|
||||||
implementation libs.androidx.appcompat
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation libs.androidx.core
|
implementation 'androidx.core:core-ktx:1.9.0'
|
||||||
implementation libs.androidx.activity
|
implementation 'androidx.activity:activity-ktx:1.6.1'
|
||||||
implementation libs.androidx.fragment
|
implementation 'androidx.fragment:fragment-ktx:1.5.5'
|
||||||
implementation libs.androidx.transition
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
|
||||||
implementation libs.androidx.collection
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
|
||||||
implementation libs.lifecycle.viewmodel
|
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
|
||||||
implementation libs.lifecycle.service
|
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
|
||||||
implementation libs.lifecycle.process
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation libs.androidx.constraintlayout
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation libs.androidx.swiperefreshlayout
|
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||||
implementation libs.androidx.recyclerview
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation libs.androidx.viewpager2
|
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||||
implementation libs.androidx.preference
|
implementation 'androidx.work:work-runtime-ktx:2.8.0'
|
||||||
implementation libs.androidx.biometric
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation libs.material
|
implementation 'com.google.android.material:material:1.8.0'
|
||||||
implementation libs.androidx.lifecycle.common.java8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
implementation libs.androidx.webkit
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
|
||||||
|
|
||||||
implementation libs.androidx.work.runtime
|
implementation 'androidx.room:room-runtime:2.5.0'
|
||||||
implementation libs.guava
|
implementation 'androidx.room:room-ktx:2.5.0'
|
||||||
|
kapt 'androidx.room:room-compiler:2.5.0'
|
||||||
|
|
||||||
implementation libs.androidx.room.runtime
|
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
||||||
implementation libs.androidx.room.ktx
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
|
||||||
ksp libs.androidx.room.compiler
|
implementation 'com.squareup.okio:okio:3.3.0'
|
||||||
|
|
||||||
implementation libs.okhttp
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation libs.okhttp.tls
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
implementation libs.okhttp.dnsoverhttps
|
|
||||||
implementation libs.okio
|
|
||||||
|
|
||||||
implementation libs.adapterdelegates
|
implementation 'com.google.dagger:hilt-android:2.45'
|
||||||
implementation libs.adapterdelegates.viewbinding
|
kapt 'com.google.dagger:hilt-compiler:2.45'
|
||||||
|
implementation 'androidx.hilt:hilt-work:1.0.0'
|
||||||
|
kapt 'androidx.hilt:hilt-compiler:1.0.0'
|
||||||
|
|
||||||
implementation libs.hilt.android
|
implementation 'io.coil-kt:coil-base:2.2.2'
|
||||||
kapt libs.hilt.compiler
|
implementation 'io.coil-kt:coil-svg:2.2.2'
|
||||||
implementation libs.androidx.hilt.work
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
|
||||||
kapt libs.androidx.hilt.compiler
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
implementation libs.coil.core
|
implementation 'ch.acra:acra-http:5.9.7'
|
||||||
implementation libs.coil.network
|
implementation 'ch.acra:acra-dialog:5.9.7'
|
||||||
implementation libs.coil.gif
|
|
||||||
implementation libs.coil.svg
|
|
||||||
implementation libs.avif.decoder
|
|
||||||
implementation libs.ssiv
|
|
||||||
implementation libs.disk.lru.cache
|
|
||||||
implementation libs.markwon
|
|
||||||
|
|
||||||
implementation libs.acra.http
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||||
implementation libs.acra.dialog
|
|
||||||
|
|
||||||
implementation libs.conscrypt.android
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.json:json:20230227'
|
||||||
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||||
|
|
||||||
debugImplementation libs.leakcanary.android
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
debugImplementation libs.workinspector
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
|
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||||
|
|
||||||
testImplementation libs.junit
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||||
testImplementation libs.json
|
|
||||||
testImplementation libs.kotlinx.coroutines.test
|
|
||||||
|
|
||||||
androidTestImplementation libs.androidx.runner
|
androidTestImplementation 'androidx.room:room-testing:2.5.0'
|
||||||
androidTestImplementation libs.androidx.rules
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
|
||||||
androidTestImplementation libs.androidx.test.core
|
|
||||||
androidTestImplementation libs.androidx.junit
|
|
||||||
|
|
||||||
androidTestImplementation libs.kotlinx.coroutines.test
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
|
||||||
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45'
|
||||||
androidTestImplementation libs.androidx.room.testing
|
|
||||||
androidTestImplementation libs.moshi.kotlin
|
|
||||||
|
|
||||||
androidTestImplementation libs.hilt.android.testing
|
|
||||||
kaptAndroidTest libs.hilt.android.compiler
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/proguard-rules.pro
vendored
@@ -8,23 +8,10 @@
|
|||||||
public static void checkParameterIsNotNull(...);
|
public static void checkParameterIsNotNull(...);
|
||||||
public static void checkNotNullParameter(...);
|
public static void checkNotNullParameter(...);
|
||||||
}
|
}
|
||||||
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||||
-dontwarn okhttp3.internal.platform.**
|
-dontwarn okhttp3.internal.platform.ConscryptPlatform
|
||||||
-dontwarn org.conscrypt.**
|
|
||||||
-dontwarn org.bouncycastle.**
|
|
||||||
-dontwarn org.openjsse.**
|
|
||||||
-dontwarn com.google.j2objc.annotations.**
|
|
||||||
-dontwarn coil3.PlatformContext
|
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
|
||||||
-keep class org.jsoup.parser.Tag
|
|
||||||
-keep class org.jsoup.internal.StringUtil
|
|
||||||
|
|
||||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
|
||||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
|
||||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
|
||||||
-keep class org.acra.sender.JobSenderService
|
|
||||||
|
|||||||
BIN
app/sampledata/covers/Forget-me-not Volume 1.jpg
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
app/sampledata/covers/Forget-me-not Volume 2.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
app/sampledata/covers/La Pomme Prisoinniere.jpg
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
app/sampledata/covers/Momo Kanchou no Himitsu Kichi.jpg
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
app/sampledata/covers/Omoide Emanon.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/sampledata/covers/Sasurai Emanon Volume 1.jpg
Normal file
|
After Width: | Height: | Size: 791 KiB |
BIN
app/sampledata/covers/Sasurai Emanon Volume 2.jpg
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
app/sampledata/covers/Sasurai Emanon Volume 3.jpg
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
app/sampledata/covers/Wandering Island Volume 1.jpg
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
app/sampledata/covers/Wandering Island Volume 2.jpg
Normal file
|
After Width: | Height: | Size: 398 KiB |
10
app/sampledata/genres
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Slice of Life, Mystery
|
||||||
|
Slice of Life, Mystery
|
||||||
|
Psychological, Romance, Comedy, Slice of Life, Supernatural
|
||||||
|
Sci-Fi, Comedy
|
||||||
|
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||||
|
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||||
|
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||||
|
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
|
||||||
|
Adventure, Slice of Life, Mystery
|
||||||
|
Adventure, Slice of Life, Mystery
|
||||||
10
app/sampledata/titles
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Forget-me-not Vol. 1
|
||||||
|
Forget-me-not Vol. 2
|
||||||
|
La Pomme Prisoinniere
|
||||||
|
Momo Kanchou no Himitsu Kichi
|
||||||
|
Omoide Emanon
|
||||||
|
Sasurai Emanon Vol. 1
|
||||||
|
Sasurai Emanon Vol. 2
|
||||||
|
Sasurai Emanon Vol. 3
|
||||||
|
Wandering Island Vol. 1
|
||||||
|
Wandering Island Vol. 2
|
||||||
@@ -17,7 +17,7 @@ class MangaDatabaseTest {
|
|||||||
MangaDatabase::class.java,
|
MangaDatabase::class.java,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
|
private val migrations = databaseMigrations
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun versions() {
|
fun versions() {
|
||||||
@@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -18,12 +19,11 @@ import org.junit.runner.RunWith
|
|||||||
import org.koitharu.kotatsu.SampleData
|
import org.koitharu.kotatsu.SampleData
|
||||||
import org.koitharu.kotatsu.awaitForIdle
|
import org.koitharu.kotatsu.awaitForIdle
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class AppShortcutManagerTest {
|
class ShortcutsUpdaterTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
var hiltRule = HiltAndroidRule(this)
|
var hiltRule = HiltAndroidRule(this)
|
||||||
@@ -32,7 +32,7 @@ class AppShortcutManagerTest {
|
|||||||
lateinit var historyRepository: HistoryRepository
|
lateinit var historyRepository: HistoryRepository
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var appShortcutManager: AppShortcutManager
|
lateinit var shortcutsUpdater: ShortcutsUpdater
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var database: MangaDatabase
|
lateinit var database: MangaDatabase
|
||||||
@@ -48,7 +48,6 @@ class AppShortcutManagerTest {
|
|||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
|
||||||
return@runTest
|
return@runTest
|
||||||
}
|
}
|
||||||
database.invalidationTracker.addObserver(appShortcutManager)
|
|
||||||
awaitUpdate()
|
awaitUpdate()
|
||||||
assertTrue(getShortcuts().isEmpty())
|
assertTrue(getShortcuts().isEmpty())
|
||||||
historyRepository.addOrUpdate(
|
historyRepository.addOrUpdate(
|
||||||
@@ -57,7 +56,6 @@ class AppShortcutManagerTest {
|
|||||||
page = 4,
|
page = 4,
|
||||||
scroll = 2,
|
scroll = 2,
|
||||||
percent = 0.3f,
|
percent = 0.3f,
|
||||||
force = false,
|
|
||||||
)
|
)
|
||||||
awaitUpdate()
|
awaitUpdate()
|
||||||
|
|
||||||
@@ -74,6 +72,6 @@ class AppShortcutManagerTest {
|
|||||||
private suspend fun awaitUpdate() {
|
private suspend fun awaitUpdate() {
|
||||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
instrumentation.awaitForIdle()
|
instrumentation.awaitForIdle()
|
||||||
appShortcutManager.await()
|
shortcutsUpdater.await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.*
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -19,9 +19,7 @@ import org.koitharu.kotatsu.core.backup.BackupRepository
|
|||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
@HiltAndroidTest
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@@ -54,7 +52,6 @@ class AppBackupAgentTest {
|
|||||||
title = SampleData.favouriteCategory.title,
|
title = SampleData.favouriteCategory.title,
|
||||||
sortOrder = SampleData.favouriteCategory.order,
|
sortOrder = SampleData.favouriteCategory.order,
|
||||||
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
||||||
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
|
|
||||||
)
|
)
|
||||||
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
||||||
historyRepository.addOrUpdate(
|
historyRepository.addOrUpdate(
|
||||||
@@ -63,7 +60,6 @@ class AppBackupAgentTest {
|
|||||||
page = 3,
|
page = 3,
|
||||||
scroll = 40,
|
scroll = 40,
|
||||||
percent = 0.2f,
|
percent = 0.2f,
|
||||||
force = false,
|
|
||||||
)
|
)
|
||||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||||
|
|
||||||
@@ -85,7 +81,7 @@ class AppBackupAgentTest {
|
|||||||
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
||||||
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
||||||
|
|
||||||
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
|
||||||
assertTrue(SampleData.tag in allTags)
|
assertTrue(SampleData.tag in allTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.domain
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import javax.inject.Inject
|
||||||
|
import junit.framework.TestCase.*
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koitharu.kotatsu.SampleData
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TrackerTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: TrackingRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var dataRepository: MangaDataRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var tracker: Tracker
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
hiltRule.inject()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noUpdates() = runTest {
|
||||||
|
val manga = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(manga.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(manga, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||||
|
tracker.checkUpdates(manga, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasUpdates() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun badIds() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaBad = loadManga("bad_ids.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun badIds2() = runTest {
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaBad = loadManga("bad_ids.json")
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
tracker.deleteTrack(mangaFirst.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullReset() = runTest {
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
val mangaEmpty = loadManga("empty.json")
|
||||||
|
tracker.deleteTrack(mangaFull.id)
|
||||||
|
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncWithHistory() = runTest {
|
||||||
|
val mangaFull = loadManga("full.json")
|
||||||
|
val mangaFirst = loadManga("first_chapters.json")
|
||||||
|
tracker.deleteTrack(mangaFull.id)
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||||
|
assertFalse(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assertEquals(3, newChapters.size)
|
||||||
|
}
|
||||||
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
||||||
|
repository.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
|
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||||
|
repository.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||||
|
assertTrue(isValid)
|
||||||
|
assert(newChapters.isEmpty())
|
||||||
|
}
|
||||||
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadManga(name: String): Manga {
|
||||||
|
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
||||||
|
dataRepository.storeManga(manga)
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.runner.AndroidJUnitRunner
|
|
||||||
import dagger.hilt.android.testing.HiltTestApplication
|
|
||||||
|
|
||||||
class HiltTestRunner : AndroidJUnitRunner() {
|
|
||||||
|
|
||||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
|
|
||||||
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||||
@@ -11,13 +10,8 @@ class CurlLoggingInterceptor(
|
|||||||
private val curlOptions: String? = null
|
private val curlOptions: String? = null
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
|
||||||
logRequest(it.networkResponse?.request ?: it.request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logRequest(request: Request) {
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
val curlCmd = StringBuilder()
|
val curlCmd = StringBuilder()
|
||||||
@@ -46,15 +40,15 @@ class CurlLoggingInterceptor(
|
|||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
curlCmd.append(" --compressed")
|
curlCmd.append(" --compressed")
|
||||||
}
|
}
|
||||||
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
|
curlCmd.append(" \"").append(request.url).append('"')
|
||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
|
|
||||||
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
private fun String.escape() = replace("\"", "\\\"")
|
||||||
"\\" + match.value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This parser is just for parser development, it should not be used in releases
|
||||||
|
*/
|
||||||
|
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||||
|
|
||||||
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
|
get() = ConfigKey.Domain("", null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder>
|
||||||
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.StrictMode
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
|
||||||
|
|
||||||
var isLeakCanaryEnabled: Boolean
|
|
||||||
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
|
|
||||||
set(value) {
|
|
||||||
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
|
|
||||||
configureLeakCanary()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
|
||||||
super.attachBaseContext(base)
|
|
||||||
enableStrictMode()
|
|
||||||
configureLeakCanary()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureLeakCanary() {
|
|
||||||
LeakCanary.config = LeakCanary.config.copy(
|
|
||||||
dumpHeap = isLeakCanaryEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
|
||||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
StrictModeNotifier(this)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
StrictMode.ThreadPolicy.Builder().apply {
|
|
||||||
detectNetwork()
|
|
||||||
detectDiskWrites()
|
|
||||||
detectCustomSlowCalls()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
|
||||||
penaltyLog()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
|
||||||
penaltyListener(notifier.executor, notifier)
|
|
||||||
}
|
|
||||||
}.build(),
|
|
||||||
)
|
|
||||||
StrictMode.setVmPolicy(
|
|
||||||
StrictMode.VmPolicy.Builder().apply {
|
|
||||||
detectActivityLeaks()
|
|
||||||
detectLeakedSqlLiteObjects()
|
|
||||||
detectLeakedClosableObjects()
|
|
||||||
detectLeakedRegistrationObjects()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
|
||||||
detectFileUriExposure()
|
|
||||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
|
||||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
|
||||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
|
||||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
|
||||||
penaltyLog()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
|
||||||
penaltyListener(notifier.executor, notifier)
|
|
||||||
}
|
|
||||||
}.build(),
|
|
||||||
)
|
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
|
||||||
detectWrongFragmentContainer()
|
|
||||||
detectFragmentTagUsage()
|
|
||||||
detectRetainInstanceUsage()
|
|
||||||
detectSetUserVisibleHint()
|
|
||||||
detectWrongNestedHierarchy()
|
|
||||||
detectFragmentReuse()
|
|
||||||
penaltyLog()
|
|
||||||
if (notifier != null) {
|
|
||||||
penaltyListener(notifier)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val PREFS_DEBUG = "_debug"
|
|
||||||
const val KEY_LEAK_CANARY = "leak_canary"
|
|
||||||
|
|
||||||
fun getDebugPreferences(context: Context): SharedPreferences =
|
|
||||||
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package org.koitharu.kotatsu
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.Notification.BigTextStyle
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.StrictMode
|
|
||||||
import android.os.strictmode.Violation
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.asExecutor
|
|
||||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
|
||||||
class StrictModeNotifier(
|
|
||||||
private val context: Context,
|
|
||||||
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
|
||||||
|
|
||||||
val executor = Dispatchers.Default.asExecutor()
|
|
||||||
|
|
||||||
private val notificationManager by lazy {
|
|
||||||
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
context.getString(R.string.strict_mode),
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
)
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
nm
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVmViolation(v: Violation) = showNotification(v)
|
|
||||||
|
|
||||||
override fun onThreadViolation(v: Violation) = showNotification(v)
|
|
||||||
|
|
||||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
|
||||||
|
|
||||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_bug)
|
|
||||||
.setContentTitle(context.getString(R.string.strict_mode))
|
|
||||||
.setContentText(violation.message)
|
|
||||||
.setStyle(
|
|
||||||
BigTextStyle()
|
|
||||||
.setBigContentTitle(context.getString(R.string.strict_mode))
|
|
||||||
.setSummaryText(violation.message)
|
|
||||||
.bigText(violation.stackTraceToString()),
|
|
||||||
).setShowWhen(true)
|
|
||||||
.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
context,
|
|
||||||
violation.hashCode(),
|
|
||||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setGroup(CHANNEL_ID)
|
|
||||||
.build()
|
|
||||||
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val CHANNEL_ID = "strict_mode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.LifecycleService
|
|
||||||
import leakcanary.AppWatcher
|
|
||||||
|
|
||||||
abstract class BaseService : LifecycleService() {
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context) {
|
|
||||||
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
AppWatcher.objectWatcher.watch(
|
|
||||||
watchedObject = this,
|
|
||||||
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
|
|
||||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
|
||||||
|
|
||||||
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
|
|
||||||
"Calling this from the main thread is prohibited"
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.core.view.MenuProvider
|
|
||||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.KotatsuApp
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.workinspector.WorkInspector
|
|
||||||
|
|
||||||
class SettingsMenuProvider(
|
|
||||||
private val context: Context,
|
|
||||||
) : MenuProvider {
|
|
||||||
|
|
||||||
private val application: KotatsuApp
|
|
||||||
get() = context.applicationContext as KotatsuApp
|
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
|
||||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareMenu(menu: Menu) {
|
|
||||||
super.onPrepareMenu(menu)
|
|
||||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
|
||||||
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
|
||||||
R.id.action_leaks -> {
|
|
||||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_works -> {
|
|
||||||
context.startActivity(WorkInspector.getIntent(context))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_leakcanary -> {
|
|
||||||
val checked = !menuItem.isChecked
|
|
||||||
menuItem.isChecked = checked
|
|
||||||
application.isLeakCanaryEnabled = checked
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_ssiv_debug -> {
|
|
||||||
val checked = !menuItem.isChecked
|
|
||||||
menuItem.isChecked = checked
|
|
||||||
SubsamplingScaleImageView.isDebug = checked
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<group android:scaleX="0.98150784"
|
|
||||||
android:scaleY="0.98150784"
|
|
||||||
android:translateX="0.22190611"
|
|
||||||
android:translateY="-0.2688478">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 417 B |
|
Before Width: | Height: | Size: 308 B |
|
Before Width: | Height: | Size: 480 B |
|
Before Width: | Height: | Size: 792 B |
@@ -1,30 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<menu
|
<menu
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_ssiv_debug"
|
android:id="@id/action_leaks"
|
||||||
android:checkable="true"
|
|
||||||
android:title="SSIV debug"
|
|
||||||
app:showAsAction="never"
|
|
||||||
tools:ignore="HardcodedText" />
|
|
||||||
<item
|
|
||||||
android:id="@+id/action_leakcanary"
|
|
||||||
android:checkable="true"
|
|
||||||
android:title="LeakCanary"
|
|
||||||
app:showAsAction="never"
|
|
||||||
tools:ignore="HardcodedText" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@+id/action_leaks"
|
|
||||||
android:title="@string/leak_canary_display_activity_label"
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
</menu>
|
||||||
android:id="@+id/action_works"
|
|
||||||
android:title="@string/wi_lib_name"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
</menu>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
||||||
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
|
<bool name="is_sync_enabled">true</bool>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="account_type_sync" translatable="false">org.kotatsu.debug.sync</string>
|
|
||||||
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.debug.history</string>
|
|
||||||
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.debug.favourites</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
<string name="strict_mode">Strict mode</string>
|
</resources>
|
||||||
</resources>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
|
||||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
|
||||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
|
||||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
|
||||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
|
||||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
|
||||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
|
||||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
|
||||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
|
||||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
|
||||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
|
||||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
|
||||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
|
||||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
|
||||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
|
||||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
|
||||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
|
||||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
|
||||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
|
||||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
|
||||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
|
||||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
|
||||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
|
||||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
|
||||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
|
||||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
|
||||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
|
||||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
|
||||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,54 +1,42 @@
|
|||||||
package org.koitharu.kotatsu.core
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.StrictMode
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.acra.ACRA
|
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
import org.acra.config.httpSender
|
import org.acra.config.httpSender
|
||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import org.acra.sender.HttpSender
|
import org.acra.sender.HttpSender
|
||||||
import org.conscrypt.Conscrypt
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
|
||||||
import org.koitharu.kotatsu.core.os.RomCompat
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
|
||||||
import java.security.Security
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class BaseApp : Application(), Configuration.Provider {
|
class KotatsuApp : Application(), Configuration.Provider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var databaseObserversProvider: Provider<Set<@JvmSuppressWildcards InvalidationTracker.Observer>>
|
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var database: Provider<MangaDatabase>
|
lateinit var database: MangaDatabase
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
@@ -56,58 +44,27 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var appValidator: AppValidator
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workScheduleManager: WorkScheduleManager
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
@LocalStorageChanges
|
|
||||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
|
||||||
get() = Configuration.Builder()
|
|
||||||
.setWorkerFactory(workerFactory)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (ACRA.isACRASenderServiceProcess()) {
|
if (BuildConfig.DEBUG) {
|
||||||
return
|
enableStrictMode()
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
// TLS 1.3 support for Android < 10
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
||||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
|
||||||
}
|
|
||||||
setupActivityLifecycleCallbacks()
|
setupActivityLifecycleCallbacks()
|
||||||
processLifecycleScope.launch {
|
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
|
|
||||||
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
|
|
||||||
}
|
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
if (ACRA.isACRASenderServiceProcess()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.JSON
|
reportFormat = StringFormat.JSON
|
||||||
|
excludeMatchingSharedPreferencesKeys = listOf(
|
||||||
|
"sources_\\w+",
|
||||||
|
)
|
||||||
httpSender {
|
httpSender {
|
||||||
uri = getString(R.string.url_error_report)
|
uri = getString(R.string.url_error_report)
|
||||||
basicAuthLogin = getString(R.string.acra_login)
|
basicAuthLogin = getString(R.string.acra_login)
|
||||||
@@ -123,9 +80,8 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
ReportField.PHONE_MODEL,
|
ReportField.PHONE_MODEL,
|
||||||
ReportField.STACK_TRACE,
|
ReportField.STACK_TRACE,
|
||||||
ReportField.CRASH_CONFIGURATION,
|
ReportField.CRASH_CONFIGURATION,
|
||||||
ReportField.CUSTOM_DATA,
|
ReportField.SHARED_PREFERENCES,
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
text = getString(R.string.crash_text)
|
text = getString(R.string.crash_text)
|
||||||
title = getString(R.string.error_occurred)
|
title = getString(R.string.error_occurred)
|
||||||
@@ -136,10 +92,16 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkManagerConfiguration(): Configuration {
|
||||||
|
return Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun setupDatabaseObservers() {
|
private fun setupDatabaseObservers() {
|
||||||
val tracker = database.get().invalidationTracker
|
val tracker = database.invalidationTracker
|
||||||
databaseObserversProvider.get().forEach {
|
databaseObservers.forEach {
|
||||||
tracker.addObserver(it)
|
tracker.addObserver(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,4 +111,30 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
registerActivityLifecycleCallbacks(it)
|
registerActivityLifecycleCallbacks(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enableStrictMode() {
|
||||||
|
StrictMode.setThreadPolicy(
|
||||||
|
StrictMode.ThreadPolicy.Builder()
|
||||||
|
.detectAll()
|
||||||
|
.penaltyLog()
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
StrictMode.setVmPolicy(
|
||||||
|
StrictMode.VmPolicy.Builder()
|
||||||
|
.detectAll()
|
||||||
|
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||||
|
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
|
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
|
.penaltyLog()
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
|
.penaltyDeath()
|
||||||
|
.detectFragmentReuse()
|
||||||
|
.detectWrongFragmentContainer()
|
||||||
|
.detectRetainInstanceUsage()
|
||||||
|
.detectSetUserVisibleHint()
|
||||||
|
.detectFragmentTagUsage()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package org.koitharu.kotatsu.base.domain
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Size
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val MIN_WEBTOON_RATIO = 2
|
||||||
|
|
||||||
|
class MangaDataRepository @Inject constructor(
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
||||||
|
db.withTransaction {
|
||||||
|
storeManga(manga)
|
||||||
|
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
|
||||||
|
db.preferencesDao.upsert(entity.copy(mode = mode.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
|
||||||
|
db.withTransaction {
|
||||||
|
storeManga(manga)
|
||||||
|
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
|
||||||
|
db.preferencesDao.upsert(
|
||||||
|
entity.copy(
|
||||||
|
cfBrightness = colorFilter?.brightness ?: 0f,
|
||||||
|
cfContrast = colorFilter?.contrast ?: 0f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
|
||||||
|
return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
|
||||||
|
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
|
||||||
|
return db.preferencesDao.observe(mangaId)
|
||||||
|
.map { it?.getColorFilterOrNull() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findMangaById(mangaId: Long): Manga? {
|
||||||
|
return db.mangaDao.find(mangaId)?.toManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||||
|
intent.manga != null -> intent.manga
|
||||||
|
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||||
|
else -> null // TODO resolve uri
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun storeManga(manga: Manga) {
|
||||||
|
val tags = manga.tags.toEntities()
|
||||||
|
db.withTransaction {
|
||||||
|
db.tagsDao.upsert(tags)
|
||||||
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||||
|
return db.tagsDao.findTags(source.name).toMangaTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
|
||||||
|
return if (cfBrightness != 0f || cfContrast != 0f) {
|
||||||
|
ReaderColorFilter(cfBrightness, cfContrast)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic determine type of manga by page size
|
||||||
|
* @return ReaderMode.WEBTOON if page is wide
|
||||||
|
*/
|
||||||
|
suspend fun determineMangaIsWebtoon(repository: MangaRepository, pages: List<MangaPage>): Boolean {
|
||||||
|
val pageIndex = (pages.size * 0.3).roundToInt()
|
||||||
|
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
|
||||||
|
val url = repository.getPageUrl(page)
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val size = if (uri.scheme == "cbz") {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
val zip = ZipFile(uri.schemeSpecificPart)
|
||||||
|
val entry = zip.getEntry(uri.fragment)
|
||||||
|
zip.getInputStream(entry).use {
|
||||||
|
getBitmapSize(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.tag(MangaSource::class.java, page.source)
|
||||||
|
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||||
|
.build()
|
||||||
|
okHttpClient.newCall(request).await().use {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
getBitmapSize(it.body?.byteStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size.width * MIN_WEBTOON_RATIO < size.height
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newEntity(mangaId: Long) = MangaPrefsEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
mode = -1,
|
||||||
|
cfBrightness = 0f,
|
||||||
|
cfContrast = 0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.path, options)?.recycle()
|
||||||
|
options.outMimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBitmapSize(input: InputStream?): Size {
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeStream(input, null, options)?.recycle()
|
||||||
|
val imageHeight: Int = options.outHeight
|
||||||
|
val imageWidth: Int = options.outWidth
|
||||||
|
check(imageHeight > 0 && imageWidth > 0)
|
||||||
|
return Size(imageWidth, imageHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.base.domain
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
|
||||||
|
|
||||||
|
class MangaIntent private constructor(
|
||||||
|
val manga: Manga?,
|
||||||
|
val mangaId: Long,
|
||||||
|
val uri: Uri?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(intent: Intent?) : this(
|
||||||
|
manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
|
||||||
|
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||||
|
uri = intent?.data,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(args: Bundle?) : this(
|
||||||
|
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
|
||||||
|
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
|
||||||
|
uri = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ID_NONE = 0L
|
||||||
|
|
||||||
|
const val KEY_MANGA = "manga"
|
||||||
|
const val KEY_ID = "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.domain
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||||
|
|
||||||
fun interface ReversibleHandle {
|
fun interface ReversibleHandle {
|
||||||
|
|
||||||
suspend fun reverse()
|
suspend fun reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
|
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
reverse()
|
reverse()
|
||||||
@@ -23,3 +22,8 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
|
|||||||
it.printStackTraceDebug()
|
it.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
|
||||||
|
this.reverse()
|
||||||
|
other.reverse()
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
@@ -13,17 +12,18 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
|
|
||||||
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||||
|
|
||||||
var viewBinding: B? = null
|
private var viewBinding: B? = null
|
||||||
private set
|
|
||||||
|
protected val binding: B
|
||||||
|
get() = checkNotNull(viewBinding)
|
||||||
|
|
||||||
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val binding = onCreateViewBinding(layoutInflater, null)
|
val binding = onInflateView(layoutInflater, null)
|
||||||
viewBinding = binding
|
viewBinding = binding
|
||||||
return MaterialAlertDialogBuilder(requireContext(), theme)
|
return MaterialAlertDialogBuilder(requireContext(), theme)
|
||||||
.setView(binding.root)
|
.setView(binding.root)
|
||||||
.run(::onBuildDialog)
|
.run(::onBuildDialog)
|
||||||
.create()
|
.create()
|
||||||
.also(::onDialogCreated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun onCreateView(
|
final override fun onCreateView(
|
||||||
@@ -32,11 +32,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
|||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?,
|
||||||
) = viewBinding?.root
|
) = viewBinding?.root
|
||||||
|
|
||||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
onViewBindingCreated(requireViewBinding(), savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
viewBinding = null
|
viewBinding = null
|
||||||
@@ -47,11 +42,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
|||||||
|
|
||||||
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
||||||
|
|
||||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
protected fun bindingOrNull(): B? = viewBinding
|
||||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|
||||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
|
||||||
}
|
}
|
||||||
147
app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.appcompat.widget.ActionBarContextView
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.inject
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
|
AppCompatActivity(),
|
||||||
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
protected lateinit var binding: B
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
protected val exceptionResolver = ExceptionResolver(this)
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
val actionModeDelegate = ActionModeDelegate()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
|
||||||
|
setTheme(settings.colorScheme.styleResId)
|
||||||
|
if (settings.isAmoledTheme) {
|
||||||
|
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||||
|
}
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
insetsDelegate.handleImeInsets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||||
|
override fun setContentView(layoutResID: Int) {
|
||||||
|
super.setContentView(layoutResID)
|
||||||
|
setupToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||||
|
override fun setContentView(view: View?) {
|
||||||
|
super.setContentView(view)
|
||||||
|
setupToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun setContentView(binding: B) {
|
||||||
|
this.binding = binding
|
||||||
|
super.setContentView(binding.root)
|
||||||
|
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
|
||||||
|
toolbar?.let(this::setSupportActionBar)
|
||||||
|
insetsDelegate.onViewCreated(binding.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
onBackPressed()
|
||||||
|
true
|
||||||
|
} else super.onOptionsItemSelected(item)
|
||||||
|
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||||
|
ActivityCompat.recreate(this)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupToolbar() {
|
||||||
|
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun isDarkAmoledTheme(): Boolean {
|
||||||
|
val uiMode = resources.configuration.uiMode
|
||||||
|
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
return isNight && settings.isAmoledTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeStarted(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||||
|
val actionModeColor = ColorUtils.compositeColors(
|
||||||
|
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
|
||||||
|
getThemeColor(com.google.android.material.R.attr.colorSurface),
|
||||||
|
)
|
||||||
|
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||||
|
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||||
|
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
|
||||||
|
setBackgroundColor(actionModeColor)
|
||||||
|
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
topMargin = insets.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.statusBarColor = actionModeColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeFinished(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||||
|
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
|
||||||
|
@Deprecated("Should not be used")
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if ( // https://issuetracker.google.com/issues/139738913
|
||||||
|
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
||||||
|
isTaskRoot &&
|
||||||
|
supportFragmentManager.backStackEntryCount == 0
|
||||||
|
) {
|
||||||
|
finishAfterTransition()
|
||||||
|
} else {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
|
||||||
|
import org.koitharu.kotatsu.utils.ext.displayCompat
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var viewBinding: B? = null
|
||||||
|
|
||||||
|
protected val binding: B
|
||||||
|
get() = checkNotNull(viewBinding)
|
||||||
|
|
||||||
|
protected val behavior: BottomSheetBehavior<*>?
|
||||||
|
get() = (dialog as? BottomSheetDialog)?.behavior
|
||||||
|
|
||||||
|
val isExpanded: Boolean
|
||||||
|
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
|
||||||
|
val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||||
|
get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
|
||||||
|
|
||||||
|
final override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
val binding = onInflateView(inflater, container)
|
||||||
|
viewBinding = binding
|
||||||
|
|
||||||
|
// Enforce max width for tablets
|
||||||
|
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
|
||||||
|
if (width > 0) {
|
||||||
|
behavior?.maxWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set peek height to 50% display height
|
||||||
|
requireContext().displayCompat?.let {
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
it.getRealMetrics(metrics)
|
||||||
|
behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
viewBinding = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return AppBottomSheetDialog(requireContext(), theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
|
||||||
|
val b = behavior ?: return
|
||||||
|
b.addBottomSheetCallback(callback)
|
||||||
|
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||||
|
if (rootView != null) {
|
||||||
|
callback.onStateChanged(rootView, b.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|
||||||
|
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
|
||||||
|
val b = behavior ?: return
|
||||||
|
if (isExpanded) {
|
||||||
|
b.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
b.isFitToContents = !isExpanded
|
||||||
|
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
||||||
|
rootView?.updateLayoutParams {
|
||||||
|
height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
|
||||||
|
}
|
||||||
|
b.isDraggable = !isLocked
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
|
||||||
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
|
Fragment(),
|
||||||
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
|
private var viewBinding: B? = null
|
||||||
|
|
||||||
|
protected val binding: B
|
||||||
|
get() = checkNotNull(viewBinding)
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
protected val exceptionResolver = ExceptionResolver(this)
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
protected val actionModeDelegate: ActionModeDelegate
|
||||||
|
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val binding = onInflateView(inflater, container)
|
||||||
|
viewBinding = binding
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
insetsDelegate.onViewCreated(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
viewBinding = null
|
||||||
|
insetsDelegate.onDestroyView()
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun bindingOrNull() = viewBinding
|
||||||
|
|
||||||
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||||
|
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
|
||||||
|
abstract class BaseFullscreenActivity<B : ViewBinding> :
|
||||||
|
BaseActivity<B>(),
|
||||||
|
View.OnSystemUiVisibilityChangeListener {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
with(window) {
|
||||||
|
statusBarColor = Color.TRANSPARENT
|
||||||
|
navigationBarColor = Color.TRANSPARENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
attributes.layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
|
decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity)
|
||||||
|
}
|
||||||
|
showSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
final override fun onSystemUiVisibilityChange(visibility: Int) {
|
||||||
|
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO WindowInsetsControllerCompat works incorrect
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
protected fun hideSystemUI() {
|
||||||
|
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
protected fun showSystemUI() {
|
||||||
|
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||||
|
PreferenceFragmentCompat(),
|
||||||
|
WindowInsetsDelegate.WindowInsetsListener,
|
||||||
|
RecyclerViewOwner {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
override val recyclerView: RecyclerView
|
||||||
|
get() = listView
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
listView.clipToPadding = false
|
||||||
|
insetsDelegate.onViewCreated(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
insetsDelegate.onDestroyView()
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (titleId != 0) {
|
||||||
|
setTitle(getString(titleId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
listView.updatePadding(
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UsePropertyAccessSyntax")
|
||||||
|
protected fun setTitle(title: CharSequence) {
|
||||||
|
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||||
|
?: activity?.setTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
|
||||||
|
abstract class BaseService : LifecycleService()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||||
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
|
|
||||||
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
|
||||||
|
protected val loadingCounter = CountedBooleanLiveData()
|
||||||
|
protected val errorEvent = SingleLiveEvent<Throwable>()
|
||||||
|
|
||||||
|
val onError: LiveData<Throwable>
|
||||||
|
get() = errorEvent
|
||||||
|
|
||||||
|
val isLoading: LiveData<Boolean>
|
||||||
|
get() = loadingCounter
|
||||||
|
|
||||||
|
protected fun launchJob(
|
||||||
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||||
|
|
||||||
|
protected fun launchLoadingJob(
|
||||||
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
|
block: suspend CoroutineScope.() -> Unit
|
||||||
|
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||||
|
loadingCounter.increment()
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
loadingCounter.decrement()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
throwable.printStackTraceDebug()
|
||||||
|
if (throwable !is CancellationException) {
|
||||||
|
errorEvent.postCall(throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
|
|
||||||
|
abstract class CoroutineIntentService : BaseService() {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
launchCoroutine(intent, startId)
|
||||||
|
return Service.START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
if (intent != null) {
|
||||||
|
withContext(dispatcher) {
|
||||||
|
processIntent(startId, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
onError(startId, e)
|
||||||
|
} finally {
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
|
||||||
|
|
||||||
|
protected abstract fun onError(startId: Int, error: Throwable)
|
||||||
|
|
||||||
|
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
throwable.printStackTraceDebug()
|
||||||
|
onError(startId, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Application.ActivityLifecycleCallbacks
|
import android.app.Application.ActivityLifecycleCallbacks
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.View
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
|
||||||
|
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/material-components/material-components-android/issues/2582
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
val window = window
|
||||||
|
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
if (window != null) {
|
||||||
|
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
||||||
|
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
||||||
|
if (drawEdgeToEdge) {
|
||||||
|
// Copied from super.onAttachedToWindow:
|
||||||
|
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
// Fix super-class's window flag bug by respecting the initial system UI visibility:
|
||||||
|
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||||
|
|
||||||
|
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
|
||||||
|
DialogInterface by delegate {
|
||||||
|
|
||||||
|
fun show() = delegate.show()
|
||||||
|
|
||||||
|
class Builder(context: Context) {
|
||||||
|
|
||||||
|
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||||
|
|
||||||
|
private val delegate = MaterialAlertDialogBuilder(context)
|
||||||
|
.setView(binding.root)
|
||||||
|
|
||||||
|
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||||
|
delegate.setTitle(titleResId)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(title: CharSequence): Builder {
|
||||||
|
delegate.setTitle(title)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessage(@StringRes messageId: Int): Builder {
|
||||||
|
delegate.setMessage(messageId)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessage(message: CharSequence): Builder {
|
||||||
|
delegate.setMessage(message)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCheckBoxText(@StringRes textId: Int): Builder {
|
||||||
|
binding.checkbox.setText(textId)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCheckBoxChecked(isChecked: Boolean): Builder {
|
||||||
|
binding.checkbox.isChecked = isChecked
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIcon(@DrawableRes iconId: Int): Builder {
|
||||||
|
delegate.setIcon(iconId)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPositiveButton(
|
||||||
|
@StringRes textId: Int,
|
||||||
|
listener: (DialogInterface, Boolean) -> Unit
|
||||||
|
): Builder {
|
||||||
|
delegate.setPositiveButton(textId) { dialog, _ ->
|
||||||
|
listener(dialog, binding.checkbox.isChecked)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNegativeButton(
|
||||||
|
@StringRes textId: Int,
|
||||||
|
listener: DialogInterface.OnClickListener? = null
|
||||||
|
): Builder {
|
||||||
|
delegate.setNegativeButton(textId, listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create() = CheckBoxAlertDialog(delegate.create())
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
package org.koitharu.kotatsu.base.ui.dialog
|
||||||
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@ class RememberSelectionDialogListener(initialValue: Int) : DialogInterface.OnCli
|
|||||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||||
selection = which
|
selection = which
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.BaseAdapter
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||||
|
DialogInterface by delegate {
|
||||||
|
|
||||||
|
fun show() = delegate.show()
|
||||||
|
|
||||||
|
class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) {
|
||||||
|
|
||||||
|
private val adapter = VolumesAdapter(storageManager)
|
||||||
|
private val delegate = MaterialAlertDialogBuilder(context)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (adapter.isEmpty) {
|
||||||
|
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||||
|
} else {
|
||||||
|
val defaultValue = runBlocking {
|
||||||
|
storageManager.getDefaultWriteableDir()
|
||||||
|
}
|
||||||
|
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||||
|
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||||
|
}
|
||||||
|
delegate.setAdapter(adapter) { d, i ->
|
||||||
|
listener.onStorageSelected(adapter.getItem(i).first)
|
||||||
|
d.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||||
|
delegate.setTitle(titleResId)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(title: CharSequence): Builder {
|
||||||
|
delegate.setTitle(title)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNegativeButton(@StringRes textId: Int): Builder {
|
||||||
|
delegate.setNegativeButton(textId, null)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create() = StorageSelectDialog(delegate.create())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() {
|
||||||
|
|
||||||
|
var selectedItemPosition: Int = -1
|
||||||
|
val volumes = getAvailableVolumes(storageManager)
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||||
|
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||||
|
view.tag = it
|
||||||
|
}
|
||||||
|
val item = volumes[position]
|
||||||
|
binding.imageViewIndicator.isChecked = selectedItemPosition == position
|
||||||
|
binding.textViewTitle.text = item.second
|
||||||
|
binding.textViewSubtitle.text = item.first.path
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItem(position: Int): Pair<File, String> = volumes[position]
|
||||||
|
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getCount() = volumes.size
|
||||||
|
|
||||||
|
override fun hasStableIds() = true
|
||||||
|
|
||||||
|
private fun getAvailableVolumes(storageManager: LocalStorageManager): List<Pair<File, String>> {
|
||||||
|
return runBlocking {
|
||||||
|
storageManager.getWriteableDirs().map {
|
||||||
|
it to storageManager.getStorageDisplayName(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface OnStorageSelectListener {
|
||||||
|
|
||||||
|
fun onStorageSelected(file: File)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.OnClickListener
|
||||||
|
import android.view.View.OnLongClickListener
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||||
|
|
||||||
|
class AdapterDelegateClickListenerAdapter<I>(
|
||||||
|
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||||
|
private val clickListener: OnListItemClickListener<I>,
|
||||||
|
) : OnClickListener, OnLongClickListener {
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
clickListener.onItemClick(adapterDelegate.item, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(v: View): Boolean {
|
||||||
|
return clickListener.onItemLongClick(adapterDelegate.item, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
abstract class BoundsScrollListener(
|
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
|
||||||
@JvmField protected val offsetTop: Int,
|
RecyclerView.OnScrollListener() {
|
||||||
@JvmField protected val offsetBottom: Int
|
|
||||||
) : RecyclerView.OnScrollListener() {
|
constructor(offset: Int = 0) : this(offset, offset)
|
||||||
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
super.onScrolled(recyclerView, dx, dy)
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
if (recyclerView.hasPendingAdapterUpdates()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
||||||
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
|
||||||
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
|
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (firstVisibleItemPosition <= offsetTop) {
|
||||||
|
onScrolledToStart(recyclerView)
|
||||||
|
}
|
||||||
val visibleItemCount = layoutManager.childCount
|
val visibleItemCount = layoutManager.childCount
|
||||||
val totalItemCount = layoutManager.itemCount
|
val totalItemCount = layoutManager.itemCount
|
||||||
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
|
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
|
||||||
onScrolledToEnd(recyclerView)
|
onScrolledToEnd(recyclerView)
|
||||||
}
|
}
|
||||||
if (firstVisibleItemPosition <= offsetTop) {
|
|
||||||
onScrolledToStart(recyclerView)
|
|
||||||
}
|
|
||||||
onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun onScrolledToStart(recyclerView: RecyclerView)
|
abstract fun onScrolledToStart(recyclerView: RecyclerView)
|
||||||
|
|
||||||
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
|
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
|
||||||
|
}
|
||||||
protected open fun onPostScrolled(
|
|
||||||
recyclerView: RecyclerView,
|
|
||||||
firstVisibleItemPosition: Int,
|
|
||||||
visibleItemCount: Int
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
fun invalidate(recyclerView: RecyclerView) {
|
|
||||||
onScrolled(recyclerView, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun postInvalidate(recyclerView: RecyclerView) = recyclerView.post {
|
|
||||||
invalidate(recyclerView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@@ -29,9 +29,9 @@ class FitHeightGridLayoutManager : GridLayoutManager {
|
|||||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||||
val parentBottom = height - paddingBottom
|
val parentBottom = height - paddingBottom
|
||||||
val offset = parentBottom - bottom
|
val offset = parentBottom - bottom
|
||||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset)
|
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||||
} else {
|
} else {
|
||||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@@ -29,9 +29,9 @@ class FitHeightLinearLayoutManager : LinearLayoutManager {
|
|||||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||||
val parentBottom = height - paddingBottom
|
val parentBottom = height - paddingBottom
|
||||||
val offset = parentBottom - bottom
|
val offset = parentBottom - bottom
|
||||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset)
|
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||||
} else {
|
} else {
|
||||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,46 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.PopupMenu
|
|
||||||
import androidx.collection.LongSet
|
|
||||||
import androidx.collection.longSetOf
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.savedstate.SavedStateRegistry
|
import androidx.savedstate.SavedStateRegistry
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLongArray
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||||
|
|
||||||
private const val KEY_SELECTION = "selection"
|
private const val KEY_SELECTION = "selection"
|
||||||
private const val PROVIDER_NAME = "selection_decoration"
|
private const val PROVIDER_NAME = "selection_decoration"
|
||||||
|
|
||||||
class ListSelectionController(
|
class ListSelectionController(
|
||||||
private val appCompatDelegate: AppCompatDelegate,
|
private val activity: Activity,
|
||||||
private val decoration: AbstractSelectionItemDecoration,
|
private val decoration: AbstractSelectionItemDecoration,
|
||||||
private val registryOwner: SavedStateRegistryOwner,
|
private val registryOwner: SavedStateRegistryOwner,
|
||||||
private val callback: Callback,
|
private val callback: Callback2,
|
||||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||||
|
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
private var focusedItemId: LongSet? = null
|
|
||||||
|
|
||||||
var useActionMode: Boolean = true
|
|
||||||
|
|
||||||
val count: Int
|
val count: Int
|
||||||
get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount
|
get() = decoration.checkedItemsCount
|
||||||
|
|
||||||
init {
|
init {
|
||||||
registryOwner.lifecycle.addObserver(StateEventObserver())
|
registryOwner.lifecycle.addObserver(StateEventObserver())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(): Set<Long> = (focusedItemId ?: peekCheckedIds()).toSet()
|
fun snapshot(): Set<Long> {
|
||||||
|
return peekCheckedIds().toSet()
|
||||||
|
}
|
||||||
|
|
||||||
fun peekCheckedIds(): LongSet {
|
fun peekCheckedIds(): Set<Long> {
|
||||||
return focusedItemId ?: decoration.checkedItemsIds
|
return decoration.checkedItemsIds
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -59,7 +52,6 @@ class ListSelectionController(
|
|||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startActionMode()
|
|
||||||
decoration.checkAll(ids)
|
decoration.checkAll(ids)
|
||||||
notifySelectionChanged()
|
notifySelectionChanged()
|
||||||
}
|
}
|
||||||
@@ -88,42 +80,16 @@ class ListSelectionController(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemLongClick(view: View, id: Long): Boolean {
|
fun onItemLongClick(id: Long): Boolean {
|
||||||
return if (useActionMode) {
|
startActionMode()
|
||||||
startSelection(id)
|
return actionMode?.also {
|
||||||
} else {
|
decoration.setItemIsChecked(id, true)
|
||||||
onItemContextClick(view, id)
|
notifySelectionChanged()
|
||||||
}
|
} != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onItemContextClick(view: View, id: Long): Boolean {
|
|
||||||
focusedItemId = longSetOf(id)
|
|
||||||
val menu = PopupMenu(view.context, view)
|
|
||||||
callback.onCreateActionMode(this, menu.menuInflater, menu.menu)
|
|
||||||
callback.onPrepareActionMode(this, null, menu.menu)
|
|
||||||
menu.setForceShowIcon(true)
|
|
||||||
if (menu.menu.hasVisibleItems()) {
|
|
||||||
menu.setOnMenuItemClickListener { menuItem ->
|
|
||||||
callback.onActionItemClicked(this, null, menuItem)
|
|
||||||
}
|
|
||||||
menu.setOnDismissListener {
|
|
||||||
focusedItemId = null
|
|
||||||
}
|
|
||||||
menu.show()
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
focusedItemId = null
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startSelection(id: Long): Boolean = startActionMode()?.also {
|
|
||||||
decoration.setItemIsChecked(id, true)
|
|
||||||
notifySelectionChanged()
|
|
||||||
} != null
|
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
return callback.onCreateActionMode(this, mode.menuInflater, menu)
|
return callback.onCreateActionMode(this, mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
@@ -140,10 +106,9 @@ class ListSelectionController(
|
|||||||
actionMode = null
|
actionMode = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startActionMode(): ActionMode? {
|
private fun startActionMode() {
|
||||||
focusedItemId = null
|
if (actionMode == null) {
|
||||||
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
|
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||||
actionMode = it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,18 +131,54 @@ class ListSelectionController(
|
|||||||
notifySelectionChanged()
|
notifySelectionChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback {
|
@Deprecated("")
|
||||||
|
interface Callback : Callback2 {
|
||||||
|
|
||||||
|
fun onSelectionChanged(count: Int)
|
||||||
|
|
||||||
|
fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
|
||||||
|
|
||||||
|
fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
|
||||||
|
|
||||||
|
fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
|
||||||
|
|
||||||
|
fun onDestroyActionMode(mode: ActionMode) = Unit
|
||||||
|
|
||||||
|
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||||
|
onSelectionChanged(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
return onCreateActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
return onPrepareActionMode(mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(
|
||||||
|
controller: ListSelectionController,
|
||||||
|
mode: ActionMode,
|
||||||
|
item: MenuItem,
|
||||||
|
): Boolean = onActionItemClicked(mode, item)
|
||||||
|
|
||||||
|
override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) {
|
||||||
|
onDestroyActionMode(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback2 {
|
||||||
|
|
||||||
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
fun onSelectionChanged(controller: ListSelectionController, count: Int)
|
||||||
|
|
||||||
fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean
|
fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
|
||||||
|
|
||||||
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
|
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode?.title = controller.count.toString()
|
mode.title = controller.count.toString()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean
|
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
|
||||||
|
|
||||||
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
|
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
|
||||||
|
interface OnListItemClickListener<I> {
|
||||||
|
|
||||||
|
fun onItemClick(item: I, view: View)
|
||||||
|
|
||||||
|
fun onItemLongClick(item: I, view: View) = false
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
@@ -15,4 +15,4 @@ class PaginationScrollListener(offset: Int, private val callback: Callback) :
|
|||||||
|
|
||||||
fun onScrolledToEnd()
|
fun onScrolledToEnd()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
|
private const val PROVIDER_NAME = "selection_decoration_sectioned"
|
||||||
|
|
||||||
|
class SectionedSelectionController<T : Any>(
|
||||||
|
private val activity: Activity,
|
||||||
|
private val owner: SavedStateRegistryOwner,
|
||||||
|
private val callback: Callback<T>,
|
||||||
|
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
||||||
|
|
||||||
|
private var actionMode: ActionMode? = null
|
||||||
|
|
||||||
|
private var pendingData: MutableMap<String, Collection<Long>>? = null
|
||||||
|
private val decorations = ArrayMap<T, AbstractSelectionItemDecoration>()
|
||||||
|
|
||||||
|
val count: Int
|
||||||
|
get() = decorations.values.sumOf { it.checkedItemsCount }
|
||||||
|
|
||||||
|
init {
|
||||||
|
owner.lifecycle.addObserver(StateEventObserver())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): Map<T, Set<Long>> {
|
||||||
|
return decorations.mapValues { it.value.checkedItemsIds.toSet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peekCheckedIds(): Map<T, Set<Long>> {
|
||||||
|
return decorations.mapValues { it.value.checkedItemsIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
decorations.values.forEach {
|
||||||
|
it.clearSelection()
|
||||||
|
}
|
||||||
|
notifySelectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
|
||||||
|
val decoration = getDecoration(section)
|
||||||
|
val pendingIds = pendingData?.remove(section.toString())
|
||||||
|
if (!pendingIds.isNullOrEmpty()) {
|
||||||
|
decoration.checkAll(pendingIds)
|
||||||
|
startActionMode()
|
||||||
|
notifySelectionChanged()
|
||||||
|
}
|
||||||
|
var shouldAddDecoration = true
|
||||||
|
for (i in (0 until recyclerView.itemDecorationCount).reversed()) {
|
||||||
|
val decor = recyclerView.getItemDecorationAt(i)
|
||||||
|
if (decor === decoration) {
|
||||||
|
shouldAddDecoration = false
|
||||||
|
break
|
||||||
|
} else if (decor.javaClass == decoration.javaClass) {
|
||||||
|
recyclerView.removeItemDecorationAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldAddDecoration) {
|
||||||
|
recyclerView.addItemDecoration(decoration)
|
||||||
|
}
|
||||||
|
if (pendingData?.isEmpty() == true) {
|
||||||
|
pendingData = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveState(): Bundle {
|
||||||
|
val bundle = Bundle(decorations.size)
|
||||||
|
for ((k, v) in decorations) {
|
||||||
|
bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
|
||||||
|
}
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemClick(section: T, id: Long): Boolean {
|
||||||
|
val decoration = getDecoration(section)
|
||||||
|
if (isInSelectionMode()) {
|
||||||
|
decoration.toggleItemChecked(id)
|
||||||
|
if (isInSelectionMode()) {
|
||||||
|
actionMode?.invalidate()
|
||||||
|
} else {
|
||||||
|
actionMode?.finish()
|
||||||
|
}
|
||||||
|
notifySelectionChanged()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemLongClick(section: T, id: Long): Boolean {
|
||||||
|
val decoration = getDecoration(section)
|
||||||
|
startActionMode()
|
||||||
|
return actionMode?.also {
|
||||||
|
decoration.setItemIsChecked(id, true)
|
||||||
|
notifySelectionChanged()
|
||||||
|
} != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSectionCount(section: T): Int {
|
||||||
|
return decorations[section]?.checkedItemsCount ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToSelection(section: T, ids: Collection<Long>): Boolean {
|
||||||
|
val decoration = getDecoration(section)
|
||||||
|
startActionMode()
|
||||||
|
return actionMode?.also {
|
||||||
|
decoration.checkAll(ids)
|
||||||
|
notifySelectionChanged()
|
||||||
|
} != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection(section: T) {
|
||||||
|
decorations[section]?.clearSelection() ?: return
|
||||||
|
notifySelectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
return callback.onCreateActionMode(this, mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
return callback.onPrepareActionMode(this, mode, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return callback.onActionItemClicked(this, mode, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
|
callback.onDestroyActionMode(this, mode)
|
||||||
|
clear()
|
||||||
|
actionMode = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startActionMode() {
|
||||||
|
if (actionMode == null) {
|
||||||
|
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInSelectionMode(): Boolean {
|
||||||
|
return decorations.values.any { x -> x.checkedItemsCount > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifySelectionChanged() {
|
||||||
|
val count = this.count
|
||||||
|
callback.onSelectionChanged(this, count)
|
||||||
|
if (count == 0) {
|
||||||
|
actionMode?.finish()
|
||||||
|
} else {
|
||||||
|
actionMode?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreState(ids: MutableMap<String, Collection<Long>>) {
|
||||||
|
if (ids.isEmpty() || isInSelectionMode()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for ((k, v) in decorations) {
|
||||||
|
val items = ids.remove(k.toString())
|
||||||
|
if (!items.isNullOrEmpty()) {
|
||||||
|
v.checkAll(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingData = ids
|
||||||
|
if (isInSelectionMode()) {
|
||||||
|
startActionMode()
|
||||||
|
notifySelectionChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDecoration(section: T): AbstractSelectionItemDecoration {
|
||||||
|
return decorations.getOrPut(section) {
|
||||||
|
callback.onCreateItemDecoration(this, section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback<T : Any> {
|
||||||
|
|
||||||
|
fun onSelectionChanged(controller: SectionedSelectionController<T>, count: Int)
|
||||||
|
|
||||||
|
fun onCreateActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean
|
||||||
|
|
||||||
|
fun onPrepareActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.title = controller.count.toString()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDestroyActionMode(controller: SectionedSelectionController<T>, mode: ActionMode) = Unit
|
||||||
|
|
||||||
|
fun onActionItemClicked(
|
||||||
|
controller: SectionedSelectionController<T>,
|
||||||
|
mode: ActionMode,
|
||||||
|
item: MenuItem,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
fun onCreateItemDecoration(
|
||||||
|
controller: SectionedSelectionController<T>,
|
||||||
|
section: T,
|
||||||
|
): AbstractSelectionItemDecoration
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class StateEventObserver : LifecycleEventObserver {
|
||||||
|
|
||||||
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
|
if (event == Lifecycle.Event.ON_CREATE) {
|
||||||
|
val registry = owner.savedStateRegistry
|
||||||
|
registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
|
||||||
|
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
|
||||||
|
if (state != null) {
|
||||||
|
Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
|
||||||
|
if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
|
||||||
|
restoreState(
|
||||||
|
state.keySet()
|
||||||
|
.associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.content.res.getColorOrThrow
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val bounds = Rect()
|
||||||
|
private val thickness: Int
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
init {
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
val ta = context.obtainStyledAttributes(
|
||||||
|
null,
|
||||||
|
materialR.styleable.MaterialDivider,
|
||||||
|
materialR.attr.materialDividerStyle,
|
||||||
|
materialR.style.Widget_Material3_MaterialDivider,
|
||||||
|
)
|
||||||
|
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
|
||||||
|
thickness = ta.getDimensionPixelSize(
|
||||||
|
materialR.styleable.MaterialDivider_dividerThickness,
|
||||||
|
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
|
||||||
|
)
|
||||||
|
ta.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun getItemOffsets(
|
||||||
|
outRect: Rect,
|
||||||
|
view: View,
|
||||||
|
parent: RecyclerView,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) {
|
||||||
|
outRect.set(0, thickness, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement for horizontal lists on demand
|
||||||
|
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||||
|
if (parent.layoutManager == null || thickness == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas.save()
|
||||||
|
val left: Float
|
||||||
|
val right: Float
|
||||||
|
if (parent.clipToPadding) {
|
||||||
|
left = parent.paddingLeft.toFloat()
|
||||||
|
right = (parent.width - parent.paddingRight).toFloat()
|
||||||
|
canvas.clipRect(
|
||||||
|
left,
|
||||||
|
parent.paddingTop.toFloat(),
|
||||||
|
right,
|
||||||
|
(parent.height - parent.paddingBottom).toFloat()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
left = 0f
|
||||||
|
right = parent.width.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous: RecyclerView.ViewHolder? = null
|
||||||
|
for (child in parent.children) {
|
||||||
|
val holder = parent.getChildViewHolder(child)
|
||||||
|
if (previous != null && shouldDrawDivider(previous, holder)) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||||
|
val top: Float = bounds.top + child.translationY
|
||||||
|
val bottom: Float = top + thickness
|
||||||
|
canvas.drawRect(left, top, right, bottom, paint)
|
||||||
|
}
|
||||||
|
previous = holder
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun shouldDrawDivider(
|
||||||
|
above: RecyclerView.ViewHolder,
|
||||||
|
below: RecyclerView.ViewHolder,
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.decor
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.collection.LongSet
|
|
||||||
import androidx.collection.MutableLongSet
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||||
@@ -14,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
|
|
||||||
private val bounds = Rect()
|
private val bounds = Rect()
|
||||||
private val boundsF = RectF()
|
private val boundsF = RectF()
|
||||||
protected val selection = MutableLongSet()
|
protected val selection = HashSet<Long>()
|
||||||
|
|
||||||
protected var hasBackground: Boolean = true
|
protected var hasBackground: Boolean = true
|
||||||
protected var hasForeground: Boolean = false
|
protected var hasForeground: Boolean = false
|
||||||
@@ -23,7 +21,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
val checkedItemsCount: Int
|
val checkedItemsCount: Int
|
||||||
get() = selection.size
|
get() = selection.size
|
||||||
|
|
||||||
val checkedItemsIds: LongSet
|
val checkedItemsIds: Set<Long>
|
||||||
get() = selection
|
get() = selection
|
||||||
|
|
||||||
fun toggleItemChecked(id: Long) {
|
fun toggleItemChecked(id: Long) {
|
||||||
@@ -41,9 +39,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun checkAll(ids: Collection<Long>) {
|
fun checkAll(ids: Collection<Long>) {
|
||||||
for (id in ids) {
|
selection.addAll(ids)
|
||||||
selection.add(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSelection() {
|
fun clearSelection() {
|
||||||
@@ -71,7 +67,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
if (parent.clipToPadding) {
|
if (parent.clipToPadding) {
|
||||||
canvas.clipRect(
|
canvas.clipRect(
|
||||||
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
||||||
parent.height - parent.paddingBottom,
|
parent.height - parent.paddingBottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +91,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
canvas.restoreToCount(checkpoint)
|
canvas.restoreToCount(checkpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun getItemId(parent: RecyclerView, child: View): Long
|
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
|
||||||
|
|
||||||
protected open fun onDrawBackground(
|
protected open fun onDrawBackground(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
@@ -112,4 +108,4 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
|||||||
bounds: RectF,
|
bounds: RectF,
|
||||||
state: RecyclerView.State,
|
state: RecyclerView.State,
|
||||||
) = Unit
|
) = Unit
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.decor
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.util.SparseIntArray
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.util.getOrDefault
|
||||||
|
import androidx.core.util.set
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
class TypedSpacingItemDecoration(
|
||||||
|
vararg spacingMapping: Pair<Int, Int>,
|
||||||
|
private val fallbackSpacing: Int = 0,
|
||||||
|
) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val mapping = SparseIntArray(spacingMapping.size)
|
||||||
|
|
||||||
|
init {
|
||||||
|
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemOffsets(
|
||||||
|
outRect: Rect,
|
||||||
|
view: View,
|
||||||
|
parent: RecyclerView,
|
||||||
|
state: RecyclerView.State
|
||||||
|
) {
|
||||||
|
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
||||||
|
val spacing = if (itemType == null) {
|
||||||
|
fallbackSpacing
|
||||||
|
} else {
|
||||||
|
mapping.getOrDefault(itemType, fallbackSpacing)
|
||||||
|
}
|
||||||
|
outRect.set(spacing, spacing, spacing, spacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.fastscroll
|
package org.koitharu.kotatsu.base.ui.list.fastscroll
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -8,9 +8,9 @@ import android.view.animation.AccelerateInterpolator
|
|||||||
import android.view.animation.DecelerateInterpolator
|
import android.view.animation.DecelerateInterpolator
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.measureWidth
|
|
||||||
import kotlin.math.hypot
|
import kotlin.math.hypot
|
||||||
|
import org.koitharu.kotatsu.utils.ext.animatorDurationScale
|
||||||
|
import org.koitharu.kotatsu.utils.ext.measureWidth
|
||||||
|
|
||||||
class BubbleAnimator(
|
class BubbleAnimator(
|
||||||
private val bubble: View,
|
private val bubble: View,
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.fastscroll
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
class FastScrollRecyclerView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle,
|
||||||
|
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
val fastScroller = FastScroller(context, attrs)
|
||||||
|
|
||||||
|
init {
|
||||||
|
fastScroller.id = R.id.fast_scroller
|
||||||
|
fastScroller.layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAdapter(adapter: Adapter<*>?) {
|
||||||
|
super.setAdapter(adapter)
|
||||||
|
fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setVisibility(visibility: Int) {
|
||||||
|
super.setVisibility(visibility)
|
||||||
|
fastScroller.visibility = visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
fastScroller.attachRecyclerView(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
fastScroller.detachRecyclerView()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.fastscroll
|
package org.koitharu.kotatsu.base.ui.list.fastscroll
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -9,34 +9,23 @@ import android.util.AttributeSet
|
|||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.*
|
||||||
import android.widget.LinearLayout
|
import androidx.annotation.*
|
||||||
import android.widget.RelativeLayout
|
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.annotation.DimenRes
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.Px
|
|
||||||
import androidx.annotation.StyleableRes
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.constraintlayout.widget.ConstraintSet
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.view.GravityCompat
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.core.view.ancestors
|
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
|
|
||||||
import org.koitharu.kotatsu.databinding.FastScrollerBinding
|
import org.koitharu.kotatsu.databinding.FastScrollerBinding
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.utils.ext.isLayoutReversed
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
private const val SCROLLBAR_HIDE_DELAY = 1000L
|
private const val SCROLLBAR_HIDE_DELAY = 1000L
|
||||||
@@ -67,11 +56,10 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
private var bubbleHeight = 0
|
private var bubbleHeight = 0
|
||||||
private var handleHeight = 0
|
private var handleHeight = 0
|
||||||
private var viewHeight = 0
|
private var viewHeight = 0
|
||||||
private var offset = 0
|
|
||||||
private var hideScrollbar = true
|
private var hideScrollbar = true
|
||||||
private var showBubble = true
|
private var showBubble = true
|
||||||
private var showBubbleAlways = false
|
private var showBubbleAlways = false
|
||||||
private var bubbleSize = BubbleSize.SMALL
|
private var bubbleSize = BubbleSize.NORMAL
|
||||||
private var bubbleImage: Drawable? = null
|
private var bubbleImage: Drawable? = null
|
||||||
private var handleImage: Drawable? = null
|
private var handleImage: Drawable? = null
|
||||||
private var trackImage: Drawable? = null
|
private var trackImage: Drawable? = null
|
||||||
@@ -95,7 +83,7 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
if (showBubbleAlways) {
|
if (showBubbleAlways) {
|
||||||
val targetPos = getRecyclerViewTargetPosition(y)
|
val targetPos = getRecyclerViewTargetPosition(y)
|
||||||
sectionIndexer?.let { bindBubble(it.getSectionText(recyclerView.context, targetPos)) }
|
sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +98,6 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
showScrollbar()
|
showScrollbar()
|
||||||
if (showBubbleAlways && sectionIndexer != null) showBubble()
|
if (showBubbleAlways && sectionIndexer != null) showBubble()
|
||||||
}
|
}
|
||||||
|
|
||||||
RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) {
|
RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) {
|
||||||
handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
|
handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
|
||||||
}
|
}
|
||||||
@@ -126,33 +113,29 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
return viewHeight * proportion
|
return viewHeight * proportion
|
||||||
}
|
}
|
||||||
|
|
||||||
val isScrollbarVisible: Boolean
|
|
||||||
get() = binding.scrollbar.isVisible
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
clipChildren = false
|
clipChildren = false
|
||||||
orientation = HORIZONTAL
|
orientation = HORIZONTAL
|
||||||
|
|
||||||
@ColorInt var bubbleColor = context.getThemeColor(appcompatR.attr.colorControlNormal, Color.DKGRAY)
|
@ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY)
|
||||||
@ColorInt var handleColor = bubbleColor
|
@ColorInt var handleColor = bubbleColor
|
||||||
@ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
|
@ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
|
||||||
@ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
|
@ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
|
||||||
|
|
||||||
var showTrack = false
|
var showTrack = false
|
||||||
|
|
||||||
context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) {
|
context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) {
|
||||||
bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor)
|
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
|
||||||
handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor)
|
handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor)
|
||||||
trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor)
|
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor)
|
||||||
textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor)
|
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
|
||||||
hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar)
|
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar)
|
||||||
showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
|
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble)
|
||||||
showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
|
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways)
|
||||||
showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
|
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack)
|
||||||
bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, bubbleSize)
|
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
|
||||||
val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
|
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
|
||||||
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||||
offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrackColor(trackColor)
|
setTrackColor(trackColor)
|
||||||
@@ -166,7 +149,7 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
|
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
|
||||||
super.onSizeChanged(w, h, oldW, oldH)
|
super.onSizeChanged(w, h, oldW, oldH)
|
||||||
viewHeight = h - paddingTop - paddingBottom
|
viewHeight = h
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@@ -179,9 +162,7 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) {
|
if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
requestDisallowInterceptTouchEvent(true)
|
requestDisallowInterceptTouchEvent(true)
|
||||||
setHandleSelected(true)
|
setHandleSelected(true)
|
||||||
@@ -195,12 +176,10 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
setYPositions()
|
setYPositions()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
setYPositions()
|
setYPositions()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
requestDisallowInterceptTouchEvent(false)
|
requestDisallowInterceptTouchEvent(false)
|
||||||
setHandleSelected(false)
|
setHandleSelected(false)
|
||||||
@@ -233,7 +212,6 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
*
|
*
|
||||||
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
|
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
|
||||||
*/
|
*/
|
||||||
@Suppress("RemoveRedundantQualifierName")
|
|
||||||
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
|
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
|
||||||
params.width = LayoutParams.WRAP_CONTENT
|
params.width = LayoutParams.WRAP_CONTENT
|
||||||
super.setLayoutParams(params)
|
super.setLayoutParams(params)
|
||||||
@@ -247,8 +225,8 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
*/
|
*/
|
||||||
fun setLayoutParams(viewGroup: ViewGroup) {
|
fun setLayoutParams(viewGroup: ViewGroup) {
|
||||||
val recyclerViewId = recyclerView?.id ?: NO_ID
|
val recyclerViewId = recyclerView?.id ?: NO_ID
|
||||||
val offsetTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
|
val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
|
||||||
val offsetBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
|
val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
|
||||||
|
|
||||||
require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
|
require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
|
||||||
|
|
||||||
@@ -265,45 +243,29 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
applyTo(viewGroup)
|
applyTo(viewGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLayoutParams<ConstraintLayout.LayoutParams> {
|
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
|
||||||
height = 0
|
height = 0
|
||||||
marginStart = offset
|
setMargins(0, marginTop, 0, marginBottom)
|
||||||
marginEnd = offset
|
|
||||||
topMargin = offsetTop
|
|
||||||
bottomMargin = offsetBottom
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
|
||||||
is CoordinatorLayout -> updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
|
||||||
height = LayoutParams.MATCH_PARENT
|
height = LayoutParams.MATCH_PARENT
|
||||||
anchorGravity = GravityCompat.END
|
anchorGravity = GravityCompat.END
|
||||||
anchorId = recyclerViewId
|
anchorId = recyclerViewId
|
||||||
marginStart = offset
|
setMargins(0, marginTop, 0, marginBottom)
|
||||||
marginEnd = offset
|
|
||||||
topMargin = offsetTop
|
|
||||||
bottomMargin = offsetBottom
|
|
||||||
}
|
}
|
||||||
|
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
|
||||||
is FrameLayout -> updateLayoutParams<FrameLayout.LayoutParams> {
|
|
||||||
height = LayoutParams.MATCH_PARENT
|
height = LayoutParams.MATCH_PARENT
|
||||||
gravity = GravityCompat.END
|
gravity = GravityCompat.END
|
||||||
marginStart = offset
|
setMargins(0, marginTop, 0, marginBottom)
|
||||||
marginEnd = offset
|
|
||||||
topMargin = offsetTop
|
|
||||||
bottomMargin = offsetBottom
|
|
||||||
}
|
}
|
||||||
|
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
|
||||||
is RelativeLayout -> updateLayoutParams<RelativeLayout.LayoutParams> {
|
|
||||||
height = 0
|
height = 0
|
||||||
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
|
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
|
||||||
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
|
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
|
||||||
addRule(RelativeLayout.ALIGN_END, recyclerViewId)
|
addRule(RelativeLayout.ALIGN_END, recyclerViewId)
|
||||||
marginStart = offset
|
setMargins(0, marginTop, 0, marginBottom)
|
||||||
marginEnd = offset
|
|
||||||
topMargin = offsetTop
|
|
||||||
bottomMargin = offsetBottom
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
|
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,12 +287,10 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
if (parent is ViewGroup) {
|
if (parent is ViewGroup) {
|
||||||
setLayoutParams(parent as ViewGroup)
|
setLayoutParams(parent as ViewGroup)
|
||||||
} else {
|
} else if (recyclerView.parent is ViewGroup) {
|
||||||
val viewGroup = findValidParent(recyclerView)
|
val viewGroup = recyclerView.parent as ViewGroup
|
||||||
if (viewGroup != null) {
|
viewGroup.addView(this)
|
||||||
viewGroup.addView(this)
|
setLayoutParams(viewGroup)
|
||||||
setLayoutParams(viewGroup)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recyclerView.addOnScrollListener(scrollListener)
|
recyclerView.addOnScrollListener(scrollListener)
|
||||||
@@ -490,7 +450,7 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
val layoutManager = recyclerView?.layoutManager ?: return
|
val layoutManager = recyclerView?.layoutManager ?: return
|
||||||
val targetPos = getRecyclerViewTargetPosition(y)
|
val targetPos = getRecyclerViewTargetPosition(y)
|
||||||
layoutManager.scrollToPosition(targetPos)
|
layoutManager.scrollToPosition(targetPos)
|
||||||
if (showBubble) sectionIndexer?.let { bindBubble(it.getSectionText(context, targetPos)) }
|
if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setViewPositions(y: Float) {
|
private fun setViewPositions(y: Float) {
|
||||||
@@ -541,20 +501,7 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
|
private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
|
||||||
val ordinal = getInt(index, -1)
|
val ordinal = getInt(index, -1)
|
||||||
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
|
return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
|
||||||
}
|
|
||||||
|
|
||||||
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
|
|
||||||
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
|
|
||||||
p
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindBubble(text: CharSequence?) {
|
|
||||||
binding.bubble.text = text
|
|
||||||
binding.bubble.alpha = if (text.isNullOrEmpty()) 0f else 1f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val BubbleSize.textSize
|
private val BubbleSize.textSize
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.fastscroll
|
package org.koitharu.kotatsu.base.ui.list.fastscroll
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -7,7 +7,7 @@ import android.view.ViewPropertyAnimator
|
|||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
|
import org.koitharu.kotatsu.utils.ext.animatorDurationScale
|
||||||
|
|
||||||
class ScrollbarAnimator(
|
class ScrollbarAnimator(
|
||||||
private val scrollbar: View,
|
private val scrollbar: View,
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
|
||||||
|
class ActionModeDelegate {
|
||||||
|
|
||||||
|
private var activeActionMode: ActionMode? = null
|
||||||
|
private var listeners: MutableList<ActionModeListener>? = null
|
||||||
|
|
||||||
|
val isActionModeStarted: Boolean
|
||||||
|
get() = activeActionMode != null
|
||||||
|
|
||||||
|
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
|
activeActionMode = mode
|
||||||
|
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
activeActionMode = null
|
||||||
|
listeners?.forEach { it.onActionModeFinished(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: ActionModeListener) {
|
||||||
|
if (listeners == null) {
|
||||||
|
listeners = ArrayList()
|
||||||
|
}
|
||||||
|
checkNotNull(listeners).add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListener(listener: ActionModeListener) {
|
||||||
|
listeners?.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
|
||||||
|
addListener(listener)
|
||||||
|
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ListenerLifecycleObserver(
|
||||||
|
private val listener: ActionModeListener,
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
super.onDestroy(owner)
|
||||||
|
removeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ interface ActionModeListener {
|
|||||||
fun onActionModeStarted(mode: ActionMode)
|
fun onActionModeStarted(mode: ActionMode)
|
||||||
|
|
||||||
fun onActionModeFinished(mode: ActionMode)
|
fun onActionModeFinished(mode: ActionMode)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.app.ActivityCompat
|
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
|
||||||
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
|
|
||||||
import java.util.WeakHashMap
|
import java.util.WeakHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,11 +22,6 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
|
|||||||
|
|
||||||
fun recreateAll() {
|
fun recreateAll() {
|
||||||
val snapshot = activities.keys.toList()
|
val snapshot = activities.keys.toList()
|
||||||
snapshot.forEach { ActivityCompat.recreate(it) }
|
snapshot.forEach { it.recreate() }
|
||||||
}
|
|
||||||
|
|
||||||
fun recreate(cls: Class<out Activity>) {
|
|
||||||
val activity = activities.keys.find { x -> x.javaClass == cls } ?: return
|
|
||||||
ActivityCompat.recreate(activity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface BaseActivityEntryPoint {
|
interface BaseActivityEntryPoint {
|
||||||
|
|
||||||
val settings: AppSettings
|
val settings: AppSettings
|
||||||
|
}
|
||||||
val exceptionResolverFactory: ExceptionResolver.Factory
|
|
||||||
|
// Hilt cannot inject into parametrized classes
|
||||||
|
fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
|
||||||
|
activity.settings = settings
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.MenuItem.OnActionExpandListener
|
import android.view.MenuItem.OnActionExpandListener
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class CountedBooleanLiveData : LiveData<Boolean>(false) {
|
||||||
|
|
||||||
|
private val counter = AtomicInteger(0)
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun increment() {
|
||||||
|
if (counter.getAndIncrement() == 0) {
|
||||||
|
postValue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun decrement() {
|
||||||
|
if (counter.decrementAndGet() == 0) {
|
||||||
|
postValue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun reset() {
|
||||||
|
if (counter.getAndSet(0) != 0) {
|
||||||
|
postValue(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
interface RecyclerViewOwner {
|
interface RecyclerViewOwner {
|
||||||
|
|
||||||
val recyclerView: RecyclerView?
|
val recyclerView: RecyclerView
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.base.domain.ReversibleHandle
|
||||||
|
|
||||||
class ReversibleAction(
|
class ReversibleAction(
|
||||||
@StringRes val stringResId: Int,
|
@StringRes val stringResId: Int,
|
||||||
val handle: ReversibleHandle?,
|
val handle: ReversibleHandle?,
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
@@ -10,7 +10,9 @@ import com.google.android.material.floatingactionbutton.ExtendedFloatingActionBu
|
|||||||
|
|
||||||
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
|
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
constructor() : super()
|
constructor() : super()
|
||||||
|
@Suppress("unused")
|
||||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
|
||||||
override fun onStartNestedScroll(
|
override fun onStartNestedScroll(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.util
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.Px
|
import androidx.annotation.Px
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
|
||||||
|
|
||||||
|
class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener {
|
||||||
|
|
||||||
|
private var animator: ValueAnimator? = null
|
||||||
|
private val interpolator = AccelerateDecelerateInterpolator()
|
||||||
|
|
||||||
|
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||||
|
val foreground = appBarLayout.statusBarForeground ?: return
|
||||||
|
val start = foreground.alpha
|
||||||
|
val collapsed = verticalOffset != 0
|
||||||
|
val end = if (collapsed) 255 else 0
|
||||||
|
animator?.cancel()
|
||||||
|
if (start == end) {
|
||||||
|
animator = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
animator = ValueAnimator.ofInt(start, end).apply {
|
||||||
|
duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration)
|
||||||
|
interpolator = this@StatusBarDimHelper.interpolator
|
||||||
|
addUpdateListener {
|
||||||
|
foreground.alpha = it.animatedValue as Int
|
||||||
|
}
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachToAppBar(appBarLayout: AppBarLayout) {
|
||||||
|
appBarLayout.addOnOffsetChangedListener(this)
|
||||||
|
appBarLayout.statusBarForeground =
|
||||||
|
MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply {
|
||||||
|
alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.OnApplyWindowInsetsListener
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
|
class WindowInsetsDelegate(
|
||||||
|
private val listener: WindowInsetsListener,
|
||||||
|
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
|
||||||
|
|
||||||
|
var handleImeInsets: Boolean = false
|
||||||
|
|
||||||
|
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
|
||||||
|
|
||||||
|
private var lastInsets: Insets? = null
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||||
|
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
|
||||||
|
val newInsets = if (handleImeInsets) {
|
||||||
|
Insets.max(
|
||||||
|
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()),
|
||||||
|
handledInsets.getInsets(WindowInsetsCompat.Type.ime()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
if (newInsets != lastInsets) {
|
||||||
|
listener.onWindowInsetsChanged(newInsets)
|
||||||
|
lastInsets = newInsets
|
||||||
|
}
|
||||||
|
return handledInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLayoutChange(
|
||||||
|
view: View,
|
||||||
|
left: Int,
|
||||||
|
top: Int,
|
||||||
|
right: Int,
|
||||||
|
bottom: Int,
|
||||||
|
oldLeft: Int,
|
||||||
|
oldTop: Int,
|
||||||
|
oldRight: Int,
|
||||||
|
oldBottom: Int,
|
||||||
|
) {
|
||||||
|
view.removeOnLayoutChangeListener(this)
|
||||||
|
if (lastInsets == null) { // Listener may not be called
|
||||||
|
onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onViewCreated(view: View) {
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view, this)
|
||||||
|
view.addOnLayoutChangeListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDestroyView() {
|
||||||
|
lastInsets = null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowInsetsListener {
|
||||||
|
|
||||||
|
fun onWindowInsetsChanged(insets: Insets)
|
||||||
|
}
|
||||||
|
}
|
||||||