Compare commits

...

90 Commits

Author SHA1 Message Date
Koitharu
3121532217 Translated using Weblate (Russian)
Currently translated at 100.0% (320 of 320 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-07-07 11:35:56 +03:00
Koitharu
20ac12ca0d Update readme and metadata 2022-07-07 11:06:20 +03:00
Koitharu
f0b222140e Update parsers 2022-07-07 10:17:58 +03:00
Koitharu
2a35ca6094 Fix landscape details view #197 2022-07-07 09:14:50 +03:00
Koitharu
93f9636916 Improve webtoon reader performance 2022-07-06 19:43:43 +03:00
Koitharu
2c24aba558 Refactor update checker 2022-07-06 16:15:25 +03:00
Koitharu
a35d7dc5ae Ability to resolve errors in manga source settings 2022-07-06 15:13:54 +03:00
J. Lavoie
1e9e7e4cd7 Translated using Weblate (Finnish)
Currently translated at 96.5% (309 of 320 strings)

Translated using Weblate (French)

Currently translated at 100.0% (320 of 320 strings)

Translated using Weblate (Italian)

Currently translated at 99.3% (318 of 320 strings)

Translated using Weblate (German)

Currently translated at 97.8% (313 of 320 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-07-06 15:12:08 +03:00
kuragehime
cfd97ebd3d Translated using Weblate (Japanese)
Currently translated at 100.0% (320 of 320 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (319 of 319 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-07-06 13:12:47 +03:00
Oğuz Ersen
3ac8dc5558 Translated using Weblate (Turkish)
Currently translated at 100.0% (320 of 320 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (319 of 319 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-07-06 13:12:47 +03:00
Anupam Malhotra
6f93440b11 Translated using Weblate (Spanish)
Currently translated at 95.2% (304 of 319 strings)

Co-authored-by: Anupam Malhotra <anpm.malhotra@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-07-06 13:12:47 +03:00
Koitharu
9283f419ba Support images in manga description 2022-07-06 13:12:58 +03:00
Koitharu
be67b36b6a Refactor extensions 2022-07-06 12:49:21 +03:00
Koitharu
6934daecff Preselect new sources based on locale 2022-07-06 12:20:48 +03:00
Koitharu
3d74d027c1 Remove duplicate item from history settings 2022-07-06 12:13:27 +03:00
Koitharu
e048235dad Update dependencies 2022-07-06 12:12:52 +03:00
Koitharu
d21dff08b8 Invalidate DiskLruCache if broken 2022-07-06 11:59:52 +03:00
Koitharu
5f06c4c3c0 Fix selection state restoring 2022-07-04 14:21:19 +03:00
Koitharu
b693b34fe7 Add "Show all" button to search results 2022-07-04 14:05:23 +03:00
Koitharu
b7f469957c TrimTransformation for images 2022-07-04 13:48:51 +03:00
Koitharu
4a7b415635 Fix search suggestions manga ShapeAppearance 2022-07-04 13:08:15 +03:00
Koitharu
d9985d03ab Save state of selection in lists 2022-07-04 13:03:11 +03:00
Koitharu
6e324fd5ab Merge branch 'feature/percent' into devel 2022-07-02 14:51:50 +03:00
Koitharu
167498dd2c Merge branch 'feature/empty_states' into devel 2022-07-02 14:48:31 +03:00
Koitharu
688eaf6aab Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2022-07-02 14:47:53 +03:00
kuragehime
d7df105e04 Translated using Weblate (Japanese)
Currently translated at 100.0% (305 of 305 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-07-02 14:45:55 +03:00
Oğuz Ersen
ea5c4cd027 Translated using Weblate (Turkish)
Currently translated at 100.0% (305 of 305 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-07-02 14:45:55 +03:00
Aliaksiej Razumaŭ
9589706df9 Translated using Weblate (Belarusian)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (304 of 304 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-07-02 14:45:55 +03:00
Koitharu
e6e37aec47 Merge branch 'devel' into feature/shikimori 2022-07-02 14:39:03 +03:00
Koitharu
fc2d5fe00e Update ScrobblingInfoBottomSheet 2022-07-02 14:33:16 +03:00
Zakhar Timoshenko
a73d3d375a Unregistering scrobbler 2022-07-02 12:33:23 +03:00
Koitharu
ce97c8f7d9 Ability to report non-fatal exceptions 2022-07-02 11:02:03 +03:00
Koitharu
a48abc56dd Catch HttpException in widgets as well 2022-07-01 16:02:33 +03:00
Koitharu
1044d7a8d1 Show checkmark on reading indicator if full 2022-07-01 15:32:47 +03:00
Zakhar Timoshenko
fb0a075c50 Fix acknowledgment 2022-06-30 18:42:53 +03:00
Zakhar Timoshenko
8a8c785a31 Add issue moderator action 2022-06-30 18:40:31 +03:00
Zakhar Timoshenko
4e976fc4ec Merge remote-tracking branch 'origin/devel' into devel 2022-06-30 18:24:56 +03:00
Zakhar Timoshenko
cdf06578c1 [Issue template] Add old version of issue templates
This is need for some GitHub clients like Octodroid and something else
2022-06-30 18:24:44 +03:00
Koitharu
4a131d6215 Update empty favourites image 2022-06-30 09:03:34 +03:00
Koitharu
45f71cdcc1 Empty status vector images 2022-06-29 16:43:25 +03:00
Koitharu
5192175ddc Merge branch 'devel' 2022-06-29 12:22:37 +03:00
Koitharu
274a672637 Increase version 2022-06-29 12:21:44 +03:00
Koitharu
c281ab5a39 Fix widgets size and background 2022-06-29 12:01:59 +03:00
Koitharu
0106afc93c Fix warnings 2022-06-29 11:38:33 +03:00
Koitharu
d69b091858 Translated using Weblate (Russian)
Currently translated at 99.6% (303 of 304 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-06-29 11:01:05 +03:00
Koitharu
f71274d90d Update translations 2022-06-29 10:54:17 +03:00
kuragehime
a37b9a1036 Translated using Weblate (Japanese)
Currently translated at 100.0% (304 of 304 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-06-29 10:35:18 +03:00
Oğuz Ersen
a9b8174b10 Translated using Weblate (Turkish)
Currently translated at 100.0% (304 of 304 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-06-29 10:35:18 +03:00
J. Lavoie
20a0eda5db Translated using Weblate (Finnish)
Currently translated at 99.6% (303 of 304 strings)

Translated using Weblate (French)

Currently translated at 100.0% (304 of 304 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (304 of 304 strings)

Translated using Weblate (German)

Currently translated at 100.0% (304 of 304 strings)

Translated using Weblate (Spanish)

Currently translated at 99.3% (302 of 304 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-06-29 10:35:18 +03:00
Artem
e231dba0b0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (301 of 301 strings)

Co-authored-by: Artem <artem@molotov.work>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-06-29 10:35:18 +03:00
Koitharu
d9459dc8fa Ui adjustments 2022-06-28 12:44:59 +03:00
Koitharu
a55ff5ce5a Option to disable reading progress indicators 2022-06-28 12:35:55 +03:00
Koitharu
f2ea1cde46 Reading progress indicator on details screen 2022-06-28 12:10:39 +03:00
Koitharu
04dd8003f7 Show reading progress indicators in lists 2022-06-28 11:39:54 +03:00
Koitharu
b82b46f7d7 Save reading percent to database 2022-06-27 16:21:53 +03:00
Koitharu
33bccd10fe Update widgets for Android 12 2022-06-27 14:44:43 +03:00
Koitharu
9956f1ae4f Fix detailed list item 2022-06-27 13:11:08 +03:00
Koitharu
09a3c5da23 Update dialogs 2022-06-27 12:26:23 +03:00
Koitharu
b1d51bbefb Migrate to MaterialSwitch component 2022-06-27 12:11:22 +03:00
Koitharu
9e7aaa6c91 Option to disable fingerprint authentication 2022-06-27 11:54:33 +03:00
Koitharu
9686c97731 Add tracker settings to option menu 2022-06-26 11:21:47 +03:00
Koitharu
663718ece4 Add ScrollView to list mode dialog 2022-06-26 11:09:58 +03:00
Koitharu
e3048d1eb2 Compact chapters list 2022-06-26 10:21:47 +03:00
Koitharu
c6785bfda0 Merge branch 'devel' into feature/shikimori 2022-06-26 10:13:57 +03:00
kuragehime
781438d83a Translated using Weblate (Japanese)
Currently translated at 100.0% (301 of 301 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-06-25 20:10:32 +02:00
Oğuz Ersen
f5625cc6b9 Translated using Weblate (Turkish)
Currently translated at 100.0% (301 of 301 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-06-25 20:10:32 +02:00
Allan Nordhøy
132d375d81 Translated using Weblate (Norwegian Bokmål)
Currently translated at 92.6% (279 of 301 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-06-25 20:10:32 +02:00
J. Lavoie
54007ecd28 Translated using Weblate (Finnish)
Currently translated at 75.0% (6 of 8 strings)

Translated using Weblate (Finnish)

Currently translated at 99.6% (300 of 301 strings)

Translated using Weblate (French)

Currently translated at 100.0% (301 of 301 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (301 of 301 strings)

Translated using Weblate (German)

Currently translated at 100.0% (301 of 301 strings)

Translated using Weblate (Spanish)

Currently translated at 99.3% (299 of 301 strings)

Added translation using Weblate (Finnish)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-06-25 20:10:31 +02:00
Koitharu
03c2b55776 Replace done menu item with button 2022-06-25 21:11:24 +03:00
Koitharu
c6e46384f8 Fix switches on Lollipop 2022-06-25 21:11:24 +03:00
Zakhar Timoshenko
ce1c607f05 [Issue template] Update 2022-06-24 23:12:24 +03:00
DevCoz
19bbe51f01 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.0% (297 of 300 strings)

Co-authored-by: DevCoz <qq851428876@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-06-24 14:37:15 +03:00
Artem
ff4a4eaf78 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (300 of 300 strings)

Co-authored-by: Artem <artem@molotov.work>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-06-24 14:37:15 +03:00
Koitharu
2cf08f74db Option to disable all sources 2022-06-24 14:10:51 +03:00
Koitharu
cc6bbc9869 Change defult list mode to grid 2022-06-24 13:56:27 +03:00
Koitharu
8d962516f4 Update dependencies 2022-06-24 13:46:18 +03:00
Koitharu
ced4ca01cb Add icon to metadata 2022-06-23 16:04:46 +03:00
Koitharu
1195323acd Add icon to metadata 2022-06-23 14:54:30 +03:00
Koitharu
87b62aef70 Merge branch 'feature/shikimori' of github.com:SkyfaceD/Kotatsu into feature/shikimori 2022-06-22 14:53:12 +03:00
Koitharu
ec89ba0155 Shikimori interaction implementation 2022-06-22 13:24:07 +03:00
Koitharu
0695103589 Merge branch 'devel' into feature/shikimori 2022-06-21 13:31:44 +03:00
Koitharu
8d47b09e80 Add biometric authentication 2022-06-21 12:51:24 +03:00
SkyfaceD
3be96cf035 Hide shikimori sensitive information 2022-05-17 15:40:34 +06:00
Koitharu
82efa8298d Cache shikimori user 2022-05-10 10:59:19 +03:00
Koitharu
c2ba716916 Merge branch 'devel' into feature/shikimori 2022-05-09 15:31:15 +03:00
Koitharu
f61497ffd9 Shikimori manga tracking selection list 2022-05-09 12:57:38 +03:00
Koitharu
7f3c46942d Merge branch 'devel' into feature/shikimori 2022-05-09 09:42:21 +03:00
Koitharu
9d1c4bd660 Add more shikimori api methods 2022-04-11 13:17:26 +03:00
Koitharu
3b357eb509 Merge branch 'devel' into feature/shikimori 2022-04-11 11:06:15 +03:00
Koitharu
786914b1a6 Shikimori authorization 2022-03-10 19:54:28 +02:00
230 changed files with 5164 additions and 1388 deletions

29
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,29 @@
**PLEASE READ THIS**
I acknowledge that:
- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
- I will fill out the title and the information in this template
Note that the issue will be automatically closed if you do not fill out the title or requested information.
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
---
## Device information
* Kotatsu version: ?
* Android version: ?
* Device: ?
## Steps to reproduce
1. First step
2. Second step
## Issue/Request
?
## Other details
Additional details and attachments.

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
url: https://github.com/nv95/kotatsu-parsers/issues/new
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead

View File

@@ -85,9 +85,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I will fill out all of the requested information in this form.
required: true

View File

@@ -21,6 +21,16 @@ body:
placeholder: |
Additional details and attachments.
- type: input
id: kotatsu-version
attributes:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
Example: "3.3"
validations:
required: true
- type: checkboxes
id: acknowledgements
attributes:
@@ -31,9 +41,7 @@ body:
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
required: true
- label: I will fill out all of the requested information in this form.
required: true

29
.github/workflows/issue_moderator.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Issue moderator
on:
issues:
types: [opened, edited, reopened]
issue_comment:
types: [created]
jobs:
moderate:
runs-on: ubuntu-latest
steps:
- name: Moderate issues
uses: tachiyomiorg/issue-moderator-action@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
auto-close-rules: |
[
{
"type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
"message": "The acknowledgment section was not removed."
},
{
"type": "body",
"regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
"message": "Requested information in the template was not filled out."
}
]

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@
/.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
.DS_Store
/build
/captures

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="quality" value="0.25" />
</component>
</project>

View File

@@ -10,9 +10,9 @@ Kotatsu is a free and open source manga reader for Android.
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK from Github Releases:
Download APK directly from GitHub:
- [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)**
### Main Features
@@ -24,8 +24,8 @@ Download APK from Github Releases:
* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Available in multiple languages
* Password protect access to the app
* Shikimori integration (manga tracking)
* Password/fingerprint protect access to the app
### Screenshots
@@ -38,23 +38,20 @@ Download APK from Github Releases:
### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a>
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
Kotatsu is Free Software: You can use, study share and improve it at your
will. Specifically you can redistribute and/or modify it under the terms of the
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
published by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
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.
### Disclaimer
### DMCA disclaimer
The developers of this application does not have any affiliation with the content providers available.
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.

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 410
versionName '3.3.1'
versionCode 413
versionName '3.4.1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -24,6 +24,10 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
}
buildTypes {
debug {
@@ -73,28 +77,29 @@ afterEvaluate {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:8a3b6df91d') {
implementation('com.github.nv95:kotatsu-parsers:8c26f3c790') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01'
implementation 'androidx.activity:activity-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.5.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01'
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0'
implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2'
@@ -102,7 +107,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
implementation 'com.squareup.okio:okio:3.1.0'
implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@@ -119,14 +124,14 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'

View File

@@ -59,7 +59,15 @@
android:label="@string/search_manga" />
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:label="@string/settings" />
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"

View File

@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
@@ -74,6 +75,7 @@ class KotatsuApp : Application() {
readerModule,
appWidgetModule,
suggestionsModule,
shikimoriModule,
bookmarksModule,
)
}
@@ -92,6 +94,7 @@ class KotatsuApp : Application() {
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
)
dialog {

View File

@@ -12,6 +12,7 @@ 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.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -82,9 +83,8 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
// ActivityCompat.recreate(this)
throw RuntimeException("Test crash")
// return true
ActivityCompat.recreate(this)
return true
}
return super.onKeyDown(keyCode, event)
}

View File

@@ -60,6 +60,15 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
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) {

View File

@@ -6,14 +6,12 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.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.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :

View File

@@ -1,89 +0,0 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogInputBinding
class TextInputDialog private constructor(
private val delegate: AlertDialog,
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context) {
private val binding = DialogInputBinding.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 setHint(@StringRes hintResId: Int): Builder {
binding.inputEdit.hint = binding.root.context.getString(hintResId)
return this
}
fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
with(binding.inputLayout) {
counterMaxLength = maxLength
isCounterEnabled = maxLength > 0
}
if (strict && maxLength > 0) {
binding.inputEdit.filters += InputFilter.LengthFilter(maxLength)
}
return this
}
fun setInputType(inputType: Int): Builder {
binding.inputEdit.inputType = inputType
return this
}
fun setText(text: String): Builder {
binding.inputEdit.setText(text)
binding.inputEdit.setSelection(text.length)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: (DialogInterface, String) -> Unit
): Builder {
delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
}
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
delegate.setNegativeButton(textId, listener)
return this
}
fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
delegate.setOnCancelListener(listener)
return this
}
fun create() =
TextInputDialog(delegate.create())
}
}

View File

@@ -0,0 +1,162 @@
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.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 KEY_SELECTION = "selection"
private const val PROVIDER_NAME = "selection_decoration"
class ListSelectionController(
private val activity: Activity,
private val decoration: AbstractSelectionItemDecoration,
private val registryOwner: SavedStateRegistryOwner,
private val callback: Callback,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
private val stateEventObserver = StateEventObserver()
val count: Int
get() = decoration.checkedItemsCount
fun snapshot(): Set<Long> {
return peekCheckedIds().toSet()
}
fun peekCheckedIds(): Set<Long> {
return decoration.checkedItemsIds
}
fun clear() {
decoration.clearSelection()
notifySelectionChanged()
}
fun addAll(ids: Collection<Long>) {
if (ids.isEmpty()) {
return
}
decoration.checkAll(ids)
notifySelectionChanged()
}
fun attachToRecyclerView(recyclerView: RecyclerView) {
recyclerView.addItemDecoration(decoration)
registryOwner.lifecycle.addObserver(stateEventObserver)
}
override fun saveState(): Bundle {
val bundle = Bundle(1)
bundle.putLongArray(KEY_SELECTION, peekCheckedIds().toLongArray())
return bundle
}
fun onItemClick(id: Long): Boolean {
if (decoration.checkedItemsCount != 0) {
decoration.toggleItemChecked(id)
if (decoration.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
notifySelectionChanged()
return true
}
return false
}
fun onItemLongClick(id: Long): Boolean {
startActionMode()
return actionMode?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return callback.onActionItemClicked(mode, item)
}
override fun onDestroyActionMode(mode: ActionMode) {
callback.onDestroyActionMode(mode)
clear()
actionMode = null
}
private fun startActionMode() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
private fun notifySelectionChanged() {
val count = decoration.checkedItemsCount
callback.onSelectionChanged(count)
if (count == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
private fun restoreState(ids: Collection<Long>) {
if (ids.isEmpty() || decoration.checkedItemsCount != 0) {
return
}
decoration.checkAll(ids)
startActionMode()
notifySelectionChanged()
}
interface Callback : ActionMode.Callback {
fun onSelectionChanged(count: Int)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
override fun onDestroyActionMode(mode: ActionMode) = Unit
}
private inner class StateEventObserver : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_CREATE) {
val registry = registryOwner.savedStateRegistry
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
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.getLongArray(KEY_SELECTION)?.toList().orEmpty())
}
}
}
}
}
}
}

View File

@@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
private val selection = HashSet<Long>()
protected val selection = HashSet<Long>()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false

View File

@@ -17,19 +17,28 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.postDelayed
import org.koitharu.kotatsu.R
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
import com.google.android.material.R as materialR
private const val ENTER_DURATION = 300L
private const val EXIT_DURATION = 200L
private const val SHORT_DURATION = 1_500L
private const val LONG_DURATION = 2_750L
private const val SHORT_DURATION_MS = 1_500L
private const val LONG_DURATION_MS = 2_750L
/**
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
*
@@ -40,16 +49,13 @@ private const val LONG_DURATION = 2_750L
class FadingSnackbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private val message: TextView
private val action: Button
private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
init {
val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
message = view.findViewById(R.id.snackbar_text)
action = view.findViewById(R.id.snackbar_action)
binding.snackbarLayout.background = createThemedBackground()
}
fun dismiss() {
@@ -62,33 +68,66 @@ class FadingSnackbar @JvmOverloads constructor(
}
fun show(
messageText: CharSequence? = null,
@StringRes actionId: Int? = null,
longDuration: Boolean = true,
actionClick: () -> Unit = { dismiss() },
dismissListener: () -> Unit = { }
messageText: CharSequence?,
@StringRes actionId: Int = 0,
duration: Int = Snackbar.LENGTH_SHORT,
onActionClick: (FadingSnackbar.() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
) {
message.text = messageText
if (actionId != null) {
action.run {
binding.snackbarText.text = messageText
if (actionId != 0) {
with(binding.snackbarAction) {
visibility = VISIBLE
text = context.getString(actionId)
setOnClickListener {
actionClick()
onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
}
}
} else {
action.visibility = GONE
binding.snackbarAction.visibility = GONE
}
alpha = 0f
visibility = VISIBLE
animate()
.alpha(1f)
.duration = ENTER_DURATION
val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
postDelayed(showDuration) {
if (duration == Snackbar.LENGTH_INDEFINITE) {
return
}
val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
postDelayed(durationMs) {
dismiss()
dismissListener()
onDismiss?.invoke()
}
}
private fun createThemedBackground(): Drawable {
val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
val shapeAppearanceModel = ShapeAppearanceModel.builder(
context,
materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
0
).build()
val background = createMaterialShapeDrawableBackground(
backgroundColor,
shapeAppearanceModel,
)
val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
return if (backgroundTint != null) {
val wrappedDrawable = DrawableCompat.wrap(background)
DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
wrappedDrawable
} else {
DrawableCompat.wrap(background)
}
}
private fun createMaterialShapeDrawableBackground(
@ColorInt backgroundColor: Int,
shapeAppearanceModel: ShapeAppearanceModel,
): MaterialShapeDrawable {
val background = MaterialShapeDrawable(shapeAppearanceModel)
background.fillColor = ColorStateList.valueOf(backgroundColor)
return background
}
}

View File

@@ -25,4 +25,5 @@ class BookmarkEntity(
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "percent") val percent: Float,
)

View File

@@ -18,6 +18,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
percent = percent,
)
fun Bookmark.toEntity() = BookmarkEntity(
@@ -28,4 +29,5 @@ fun Bookmark.toEntity() = BookmarkEntity(
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
percent = percent,
)

View File

@@ -11,6 +11,7 @@ class Bookmark(
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
val percent: Float,
) {
override fun equals(other: Any?): Boolean {
@@ -26,6 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
if (percent != other.percent) return false
return true
}
@@ -38,6 +40,7 @@ class Bookmark(
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + percent.hashCode()
return result
}
}

View File

@@ -111,6 +111,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("chapter_id", chapterId)
jo.put("page", page)
jo.put("scroll", scroll)
jo.put("percent", percent)
return jo
}

View File

@@ -15,11 +15,11 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) {
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2))
}
suspend fun finish() {
suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish()
}

View File

@@ -9,10 +9,7 @@ import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.*
class RestoreRepository(private val db: MangaDatabase) {
@@ -95,7 +92,8 @@ class RestoreRepository(private val db: MangaDatabase) {
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat()
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
)
private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(

View File

@@ -6,8 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -15,6 +21,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
@@ -26,8 +34,9 @@ import org.koitharu.kotatsu.tracker.data.TracksDao
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
],
version = 11,
version = 12,
)
abstract class MangaDatabase : RoomDatabase() {
@@ -50,6 +59,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao
}
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@@ -67,6 +78,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL,
`id` INTEGER NOT NULL,
`manga_id` INTEGER NOT NULL,
`target_id` INTEGER NOT NULL,
`status` TEXT,
`chapter` INTEGER NOT NULL,
`comment` TEXT,
`rating` REAL NOT NULL,
PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
)
""".trimIndent()
)
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
}

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
import androidx.annotation.StringRes
import okio.IOException
import org.koitharu.kotatsu.R
class CloudFlareProtectedException(
val url: String

View File

@@ -11,4 +11,5 @@ data class MangaHistory(
val chapterId: Long,
val page: Int,
val scroll: Int,
val percent: Float,
) : Parcelable

View File

@@ -3,14 +3,13 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaChapters(
val chapters: List<MangaChapter>,
) : Parcelable {
constructor(parcel: Parcel) : this(
createList(parcel.readInt()) { parcel.readMangaChapter() }
List(parcel.readInt()) { parcel.readMangaChapter() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {

View File

@@ -3,14 +3,13 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaPages(
val pages: List<MangaPage>,
) : Parcelable {
constructor(parcel: Parcel) : this(
createList(parcel.readInt()) { parcel.readMangaPage() }
List(parcel.readInt()) { parcel.readMangaPage() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {

View File

@@ -3,14 +3,14 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.createSet
import org.koitharu.kotatsu.utils.ext.Set
class ParcelableMangaTags(
val tags: Set<MangaTag>,
) : Parcelable {
constructor(parcel: Parcel) : this(
createSet(parcel.readInt()) { parcel.readMangaTag() }
Set(parcel.readInt()) { parcel.readMangaTag() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {

View File

@@ -9,6 +9,7 @@ object CommonHeaders {
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()

View File

@@ -10,6 +10,10 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
@@ -18,12 +22,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) {
@@ -40,7 +41,7 @@ class AppSettings(context: Context) {
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection
@@ -104,10 +105,13 @@ class AppSettings(context: Context) {
val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
var historyGrouping: Boolean
var isHistoryGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -125,6 +129,10 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -242,15 +250,7 @@ class AppSettings(context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
fun observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun observe() = prefs.observe()
companion object {
@@ -293,11 +293,13 @@ class AppSettings(context: Context) {
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
@@ -307,6 +309,7 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@@ -316,9 +319,6 @@ class AppSettings(context: Context) {
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui
import android.text.Html
import coil.ComponentRegistry
import coil.ImageLoader
import coil.disk.DiskCache
@@ -10,6 +11,7 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.FaviconMapper
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.utils.image.CoilImageGetter
val uiModule
get() = module {
@@ -29,6 +31,9 @@ val uiModule
ImageLoader.Builder(androidContext())
.okHttpClient(httpClientFactory)
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.IO)
.decoderDispatcher(Dispatchers.Default)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.components(
ComponentRegistry.Builder()
@@ -37,4 +42,5 @@ val uiModule
.build()
).build()
}
factory<Html.ImageGetter> { CoilImageGetter(androidContext(), get()) }
}

View File

@@ -8,6 +8,6 @@ val detailsModule
get() = module {
viewModel { intent ->
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get())
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get(), get())
}
}

View File

@@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.*
import android.widget.AdapterView
import android.widget.Spinner
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
@@ -16,6 +15,7 @@ import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
@@ -34,16 +34,15 @@ import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem>,
ActionMode.Callback,
AdapterView.OnItemSelectedListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener {
SearchView.OnQueryTextListener,
ListSelectionController.Callback {
private val viewModel by sharedViewModel<DetailsViewModel>()
private var chaptersAdapter: ChaptersAdapter? = null
private var actionMode: ActionMode? = null
private var selectionDecoration: ChaptersSelectionDecoration? = null
private var selectionController: ListSelectionController? = null
override fun onInflateView(
inflater: LayoutInflater,
@@ -53,9 +52,14 @@ class ChaptersFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
chaptersAdapter = ChaptersAdapter(this)
selectionDecoration = ChaptersSelectionDecoration(view.context)
selectionController = ListSelectionController(
activity = requireActivity(),
decoration = ChaptersSelectionDecoration(view.context),
registryOwner = this,
callback = this,
)
with(binding.recyclerViewChapters) {
addItemDecoration(selectionDecoration!!)
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
adapter = chaptersAdapter
}
@@ -74,20 +78,13 @@ class ChaptersFragment :
override fun onDestroyView() {
chaptersAdapter = null
selectionDecoration = null
selectionController = null
binding.spinnerBranches?.adapter = null
super.onDestroyView()
}
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.chapter.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
binding.recyclerViewChapters.invalidateItemDecorations()
}
if (selectionController?.onItemClick(item.chapter.id) == true) {
return
}
if (item.hasFlag(ChapterListItem.FLAG_MISSING)) {
@@ -106,14 +103,7 @@ class ChaptersFragment :
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.chapter.id, true)
binding.recyclerViewChapters.invalidateItemDecorations()
it.invalidate()
} != null
return selectionController?.onItemLongClick(item.chapter.id) ?: false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
@@ -122,13 +112,13 @@ class ChaptersFragment :
DownloadService.start(
context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds?.toSet()
selectionController?.snapshot(),
)
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionDecoration?.checkedItemsIds
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
@@ -147,9 +137,7 @@ class ChaptersFragment :
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids)
binding.recyclerViewChapters.invalidateItemDecorations()
mode.invalidate()
selectionController?.addAll(ids)
true
}
else -> false
@@ -169,7 +157,7 @@ class ChaptersFragment :
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
val selectedIds = selectionController?.peekCheckedIds() ?: return false
val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
@@ -181,10 +169,8 @@ class ChaptersFragment :
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
selectionDecoration?.clearSelection()
override fun onSelectionChanged(count: Int) {
binding.recyclerViewChapters.invalidateItemDecorations()
actionMode = null
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true

View File

@@ -21,9 +21,11 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -37,11 +39,13 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -81,7 +85,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
binding.snackbar.show(messageText = getString(it), longDuration = false)
binding.snackbar.show(messageText = getString(it))
}
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
@@ -114,6 +118,21 @@ class DetailsActivity :
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
e is ParseException || e is IllegalArgumentException || e is IllegalStateException -> {
binding.snackbar.show(
messageText = e.getDisplayMessage(resources),
actionId = R.string.report,
duration = if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE
} else {
Snackbar.LENGTH_LONG
},
onActionClick = {
e.sendWithAcra()
dismiss()
}
)
}
else -> {
binding.snackbar.show(e.getDisplayMessage(resources))
}
@@ -151,14 +170,11 @@ class DetailsActivity :
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible =
manga?.source != null && manga.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this)
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
return super.onPrepareOptionsMenu(menu)
}
@@ -199,6 +215,12 @@ class DetailsActivity :
}
true
}
R.id.action_shiki_track -> {
viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
}
true
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
lifecycleScope.launch {

View File

@@ -17,9 +17,11 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
@@ -31,7 +33,9 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -39,6 +43,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
@@ -67,6 +72,7 @@ class DetailsFragment :
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -74,6 +80,8 @@ class DetailsFragment :
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
addMenuProvider(DetailsMenuProvider())
}
@@ -102,8 +110,6 @@ class DetailsFragment :
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
textViewDescription.text = manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
?: getString(R.string.no_description)
when (manga.state) {
MangaState.FINISHED -> {
textViewState.apply {
@@ -166,6 +172,14 @@ class DetailsFragment :
}
}
private fun onDescriptionChanged(description: CharSequence?) {
if (description.isNullOrBlank()) {
binding.textViewDescription.setText(R.string.no_description)
} else {
binding.textViewDescription.text = description
}
}
private fun onHistoryChanged(history: MangaHistory?) {
with(binding.buttonRead) {
if (history == null) {
@@ -176,6 +190,7 @@ class DetailsFragment :
setIconResource(R.drawable.ic_play)
}
}
binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onFavouriteChanged(isFavourite: Boolean) {
@@ -209,12 +224,39 @@ class DetailsFragment :
}
}
private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
with(binding.scrobblingLayout) {
root.isVisible = scrobbling != null
if (scrobbling == null) {
CoilUtils.dispose(imageViewCover)
return
}
imageViewCover.newImageRequest(scrobbling.coverUrl)
.crossfade(true)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
textViewTitle.text = scrobbling.title
textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
ratingBar.rating = scrobbling.rating * ratingBar.numStars
textViewStatus.text = scrobbling.status?.let {
resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
}
}
}
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.button_favorite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
}
R.id.scrobbling_layout -> {
ScrobblingInfoBottomSheet.show(childFragmentManager)
}
R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.details.ui
import android.text.Html
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@@ -26,10 +27,13 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel(
intent: MangaIntent,
@@ -40,6 +44,8 @@ class DetailsViewModel(
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobbler: Scrobbler,
private val imageGetter: Html.ImageGetter,
) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
@@ -76,9 +82,26 @@ class DetailsViewModel(
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() }
.transformLatest {
val description = it?.description
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml())
emit(description.parseAsHtml(imageGetter = imageGetter))
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
get() = scrobbler.isAvailable
val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
@@ -188,6 +211,25 @@ class DetailsViewModel(
}
}
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
launchJob(Dispatchers.Default) {
scrobbler.updateScrobblingInfo(
mangaId = delegate.mangaId,
rating = rating,
status = status,
comment = null,
)
}
}
fun unregisterScrobbling() {
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId
)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.acra.ACRA
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.setCurrentManga
class MangaDetailsDelegate(
private val intent: MangaIntent,
@@ -32,6 +35,7 @@ class MangaDetailsDelegate(
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null)
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow<Manga?>(null)
val manga: StateFlow<Manga?>
@@ -41,6 +45,7 @@ class MangaDetailsDelegate(
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
ACRA.setCurrentManga(manga)
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch

View File

@@ -0,0 +1,150 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.app.ActivityOptions
import android.content.Intent
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ScrobblingInfoBottomSheet :
BaseBottomSheet<SheetScrobblingBinding>(),
AdapterView.OnItemSelectedListener,
RatingBar.OnRatingBarChangeListener,
View.OnClickListener,
PopupMenu.OnMenuItemClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var menu: PopupMenu? = null
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
return SheetScrobblingBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.onError.observe(viewLifecycleOwner) {
Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show()
}
binding.spinnerStatus.onItemSelectedListener = this
binding.ratingBar.onRatingBarChangeListener = this
binding.buttonMenu.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
menu = PopupMenu(view.context, binding.buttonMenu).apply {
inflate(R.menu.opt_scrobbling)
setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
}
}
override fun onDestroyView() {
super.onDestroyView()
menu = null
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position),
)
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) {
viewModel.updateScrobbling(
rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
)
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_menu -> menu?.show()
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
}
}
}
private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
if (scrobbling == null) {
dismissAllowingStateLoss()
return
}
binding.textViewTitle.text = scrobbling.title
binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
binding.textViewDescription.text = scrobbling.description
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(scrobbling.coverUrl)
.crossfade(true)
.lifecycle(viewLifecycleOwner)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.enqueueWith(coil)
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser))
)
}
R.id.action_unregister -> {
viewModel.unregisterScrobbling()
dismiss()
}
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
dismiss()
}
}
return true
}
}

View File

@@ -11,10 +11,10 @@ import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule
get() = module {
factory { FavouritesRepository(get(), get()) }
single { FavouritesRepository(get(), get()) }
viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get(), get())
FavouritesListViewModel(categoryId.get(), get(), get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga ->

View File

@@ -4,6 +4,7 @@ import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import com.google.android.material.R as materialR
class CategoriesEditDelegate(
private val context: Context,
@@ -11,9 +12,10 @@ class CategoriesEditDelegate(
) {
fun deleteCategory(category: FavouriteCategory) {
MaterialAlertDialogBuilder(context)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
callback.onDeleteCategory(category)

View File

@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
@@ -24,7 +22,8 @@ import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener {
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener,
View.OnClickListener {
private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
@@ -39,6 +38,7 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
initSortSpinner()
binding.buttonDone.setOnClickListener(this)
viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged)
@@ -62,22 +62,14 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_config, menu)
menu.findItem(R.id.action_done)?.setTitle(R.string.save)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
viewModel.save(
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.save(
title = binding.editName.text?.toString().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onWindowInsetsChanged(insets: Insets) {

View File

@@ -2,11 +2,9 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -28,7 +26,7 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback,
Toolbar.OnMenuItemClickListener, View.OnClickListener {
View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@@ -45,7 +43,7 @@ class FavouriteCategoriesBottomSheet :
super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.toolbar.setOnMenuItemClickListener(this)
binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
@@ -57,19 +55,10 @@ class FavouriteCategoriesBottomSheet :
super.onDestroyView()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_done -> {
dismiss()
true
}
else -> false
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
R.id.button_done -> dismiss()
}
}

View File

@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.CountersProvider
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
@@ -25,8 +27,9 @@ class FavouritesListViewModel(
private val categoryId: Long,
private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
settings: AppSettings,
) : MangaListViewModel(settings), CountersProvider {
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : MangaListViewModel(settings), ListExtraProvider {
var sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null)
@@ -47,7 +50,7 @@ class FavouritesListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_heart_outline,
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet
@@ -92,4 +95,12 @@ class FavouritesListViewModel(
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
}

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule
get() = module {
factory { HistoryRepository(get(), get(), get()) }
single { HistoryRepository(get(), get(), get(), getAll()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) }
}

View File

@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.history.data
import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
scroll = scroll.toInt(),
percent = percent,
)

View File

@@ -45,26 +45,36 @@ abstract class HistoryDao {
@Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow<Int>
@Query("SELECT percent FROM history WHERE manga_id = :id")
abstract fun findProgress(id: Long): Float?
@Query("DELETE FROM history")
abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt WHERE manga_id = :mangaId")
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(
mangaId: Long,
page: Int,
chapterId: Long,
scroll: Float,
updatedAt: Long
percent: Float,
updatedAt: Long,
): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
suspend fun update(entity: HistoryEntity) =
update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
suspend fun update(entity: HistoryEntity) = update(
mangaId = entity.mangaId,
page = entity.page,
chapterId = entity.chapterId,
scroll = entity.scroll,
percent = entity.percent,
updatedAt = entity.updatedAt
)
@Transaction
open suspend fun upsert(entity: HistoryEntity): Boolean {

View File

@@ -13,16 +13,17 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
onDelete = ForeignKey.CASCADE,
)
]
)
class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
@ColumnInfo(name = "percent") val percent: Float,
)

View File

@@ -13,13 +13,18 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
const val PROGRESS_NONE = -1f
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val scrobblers: List<Scrobbler>,
) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
@@ -59,7 +64,7 @@ class HistoryRepository(
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
@@ -75,9 +80,14 @@ class HistoryRepository(
chapterId = chapterId,
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
)
)
trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
}
}
}
@@ -85,6 +95,10 @@ class HistoryRepository(
return db.historyDao.find(manga.id)?.toMangaHistory()
}
suspend fun getProgress(mangaId: Long): Float {
return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE
}
suspend fun clear() {
db.historyDao.clear()
}

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
class HistoryListMenuProvider(
private val context: Context,
@@ -19,9 +20,10 @@ class HistoryListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_clear_history -> {
MaterialAlertDialogBuilder(context)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearHistory()

View File

@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -19,6 +17,7 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -26,6 +25,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -37,7 +38,7 @@ class HistoryListViewModel(
val isGroupingEnabled = MutableLiveData<Boolean>()
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) }
override val content = combine(
@@ -48,7 +49,7 @@ class HistoryListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_history,
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
@@ -89,7 +90,7 @@ class HistoryListViewModel(
}
fun setGrouping(isGroupingEnabled: Boolean) {
settings.historyGrouping = isGroupingEnabled
settings.isHistoryGroupingEnabled = isGroupingEnabled
}
private suspend fun mapList(
@@ -98,6 +99,7 @@ class HistoryListViewModel(
mode: ListMode
): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
val showPercent = settings.isReadingIndicatorsEnabled
var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history, null)
@@ -111,10 +113,11 @@ class HistoryListViewModel(
prevDate = date
}
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) {
ListMode.LIST -> manga.toListModel(counter)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter)
ListMode.GRID -> manga.toGridModel(counter)
ListMode.LIST -> manga.toListModel(counter, percent)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
return result

View File

@@ -0,0 +1,151 @@
package org.koitharu.kotatsu.history.ui.util
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.ColorUtils
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import kotlin.math.roundToInt
class ReadingProgressDrawable(
context: Context,
@StyleRes styleResId: Int,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check)
private val lineColor: Int
private val outlineColor: Int
private val backgroundColor: Int
private val textColor: Int
private val textPattern = context.getString(R.string.percent_string_pattern)
private val textBounds = Rect()
private val tempRect = Rect()
private val hasBackground: Boolean
private val hasOutline: Boolean
private val hasText: Boolean
private val desiredHeight: Int
private val desiredWidth: Int
private val autoFitTextSize: Boolean
var progress: Float = PROGRESS_NONE
set(value) {
field = value
text = textPattern.format((value * 100f).toInt().toString())
paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf()
}
private var text = ""
init {
val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1)
desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1)
autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false)
lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK)
outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT)
backgroundColor = ColorUtils.setAlphaComponent(
ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT),
(255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
)
textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor)
paint.strokeCap = Paint.Cap.ROUND
paint.textAlign = Paint.Align.CENTER
paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize)
paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f)
ta.recycle()
hasBackground = Color.alpha(backgroundColor) != 0
hasOutline = Color.alpha(outlineColor) != 0
hasText = Color.alpha(textColor) != 0 && paint.textSize > 0
checkDrawable?.setTint(textColor)
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
if (autoFitTextSize) {
val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
paint.textSize = getTextSizeForWidth(innerWidth, "100%")
paint.getTextBounds(text, 0, text.length, textBounds)
invalidateSelf()
}
}
override fun draw(canvas: Canvas) {
if (progress < 0f) {
return
}
val cx = bounds.exactCenterX()
val cy = bounds.exactCenterY()
val radius = minOf(bounds.width(), bounds.height()) / 2f
if (hasBackground) {
paint.style = Paint.Style.FILL
paint.color = backgroundColor
canvas.drawCircle(cx, cy, radius, paint)
}
val innerRadius = radius - paint.strokeWidth / 2f
paint.style = Paint.Style.STROKE
if (hasOutline) {
paint.color = outlineColor
canvas.drawCircle(cx, cy, innerRadius, paint)
}
paint.color = lineColor
canvas.drawArc(
cx - innerRadius,
cy - innerRadius,
cx + innerRadius,
cy + innerRadius,
-90f,
360f * progress,
false,
paint,
)
if (hasText) {
if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
tempRect.set(bounds)
tempRect *= 0.6
checkDrawable.bounds = tempRect
checkDrawable.draw(canvas)
} else {
paint.style = Paint.Style.FILL
paint.color = textColor
val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
canvas.drawText(text, cx, ty, paint)
}
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun getIntrinsicHeight() = desiredHeight
override fun getIntrinsicWidth() = desiredWidth
private fun getTextSizeForWidth(width: Float, text: String): Float {
val testTextSize = 48f
paint.textSize = testTextSize
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
private operator fun Rect.timesAssign(factor: Double) {
val newWidth = (width() * factor).roundToInt()
val newHeight = (height() * factor).roundToInt()
inset(
(width() - newWidth) / 2,
(height() - newHeight) / 2,
)
}
}

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.history.ui.util
import android.animation.Animator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
class ReadingProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
private var percentAnimator: ValueAnimator? = null
private val animationDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
@StyleRes
private val drawableStyle: Int
var percent: Float
get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
set(value) {
cancelAnimation()
getProgressDrawable().progress = value
}
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ReadingProgressView, defStyleAttr, 0)
drawableStyle = ta.getResourceId(R.styleable.ReadingProgressView_progressStyle, R.style.ProgressDrawable)
ta.recycle()
outlineProvider = OutlineProvider()
if (isInEditMode) {
percent = 0.27f
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
percentAnimator?.run {
if (isRunning) end()
}
percentAnimator = null
}
override fun onAnimationUpdate(animation: ValueAnimator) {
val p = animation.animatedValue as Float
getProgressDrawable().progress = p
}
override fun onAnimationStart(animation: Animator?) = Unit
override fun onAnimationEnd(animation: Animator?) {
if (percentAnimator === animation) {
percentAnimator = null
}
}
override fun onAnimationCancel(animation: Animator?) = Unit
override fun onAnimationRepeat(animation: Animator?) = Unit
fun setPercent(value: Float, animate: Boolean) {
val currentDrawable = peekProgressDrawable()
if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
percent = value
return
}
percentAnimator?.cancel()
percentAnimator = ValueAnimator.ofFloat(
currentDrawable.progress.coerceAtLeast(0f),
value
).apply {
duration = animationDuration
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener(this@ReadingProgressView)
addListener(this@ReadingProgressView)
start()
}
}
private fun cancelAnimation() {
percentAnimator?.cancel()
percentAnimator = null
}
private fun peekProgressDrawable(): ReadingProgressDrawable? {
return background as? ReadingProgressDrawable
}
private fun getProgressDrawable(): ReadingProgressDrawable {
var d = peekProgressDrawable()
if (d != null) {
return d
}
d = ReadingProgressDrawable(context, drawableStyle)
background = d
return d
}
private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.list.domain
fun interface CountersProvider {
interface ListExtraProvider {
suspend fun getCounter(mangaId: Long): Int
suspend fun getProgress(mangaId: Long): Float
}

View File

@@ -3,13 +3,11 @@ package org.koitharu.kotatsu.list.ui
import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
@@ -19,6 +17,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
@@ -47,12 +46,11 @@ abstract class MangaListFragment :
PaginationScrollListener.Callback,
MangaListListener,
SwipeRefreshLayout.OnRefreshListener,
ActionMode.Callback {
ListSelectionController.Callback {
private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
private var selectionDecoration: MangaSelectionDecoration? = null
private var actionMode: ActionMode? = null
private var selectionController: ListSelectionController? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
@@ -63,7 +61,7 @@ abstract class MangaListFragment :
protected abstract val viewModel: MangaListViewModel
protected val selectedItemsIds: Set<Long>
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
get() = selectionController?.snapshot().orEmpty()
protected val selectedItems: Set<Manga>
get() = collectSelectedItems()
@@ -80,12 +78,17 @@ abstract class MangaListFragment :
lifecycleOwner = viewLifecycleOwner,
listener = this,
)
selectionDecoration = MangaSelectionDecoration(view.context)
selectionController = ListSelectionController(
activity = requireActivity(),
decoration = MangaSelectionDecoration(view.context),
registryOwner = this,
callback = this,
)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = listAdapter
addItemDecoration(selectionDecoration!!)
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
addOnScrollListener(paginationListener!!)
}
with(binding.swipeRefreshLayout) {
@@ -106,34 +109,19 @@ abstract class MangaListFragment :
override fun onDestroyView() {
listAdapter = null
paginationListener = null
selectionDecoration = null
selectionController = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
binding.recyclerView.invalidateItemDecorations()
}
return
if (selectionController?.onItemClick(item.id) != true) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true)
binding.recyclerView.invalidateItemDecorations()
it.invalidate()
} != null
return selectionController?.onItemLongClick(item.id) ?: false
}
@CallSuper
@@ -250,7 +238,7 @@ abstract class MangaListFragment :
addOnLayoutChangeListener(spanResolver)
}
}
selectionDecoration?.let { addItemDecoration(it) }
selectionController?.attachToRecyclerView(binding.recyclerView)
}
}
@@ -260,7 +248,7 @@ abstract class MangaListFragment :
@CallSuper
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectionDecoration?.checkedItemsCount?.toString()
mode.title = selectionController?.count?.toString()
return true
}
@@ -270,9 +258,7 @@ abstract class MangaListFragment :
val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id
} ?: return false
selectionDecoration?.checkAll(ids)
binding.recyclerView.invalidateItemDecorations()
mode.invalidate()
selectionController?.addAll(ids)
true
}
R.id.action_share -> {
@@ -294,14 +280,12 @@ abstract class MangaListFragment :
}
}
override fun onDestroyActionMode(mode: ActionMode) {
selectionDecoration?.clearSelection()
override fun onSelectionChanged(count: Int) {
binding.recyclerView.invalidateItemDecorations()
actionMode = null
}
private fun collectSelectedItems(): Set<Manga> {
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size)
for (item in items) {

View File

@@ -18,17 +18,17 @@ import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false

View File

@@ -11,6 +11,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -43,8 +44,9 @@ fun mangaGridItemAD(
}
}
bind {
bind { payloads ->
binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest?.dispose()
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl)
@@ -60,6 +62,7 @@ fun mangaGridItemAD(
onViewRecycled {
itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null
imageRequest?.dispose()
imageRequest = null

View File

@@ -54,9 +54,14 @@ class MangaListAdapter(
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when (newItem) {
is MangaListModel,
is MangaGridModel,
is MangaListDetailedModel,
is MangaItemModel -> {
oldItem as MangaItemModel
if (oldItem.progress != newItem.progress) {
PAYLOAD_PROGRESS
} else {
Unit
}
}
is CurrentFilterModel -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
@@ -77,5 +82,7 @@ class MangaListAdapter(
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10
const val ITEM_TYPE_HEADER_FILTER = 11
val PAYLOAD_PROGRESS = Any()
}
}

View File

@@ -10,6 +10,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga
@@ -36,10 +37,11 @@ fun mangaListDetailedItemAD(
clickListener.onItemLongClick(item.manga, it)
}
bind {
bind { payloads ->
imageRequest?.dispose()
binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl)
.referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder)
@@ -56,6 +58,7 @@ fun mangaListDetailedItemAD(
onViewRecycled {
itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE
badge = null
imageRequest?.dispose()
imageRequest = null

View File

@@ -4,21 +4,23 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.domain.CountersProvider
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.ifZero
fun Manga.toListModel(counter: Int) = MangaListModel(
fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel(
id = id,
title = title,
subtitle = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this,
counter = counter,
progress = progress,
)
fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel(
fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
@@ -27,50 +29,48 @@ fun Manga.toListDetailedModel(counter: Int) = MangaListDetailedModel(
coverUrl = coverUrl,
manga = this,
counter = counter,
progress = progress,
)
fun Manga.toGridModel(counter: Int) = MangaGridModel(
fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel(
id = id,
title = title,
coverUrl = coverUrl,
manga = this,
counter = counter,
progress = progress,
)
suspend fun List<Manga>.toUi(
mode: ListMode,
countersProvider: CountersProvider,
extraProvider: ListExtraProvider,
): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) }
}
suspend fun <C : MutableCollection<ListModel>> List<Manga>.toUi(
destination: C,
mode: ListMode,
countersProvider: CountersProvider,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(countersProvider.getCounter(it.id)) }
ListMode.LIST -> map {
it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
ListMode.DETAILED_LIST -> map {
it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
ListMode.GRID -> map {
it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id))
}
}
fun List<Manga>.toUi(
mode: ListMode,
): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(0) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) }
ListMode.GRID -> map { it.toGridModel(0) }
ListMode.LIST -> map { it.toListModel(0, PROGRESS_NONE) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0, PROGRESS_NONE) }
ListMode.GRID -> map { it.toGridModel(0, PROGRESS_NONE) }
}
fun <C : MutableCollection<ListModel>> List<Manga>.toUi(
destination: C,
mode: ListMode,
): C = when (mode) {
ListMode.LIST -> mapTo(destination) { it.toListModel(0) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(0) }
ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) }
ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) }
ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) }
}
fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState(

View File

@@ -4,8 +4,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel(
override val id: Long,
val title: String,
val coverUrl: String,
override val title: String,
override val coverUrl: String,
override val manga: Manga,
val counter: Int,
override val counter: Int,
override val progress: Float,
) : MangaItemModel

View File

@@ -6,4 +6,8 @@ sealed interface MangaItemModel : ListModel {
val id: Long
val manga: Manga
val title: String
val coverUrl: String
val counter: Int
val progress: Float
}

View File

@@ -4,11 +4,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
override val id: Long,
val title: String,
override val title: String,
val subtitle: String?,
val tags: String,
val coverUrl: String,
override val coverUrl: String,
val rating: String?,
override val manga: Manga,
val counter: Int,
override val counter: Int,
override val progress: Float,
) : MangaItemModel

View File

@@ -4,9 +4,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel(
override val id: Long,
val title: String,
override val title: String,
val subtitle: String,
val coverUrl: String,
override val coverUrl: String,
override val manga: Manga,
val counter: Int,
override val counter: Int,
override val progress: Float,
) : MangaItemModel

View File

@@ -13,9 +13,9 @@ import java.io.InputStream
class PagesCache(context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir
private val lruCache = DiskLruCache.create(
cacheDir.subdir(CacheDir.PAGES.dir),
FileSize.MEGABYTES.convert(200, FileSize.BYTES),
private val lruCache = createDiskLruCacheSafe(
dir = cacheDir.subdir(CacheDir.PAGES.dir),
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
)
operator fun get(url: String): File? {
@@ -60,4 +60,14 @@ class PagesCache(context: Context) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
}
}
}
private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
return try {
DiskLruCache.create(dir, size)
} catch (e: Exception) {
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}
}

View File

@@ -49,7 +49,7 @@ class LocalListViewModel(
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_storage,
icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import,

View File

@@ -145,8 +145,15 @@ class MainActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
drawerToggle?.isDrawerIndicatorEnabled =
drawer?.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED
val isSearchOpened = isSearchOpened()
adjustDrawerLock(isSearchOpened)
if (isSearchOpened) {
binding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = SCROLL_FLAG_NO_SCROLL
}
binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant))
binding.appbar.updatePadding(left = 0, right = 0)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -299,8 +306,9 @@ class MainActivity :
}
override fun onClearSearchHistory() {
MaterialAlertDialogBuilder(this)
MaterialAlertDialogBuilder(this, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.clear_search_history)
.setIcon(R.drawable.ic_clear_all)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
@@ -395,29 +403,31 @@ class MainActivity :
private fun onSearchOpened() {
TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = false
binding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = SCROLL_FLAG_NO_SCROLL
}
binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant))
binding.appbar.updatePadding(left = 0, right = 0)
adjustDrawerLock()
adjustDrawerLock(isSearchOpened = true)
adjustFabVisibility(isSearchOpened = true)
}
private fun onSearchClosed() {
TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = true
binding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
}
binding.appbar.background = null
val padding = resources.getDimensionPixelOffset(R.dimen.margin_normal)
binding.appbar.updatePadding(left = padding, right = padding)
adjustDrawerLock()
adjustDrawerLock(isSearchOpened = false)
adjustFabVisibility(isSearchOpened = false)
}
private fun isSearchOpened(): Boolean {
return supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true
}
private fun onFirstStart() {
lifecycleScope.launchWhenResumed {
val isUpdateSupported = withContext(Dispatchers.Default) {
@@ -439,7 +449,7 @@ class MainActivity :
private fun adjustFabVisibility(
isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true,
topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY),
isSearchOpened: Boolean = supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true,
isSearchOpened: Boolean = isSearchOpened(),
) {
val fab = binding.fab
if (isResumeEnabled && !isSearchOpened && topFragment is HistoryListFragment) {
@@ -453,12 +463,15 @@ class MainActivity :
}
}
private fun adjustDrawerLock() {
private fun adjustDrawerLock(
isSearchOpened: Boolean = isSearchOpened(),
) {
val drawer = drawer ?: return
val isLocked = actionModeDelegate.isActionModeStarted || (drawerToggle?.isDrawerIndicatorEnabled == false)
val isLocked = actionModeDelegate.isActionModeStarted || isSearchOpened
drawer.setDrawerLockMode(
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
)
drawerToggle?.isDrawerIndicatorEnabled = !isLocked
}
private inner class VoiceInputCallback : ActivityResultCallback<String?> {

View File

@@ -10,6 +10,11 @@ import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.core.graphics.Insets
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
@@ -17,8 +22,11 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
TextWatcher, View.OnClickListener {
class ProtectActivity :
BaseActivity<ActivityProtectBinding>(),
TextView.OnEditorActionListener,
TextWatcher,
View.OnClickListener {
private val viewModel by viewModel<ProtectViewModel>()
@@ -39,7 +47,9 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
finishAfterTransition()
}
binding.editPassword.requestFocus()
if (!useFingerprint()) {
binding.editPassword.requestFocus()
}
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -85,6 +95,31 @@ class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEdito
binding.layoutPassword.isEnabled = !isLoading
}
private fun useFingerprint(): Boolean {
if (!viewModel.isBiometricEnabled) {
return false
}
if (BiometricManager.from(this).canAuthenticate(BIOMETRIC_WEAK) != BIOMETRIC_SUCCESS) {
return false
}
val prompt = BiometricPrompt(this, BiometricCallback())
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK)
.setTitle(getString(R.string.app_name))
.setConfirmationRequired(false)
.setNegativeButtonText(getString(android.R.string.cancel))
.build()
prompt.authenticate(promptInfo)
return true
}
private inner class BiometricCallback : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
viewModel.unlock()
}
}
companion object {
private const val EXTRA_INTENT = "src_intent"

View File

@@ -19,6 +19,9 @@ class ProtectViewModel(
val onUnlockSuccess = SingleLiveEvent<Unit>()
val isBiometricEnabled
get() = settings.isBiometricProtectionEnabled
fun tryUnlock(password: String) {
if (job?.isActive == true) {
return
@@ -27,12 +30,16 @@ class ProtectViewModel(
val passwordHash = password.md5()
val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) {
protectHelper.unlock()
onUnlockSuccess.call(Unit)
unlock()
} else {
delay(PASSWORD_COMPARE_DELAY)
throw WrongPasswordException()
}
}
}
fun unlock() {
protectHelper.unlock()
onUnlockSuccess.call(Unit)
}
}

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ktx.sendWithAcra
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -214,6 +215,8 @@ class ReaderActivity :
val resolveTextId = ExceptionResolver.getResolveStringId(e)
if (resolveTextId != 0) {
dialog.setPositiveButton(resolveTextId, listener)
} else {
dialog.setPositiveButton(R.string.report, listener)
}
dialog.show()
}
@@ -368,7 +371,11 @@ class ReaderActivity :
override fun onClick(dialog: DialogInterface?, which: Int) {
if (which == DialogInterface.BUTTON_POSITIVE) {
dialog?.dismiss()
tryResolve(exception)
if (ExceptionResolver.canResolve(exception)) {
tryResolve(exception)
} else {
exception.sendWithAcra()
}
} else {
onCancel(dialog)
}

View File

@@ -6,9 +6,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.acra.ACRA
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.*
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -32,6 +33,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.setCurrentManga
import java.util.*
private const val BOUNDS_PAGE_OFFSET = 2
private const val PAGES_TRIM_THRESHOLD = 120
@@ -135,13 +138,16 @@ class ReaderViewModel(
}
}
// TODO check performance
fun saveCurrentState(state: ReaderState? = null) {
if (state != null) {
currentState.value = state
}
val readerState = state ?: currentState.value ?: return
historyRepository.saveStateAsync(
mangaData.value ?: return,
state ?: currentState.value ?: return
manga = mangaData.value ?: return,
state = readerState,
percent = computePercent(readerState.chapterId, readerState.page),
)
}
@@ -223,7 +229,7 @@ class ReaderViewModel(
if (bookmarkJob?.isActive == true) {
return
}
bookmarkJob = launchJob {
bookmarkJob = launchJob(Dispatchers.Default) {
loadingJob?.join()
val state = checkNotNull(currentState.value)
val page = checkNotNull(getCurrentPage()) { "Page not found" }
@@ -235,9 +241,10 @@ class ReaderViewModel(
scroll = state.scroll,
imageUrl = page.preview ?: pageLoader.getPageUrl(page),
createdAt = Date(),
percent = computePercent(state.chapterId, state.page),
)
bookmarksRepository.addBookmark(bookmark)
onShowToast.call(R.string.bookmark_added)
onShowToast.postCall(R.string.bookmark_added)
}
}
@@ -257,6 +264,7 @@ class ReaderViewModel(
private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga")
ACRA.setCurrentManga(manga)
mangaData.value = manga
val repo = MangaRepository(manga.source)
manga = repo.getDetails(manga)
@@ -279,7 +287,8 @@ class ReaderViewModel(
val pages = loadChapter(requireNotNull(currentState.value).chapterId)
// save state
currentState.value?.let {
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll)
val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
shortcutsRepository.updateShortcuts()
}
@@ -364,20 +373,35 @@ class ReaderViewModel(
it.printStackTraceDebug()
}.getOrDefault(defaultMode)
}
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
val chapters = manga?.chapters ?: return PROGRESS_NONE
val chaptersCount = chapters.size
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val pages = content.value?.pages ?: return PROGRESS_NONE
val pagesCount = pages.count { x -> x.chapterId == chapterId }
if (chaptersCount == 0 || pagesCount == 0) {
return PROGRESS_NONE
}
val pagePercent = (pageIndex + 1) / pagesCount.toFloat()
val ppc = 1f / chaptersCount
return ppc * chapterIndex + ppc * pagePercent
}
}
/**
* This function is not a member of the ReaderViewModel
* because it should work independently of the ViewModel's lifecycle.
*/
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState): Job {
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) {
runCatching {
addOrUpdate(
manga = manga,
chapterId = state.chapterId,
page = state.page,
scroll = state.scroll
scroll = state.scroll,
percent = percent,
)
}.onFailure {
it.printStackTraceDebug()

View File

@@ -16,6 +16,7 @@ abstract class BasePageHolder<B : ViewBinding>(
exceptionResolver: ExceptionResolver
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)

View File

@@ -11,10 +11,11 @@ import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val settings: AppSettings,
private val exceptionResolver: ExceptionResolver
private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() {
private val differ = AsyncListDiffer(this, DiffCallback())

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign
class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0
constructor(context: Context) : super(context)
constructor(
context: Context,
orientation: Int,
reverseLayout: Boolean,
) : super(context, orientation, reverseLayout)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State): Int {
scrollDirection = dy.sign
return super.scrollVerticallyBy(dy, recycler, state)
}
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
if (state.hasTargetScrollPosition()) {
super.calculateExtraLayoutSpace(state, extraLayoutSpace)
return
}
val pageSize = height
extraLayoutSpace[0] = if (scrollDirection < 0) pageSize else 0
extraLayoutSpace[1] = if (scrollDirection < 0) 0 else pageSize
}
}

View File

@@ -12,7 +12,6 @@ import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -33,7 +32,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = webtoonAdapter
doOnCurrentItemChanged(::notifyPageChanged)
addOnPageScrollListener(PageScrollListener())
}
}
@@ -93,4 +92,12 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun switchPageTo(position: Int, smooth: Boolean) {
binding.recyclerView.firstVisibleItemPosition = position
}
private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() {
override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) {
super.onPageChanged(recyclerView, index)
notifyPageChanged(index)
}
}
}

View File

@@ -2,25 +2,27 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.util.AttributeSet
import androidx.core.view.ViewCompat
import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import java.util.*
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, ViewCompat.TYPE_TOUCH)
private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null
override fun startNestedScroll(axes: Int, type: Int): Boolean {
return true
}
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH)
override fun startNestedScroll(axes: Int, type: Int): Boolean = true
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH)
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH)
override fun dispatchNestedPreScroll(
dx: Int,
@@ -34,6 +36,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
consumed[0] = 0
consumed[1] = consumedY
}
notifyScrollChanged(dy)
return consumedY != 0 || dy == 0
}
@@ -75,4 +78,39 @@ class WebtoonRecyclerView @JvmOverloads constructor(
}
return 0
}
fun addOnPageScrollListener(listener: OnPageScrollListener) {
val list = onPageScrollListeners ?: LinkedList<OnPageScrollListener>().also { onPageScrollListeners = it }
list.add(listener)
}
fun removeOnPageScrollListener(listener: OnPageScrollListener) {
onPageScrollListeners?.remove(listener)
}
private fun notifyScrollChanged(dy: Int) {
val listeners = onPageScrollListeners
if (listeners.isNullOrEmpty()) {
return
}
val centerPosition = findCenterViewPosition()
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
}
abstract class OnPageScrollListener {
private var lastPosition = NO_POSITION
fun dispatchScroll(recyclerView: WebtoonRecyclerView, dy: Int, centerPosition: Int) {
onScroll(recyclerView, dy)
if (centerPosition != NO_POSITION && centerPosition != lastPosition) {
lastPosition = centerPosition
onPageChanged(recyclerView, centerPosition)
}
}
open fun onScroll(recyclerView: WebtoonRecyclerView, dy: Int) = Unit
open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit
}
}

View File

@@ -151,7 +151,7 @@ class RemoteListViewModel(
}
private fun createEmptyState(filterState: FilterState) = EmptyState(
icon = R.drawable.ic_book_cross,
icon = R.drawable.ic_empty_search,
textPrimary = R.string.nothing_found,
textSecondary = 0,
actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.scrobbling.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entity: ScrobblingEntity)
@Update
abstract suspend fun update(entity: ScrobblingEntity)
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.scrobbling.data
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(
tableName = "scrobblings",
primaryKeys = ["scrobbler", "id", "manga_id"],
)
class ScrobblingEntity(
@ColumnInfo(name = "scrobbler") val scrobbler: Int,
@ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "target_id") val targetId: Long,
@ColumnInfo(name = "status") val status: String?,
@ColumnInfo(name = "chapter") val chapter: Int,
@ColumnInfo(name = "comment") val comment: String?,
@ColumnInfo(name = "rating") val rating: Float,
) {
fun copy(
status: String?,
comment: String?,
rating: Float,
) = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.scrobbling.domain
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
import java.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.*
import org.koitharu.kotatsu.utils.ext.findKeyByValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class Scrobbler(
protected val db: MangaDatabase,
val scrobblerService: ScrobblerService,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
abstract val isAvailable: Boolean
abstract suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
abstract suspend fun linkManga(mangaId: Long, targetId: Long)
abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter)
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo(mangaId)
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo(mangaId) }
}
abstract suspend fun unregisterScrobbling(mangaId: Long)
protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) {
runCatching {
getMangaInfo(targetId)
}.onFailure {
it.printStackTraceDebug()
}.onSuccess {
infoCache.put(targetId, it)
}.getOrNull() ?: return null
}
return ScrobblingInfo(
scrobbler = scrobblerService,
mangaId = mangaId,
targetId = targetId,
status = statuses.findKeyByValue(status),
chapter = chapter,
comment = comment,
rating = rating,
title = mangaInfo.name,
coverUrl = mangaInfo.cover,
description = mangaInfo.descriptionHtml.parseAsHtml(),
externalUrl = mangaInfo.url,
)
}
}
suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
return runCatching {
scrobble(mangaId, chapter)
}.onFailure {
it.printStackTraceDebug()
}.isSuccess
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.scrobbling.domain.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrobblerManga(
val id: Long,
val name: String,
val altName: String?,
val cover: String,
val url: String,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblerManga
if (id != other.id) return false
if (name != other.name) return false
if (altName != other.altName) return false
if (cover != other.cover) return false
if (url != other.url) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + name.hashCode()
result = 31 * result + altName.hashCode()
result = 31 * result + cover.hashCode()
result = 31 * result + url.hashCode()
return result
}
override fun toString(): String {
return "ScrobblerManga #$id \"$name\" $url"
}
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.scrobbling.domain.model
class ScrobblerMangaInfo(
val id: Long,
val name: String,
val cover: String,
val url: String,
val descriptionHtml: String,
)

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.scrobbling.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class ScrobblerService(
val id: Int,
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori)
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.domain.model
class ScrobblingInfo(
val scrobbler: ScrobblerService,
val mangaId: Long,
val targetId: Long,
val status: ScrobblingStatus?,
val chapter: Int,
val comment: String?,
val rating: Float,
val title: String,
val coverUrl: String,
val description: CharSequence?,
val externalUrl: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ScrobblingInfo
if (scrobbler != other.scrobbler) return false
if (mangaId != other.mangaId) return false
if (targetId != other.targetId) return false
if (status != other.status) return false
if (chapter != other.chapter) return false
if (comment != other.comment) return false
if (rating != other.rating) return false
if (title != other.title) return false
if (coverUrl != other.coverUrl) return false
if (description != other.description) return false
if (externalUrl != other.externalUrl) return false
return true
}
override fun hashCode(): Int {
var result = scrobbler.hashCode()
result = 31 * result + mangaId.hashCode()
result = 31 * result + targetId.hashCode()
result = 31 * result + (status?.hashCode() ?: 0)
result = 31 * result + chapter
result = 31 * result + (comment?.hashCode() ?: 0)
result = 31 * result + rating.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + coverUrl.hashCode()
result = 31 * result + (description?.hashCode() ?: 0)
result = 31 * result + externalUrl.hashCode()
return result
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.scrobbling.domain.model
enum class ScrobblingStatus {
PLANNED,
READING,
RE_READING,
COMPLETED,
ON_HOLD,
DROPPED,
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.scrobbling.shikimori
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage
import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel
val shikimoriModule
get() = module {
single { ShikimoriStorage(androidContext()) }
factory {
val okHttp = OkHttpClient.Builder().apply {
authenticator(ShikimoriAuthenticator(get(), ::get))
addInterceptor(ShikimoriInterceptor(get()))
}.build()
ShikimoriRepository(okHttp, get(), get())
}
factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class
viewModel { params ->
ShikimoriSettingsViewModel(get(), params.getOrNull())
}
viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) }
}

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
class ShikimoriAuthenticator(
private val storage: ShikimoriStorage,
private val repositoryProvider: () -> ShikimoriRepository,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}
return response
}
}

View File

@@ -0,0 +1,199 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val REDIRECT_URI = "kotatsu://shikimori-auth"
private const val BASE_URL = "https://shikimori.one/"
private const val MANGA_PAGE_SIZE = 10
class ShikimoriRepository(
private val okHttp: OkHttpClient,
private val storage: ShikimoriStorage,
private val db: MangaDatabase,
) {
val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" +
"redirect_uri=$REDIRECT_URI&response_type=code&scope="
val isAuthorized: Boolean
get() = storage.accessToken != null
suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) {
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
}
suspend fun loadUser(): ShikimoriUser {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/users/whoami")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriUser(response).also { storage.user = it }
}
fun getCachedUser(): ShikimoriUser? {
return storage.user
}
suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId)
}
fun logout() {
storage.clear()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = offset / MANGA_PAGE_SIZE
val pageOffset = offset % MANGA_PAGE_SIZE
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("mangas")
.addEncodedQueryParameter("page", (page + 1).toString())
.addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
.addEncodedQueryParameter("censored", false.toString())
.addQueryParameter("search", query)
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJsonArray()
val list = response.mapJSON { ScrobblerManga(it) }
return if (pageOffset != 0) list.drop(pageOffset) else list
}
suspend fun createRate(mangaId: Long, shikiMangaId: Long) {
val user = getCachedUser() ?: loadUser()
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("target_id", shikiMangaId)
put("target_type", "Manga")
put("user_id", user.id)
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.build()
val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("chapters", chapter.number)
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("score", rating.toString())
if (comment != null) {
put("text", comment)
}
if (status != null) {
put("status", status)
}
}
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ScrobblerMangaInfo(response)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.SHIKIMORI.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("target_id"),
status = json.getString("status"),
chapter = json.getInt("chapters"),
comment = json.getString("text"),
rating = json.getDouble("score").toFloat() / 10f,
)
db.scrobblingDao.insert(entity)
}
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
id = json.getLong("id"),
name = json.getString("name"),
altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
url = json.getString("url").toAbsoluteUrl("shikimori.one"),
)
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"),
url = json.getString("url").toAbsoluteUrl("shikimori.one"),
descriptionHtml = json.getString("description_html"),
)
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data
import android.content.Context
import androidx.core.content.edit
import org.json.JSONObject
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
private const val PREF_NAME = "shikimori"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER = "user"
class ShikimoriStorage(context: Context) {
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var user: ShikimoriUser?
get() = prefs.getString(KEY_USER, null)?.let {
ShikimoriUser(JSONObject(it))
}
set(value) = prefs.edit {
putString(KEY_USER, value?.toJson()?.toString())
}
fun clear() = prefs.edit {
clear()
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.shikimori.data.model
import org.json.JSONObject
class ShikimoriUser(
val id: Long,
val nickname: String,
val avatar: String,
) {
constructor(json: JSONObject) : this(
id = json.getLong("id"),
nickname = json.getString("nickname"),
avatar = json.getString("avatar"),
)
fun toJson() = JSONObject().apply {
put("id", id)
put("nickname", nickname)
put("avatar", avatar)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShikimoriUser
if (id != other.id) return false
if (nickname != other.nickname) return false
if (avatar != other.avatar) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + nickname.hashCode()
result = 31 * result + avatar.hashCode()
return result
}
}

View File

@@ -0,0 +1,68 @@
package org.koitharu.kotatsu.scrobbling.shikimori.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
private const val RATING_MAX = 10f
class ShikimoriScrobbler(
private val repository: ShikimoriRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.SHIKIMORI) {
init {
statuses[ScrobblingStatus.PLANNED] = "planned"
statuses[ScrobblingStatus.READING] = "watching"
statuses[ScrobblingStatus.RE_READING] = "rewatching"
statuses[ScrobblingStatus.COMPLETED] = "completed"
statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
statuses[ScrobblingStatus.DROPPED] = "dropped"
}
override val isAvailable: Boolean
get() = repository.isAuthorized
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
return repository.findManga(query, offset)
}
override suspend fun linkManga(mangaId: Long, targetId: Long) {
repository.createRate(mangaId, targetId)
}
override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return
repository.updateRate(entity.id, entity.mangaId, chapter)
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating * RATING_MAX,
status = statuses[status],
comment = comment,
)
}
override suspend fun unregisterScrobbling(mangaId: Long) {
repository.unregister(mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
return repository.getMangaInfo(id)
}
}

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.scrobbling.shikimori.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
import org.koitharu.kotatsu.utils.PreferenceIconTarget
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.withArgs
class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) {
private val viewModel by viewModel<ShikimoriSettingsViewModel> {
parametersOf(arguments?.getString(ARG_AUTH_CODE))
}
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_shikimori)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.user.observe(viewLifecycleOwner, this::onUserChanged)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
KEY_USER -> openAuthorization()
KEY_LOGOUT -> {
viewModel.logout()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun onUserChanged(user: ShikimoriUser?) {
val pref = findPreference<Preference>(KEY_USER) ?: return
pref.isSelectable = user == null
pref.title = user?.nickname ?: getString(R.string.sign_in)
ImageRequest.Builder(requireContext())
.data(user?.avatar)
.transformations(CircleCropTransformation())
.target(PreferenceIconTarget(pref))
.enqueueWith(coil)
findPreference<Preference>(KEY_LOGOUT)?.isVisible = user != null
}
private fun openAuthorization(): Boolean {
return runCatching {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(viewModel.authorizationUrl)
startActivity(intent)
}.isSuccess
}
companion object {
private const val KEY_USER = "shiki_user"
private const val KEY_LOGOUT = "shiki_logout"
private const val ARG_AUTH_CODE = "auth_code"
fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) {
putString(ARG_AUTH_CODE, authCode)
}
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.scrobbling.shikimori.ui
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser
class ShikimoriSettingsViewModel(
private val repository: ShikimoriRepository,
authCode: String?,
) : BaseViewModel() {
val authorizationUrl: String
get() = repository.oauthUrl
val user = MutableLiveData<ShikimoriUser?>()
init {
if (authCode != null) {
authorize(authCode)
} else {
loadUser()
}
}
fun logout() {
launchJob(Dispatchers.Default) {
repository.logout()
user.postValue(null)
}
}
private fun loadUser() = launchJob(Dispatchers.Default) {
val userModel = if (repository.isAuthorized) {
repository.getCachedUser()?.let(user::postValue)
repository.loadUser()
} else {
null
}
user.postValue(userModel)
}
private fun authorize(code: String) = launchJob(Dispatchers.Default) {
repository.authorize(code)
user.postValue(repository.loadUser())
}
}

View File

@@ -0,0 +1,155 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
class ScrobblingSelectorBottomSheet :
BaseBottomSheet<SheetScrobblingSelectorBinding>(),
OnListItemClickListener<ScrobblerManga>,
PaginationScrollListener.Callback,
View.OnClickListener,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
private val viewModel by viewModel<ScrobblingSelectorViewModel> {
parametersOf(requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding {
return SheetScrobblingSelectorBinding.inflate(inflater, container, false)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also {
it.setOnKeyListener(this)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener { dismiss() }
addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar))
val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this)
val decoration = ShikiMangaSelectionDecoration(view.context)
with(binding.recyclerView) {
adapter = listAdapter
addItemDecoration(decoration)
addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet))
}
binding.buttonDone.setOnClickListener(this)
initOptionsMenu()
viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it }
viewModel.selectedItemId.observe(viewLifecycleOwner) {
decoration.checkedItemId = it
binding.recyclerView.invalidateItemDecorations()
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onClose.observe(viewLifecycleOwner) {
dismiss()
}
viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.toolbar.subtitle = it
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.onDoneClick()
}
}
override fun onItemClick(item: ScrobblerManga, view: View) {
viewModel.selectedItemId.value = item.id
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
setExpanded(isExpanded = true, isLocked = true)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) }
return true
}
override fun onQueryTextSubmit(query: String?): Boolean {
if (query == null || query.length < 3) {
return false
}
viewModel.search(query)
binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView()
return true
}
override fun onQueryTextChange(newText: String?): Boolean = false
override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false
if (menuItem.isActionViewExpanded) {
if (event?.action == KeyEvent.ACTION_UP) {
menuItem.collapseActionView()
}
return true
}
}
return false
}
private fun onError(e: Throwable) {
Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
if (viewModel.isEmpty) {
dismissAllowingStateLoss()
}
}
private fun initOptionsMenu() {
binding.toolbar.inflateMenu(R.menu.opt_shiki_selector)
val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
companion object {
private const val TAG = "ScrobblingSelectorBottomSheet"
fun show(fm: FragmentManager, manga: Manga) =
ScrobblingSelectorBottomSheet().withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
}.show(fm, TAG)
}
}

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.scrobbling.ui.selector
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.RecyclerView.NO_ID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class ScrobblingSelectorViewModel(
val manga: Manga,
private val scrobbler: Scrobbler,
) : BaseViewModel() {
private val shikiMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private var doneJob: Job? = null
val content: LiveData<List<ListModel>> = combine(
shikiMangaList.filterNotNull(),
hasNextPage
) { list, isHasNextPage ->
when {
list.isEmpty() -> listOf()
isHasNextPage -> list + LoadingFooter
else -> list
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val selectedItemId = MutableLiveData(NO_ID)
val searchQuery = MutableLiveData(manga.title)
val onClose = SingleLiveEvent<Unit>()
val isEmpty: Boolean
get() = shikiMangaList.value.isNullOrEmpty()
init {
launchJob(Dispatchers.Default) {
try {
val info = scrobbler.getScrobblingInfoOrNull(manga.id)
if (info != null) {
selectedItemId.postValue(info.targetId)
}
} finally {
loadList(append = false)
}
}
}
fun search(query: String) {
loadingJob?.cancel()
searchQuery.value = query
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
if (append && !hasNextPage.value) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) shikiMangaList.value?.size ?: 0 else 0
val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset)
if (!append) {
shikiMangaList.value = list
} else if (list.isNotEmpty()) {
shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
}
fun onDoneClick() {
if (doneJob?.isActive == true) {
return
}
val targetId = selectedItemId.value ?: NO_ID
if (targetId == NO_ID) {
onClose.call(Unit)
}
doneJob = launchJob(Dispatchers.Default) {
scrobbler.linkManga(manga.id, targetId)
onClose.postCall(Unit)
}
}
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.getItem
class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
var checkedItemId: Long
get() = selection.singleOrNull() ?: NO_ID
set(value) {
clearSelection()
if (value != NO_ID) {
selection.add(value)
}
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(ScrobblerManga::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, defaultRadius, defaultRadius, paint)
checkIcon?.run {
val offset = (bounds.height() - intrinsicHeight) / 2
setBounds(
(bounds.right - offset - intrinsicWidth).toInt(),
(bounds.top + offset).toInt(),
(bounds.right - offset).toInt(),
(bounds.top + offset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun shikimoriMangaAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<ScrobblerManga>,
) = adapterDelegateViewBinding<ScrobblerManga, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
clickListener.onItemClick(item, it)
}
bind {
imageRequest?.dispose()
binding.textViewTitle.text = item.name
binding.textViewSubtitle.textAndVisible = item.altName
imageRequest = binding.imageViewCover.newImageRequest(item.cover)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
}
onViewRecycled {
imageRequest?.dispose()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
}
}

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