Compare commits

...

99 Commits

Author SHA1 Message Date
a0aa33a499 Option to clear updates feed 2020-06-29 13:43:01 +03:00
b27bc86141 Fix search history preference 2020-06-29 13:28:25 +03:00
84ef2af82f Fix MangaDetailsPresenter sharing 2020-06-29 13:26:28 +03:00
a2f09d8763 Restore download after network error 2020-06-29 09:48:17 +03:00
79058440a1 Fix grid span count #11 2020-06-25 19:40:50 +03:00
7f9cfdbf7a Show app update in dialog 2020-06-20 12:10:42 +03:00
85f7477450 Cleaning up traks 2020-06-20 11:35:42 +03:00
1b4a65f476 Update pages thumbnails list 2020-06-11 19:59:54 +03:00
2e69395ade Update ui 2020-06-08 19:22:56 +03:00
3f61f13b7b Show related manga 2020-06-07 20:14:44 +03:00
10a0f0ad53 Fix empty tracker log records 2020-06-05 19:25:10 +03:00
680fc66f21 Tracker fixes 2020-06-03 18:52:00 +03:00
e01b74ee3d Pagination loading indicator 2020-05-31 12:10:43 +03:00
3539e6a892 Global search 2020-05-30 11:18:14 +03:00
ff56f5a343 Fix updates feed 2020-05-30 10:25:48 +03:00
9ce43a39c8 Global search 2020-05-30 09:48:04 +03:00
0e3aa3f380 Update readme 2020-05-24 13:25:38 +03:00
7927bf0c9a Refactor 2020-05-24 12:58:17 +03:00
aec2d71688 Manga updates feed 2020-05-22 20:28:14 +03:00
140a0f4d66 Log manga tracking 2020-05-22 19:20:08 +03:00
7cf57535ab Update dependencies 2020-05-21 21:27:21 +03:00
31fe924157 Merge branch 'master' into devel 2020-05-21 21:20:39 +03:00
6d193baa69 Increment version 2020-05-21 20:48:01 +03:00
3bd7b54405 Capitalize MangaLib genres 2020-05-21 20:47:10 +03:00
d99450c5a3 Prepopulate favourite categories 2020-05-21 20:43:24 +03:00
6444122c0a Update database: add tracklogs table 2020-05-21 20:30:10 +03:00
fe14ccb5ec Update readme 2020-05-20 20:05:51 +03:00
e38e5fdf0f Small enhancements 2020-05-20 19:51:28 +03:00
c1c2b11bd8 Show local manga size 2020-05-20 19:31:17 +03:00
7d147b3c37 Check wakelock is held in download service 2020-05-20 19:04:31 +03:00
260ff32cd1 Detect chapters in cbz if index missing 2020-05-20 18:59:30 +03:00
ccc5f3e423 Fix webtoon scroll 2020-05-17 17:25:18 +03:00
8b32a60743 Misc fixes 2020-05-16 10:20:55 +03:00
c1faf2fe06 Remember last opened section 2020-05-16 09:07:09 +03:00
3588270742 Show favourites by categories and manager categories order 2020-05-16 08:49:34 +03:00
01607ec1e2 Fix MangaLib provider 2020-05-13 20:17:37 +03:00
50f8cb9193 Action mode chapters selection 2020-05-11 17:30:59 +03:00
0100974508 Optimize webtoon scroll 2020-05-11 14:04:51 +03:00
b438898456 Add mangalib source 2020-05-10 18:52:00 +03:00
c3c43dce3d Update dependencies and fix deprecations 2020-05-10 13:38:14 +03:00
e33dfd63e4 Downgrade kotlin 2020-05-10 12:36:14 +03:00
1927500f5a Configure shelf appwidget 2020-05-09 14:15:13 +03:00
f9ccd0851d Small refactor 2020-05-09 12:33:09 +03:00
23412e5c17 Optimize webtoon scroll 2020-05-09 10:16:46 +03:00
1b7c8355ec Refactor: fix deprecations 2020-05-03 18:04:26 +03:00
8378b3dd90 Disable save action for local chapters 2020-05-03 17:31:33 +03:00
9ff5bb6352 Update readme 2020-04-26 20:43:26 +03:00
b2bb1d22df Fix default storage 2020-04-26 20:28:15 +03:00
34acf5bb55 Fix json null string 2020-04-26 20:25:46 +03:00
5af32898f8 Add MangaDex source 2020-04-26 20:22:49 +03:00
ef7108f6c9 Fix typo 2020-04-26 16:48:57 +03:00
941d992793 Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-26 16:38:32 +03:00
de9a07a680 Select storage where save manga 2020-04-26 12:22:36 +03:00
0dc74f9188 Update readme 2020-04-25 18:06:11 +03:00
f95cf9b231 Reset new chapters on reading 2020-04-25 17:22:20 +03:00
0d0982b244 Check if in-app update allowed 2020-04-19 08:43:45 +03:00
ef4dd82e92 Fix TextInputDialog keyboard behavior 2020-04-19 08:06:24 +03:00
bc825681a8 Small ui fixes 2020-04-18 20:24:19 +03:00
da6204f44f Remove unused resources 2020-04-18 19:56:12 +03:00
10c68bdd72 Update readme 2020-04-18 17:33:27 +03:00
b1e90dde8f Optimize page loading 2020-04-18 12:20:18 +03:00
e0d45961f8 Add some animations 2020-04-18 11:19:20 +03:00
b732a220f6 Merge remote-tracking branch 'origin/feature/appwidget' into devel 2020-04-12 12:44:54 +03:00
582adae11f Manage favourites categories 2020-04-12 12:40:40 +03:00
3014ebdfd4 Notification settings for pre-Oreo android 2020-04-12 09:58:20 +03:00
12b13f98f8 Migrate to WorkManager 2020-04-11 11:39:40 +03:00
c13c43c616 Recent manga app widget 2020-04-10 20:13:17 +03:00
ab1eacea3f Shelf app widget 2020-04-10 19:56:51 +03:00
ac4b97928a Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-10 17:14:40 +03:00
aa8281678b Update settings 2020-04-10 17:14:33 +03:00
0be4f56538 Refactor shortcut helper 2020-04-08 20:30:44 +03:00
679c06557e Change notification icon 2020-04-08 20:15:29 +03:00
1d387709f2 Cleanup code 2020-04-05 20:40:58 +03:00
a78774d10e Optimize pages thumbnails 2020-04-05 13:04:22 +03:00
390639e9e3 Fix recycled bitmap crash 2020-04-05 12:58:06 +03:00
b98ec2199d Crash info dialog 2020-04-05 12:46:05 +03:00
8b28f1cd74 Small UI fixes 2020-04-03 21:20:41 +03:00
904b78a01e Optimize images in lists 2020-04-03 20:59:34 +03:00
a774d2d915 Read from start quick action 2020-04-03 20:45:18 +03:00
9d19b5fec0 Fix grouple genres list 2020-04-03 20:34:14 +03:00
b6c0f3ca8c Small refactor 2020-04-03 20:24:46 +03:00
e06cb1230f Update dependencies 2020-04-03 19:54:18 +03:00
1720fde4c4 Small fix webtoon reader 2020-04-02 21:59:41 +03:00
4c3dbe1643 Update notification enhancement 2020-04-02 20:39:15 +03:00
3f31bd5ad1 Tracker enhancements 2020-04-02 20:24:46 +03:00
3a79b4667b Merge branch 'master' into devel 2020-04-02 20:10:52 +03:00
65e92fa206 Fix sources preference summary 2020-03-29 21:18:32 +03:00
9cb181d53e Show new chapters on details 2020-03-29 21:08:45 +03:00
a2d4a63eb1 Tracker notifications option in settings 2020-03-29 20:48:53 +03:00
c4f712be3a Improve download notification 2020-03-29 20:41:37 +03:00
9e8367e45e Increase database version 2020-03-29 20:24:06 +03:00
fa2d1de2f2 Merge branch 'San4ito-patch-1' into devel 2020-03-29 18:05:48 +03:00
f8f4573486 Merge branch 'patch-1' of https://github.com/San4ito/Kotatsu into San4ito-patch-1 2020-03-29 18:05:30 +03:00
f15f0ce769 Merge branch 'master' into devel 2020-03-29 18:03:58 +03:00
aad26d24ec Tracking manga updates 2020-03-29 17:33:44 +03:00
80c8344f8d Manga tracking job service 2020-03-29 13:32:25 +03:00
44b23d0b69 Merge branch 'master' into devel 2020-03-29 11:16:45 +03:00
f230f2d198 Update database scheme: tracks table 2020-03-29 10:40:49 +03:00
San4ito
cf50b608a7 Убрал небольшие опечатки
Думаю, так должно быть лучше.
2020-03-28 22:14:21 +03:00
280 changed files with 5716 additions and 1290 deletions

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" />
</component>
</project>

View File

@@ -3,6 +3,7 @@
<words> <words>
<w>chucker</w> <w>chucker</w>
<w>desu</w> <w>desu</w>
<w>failsafe</w>
<w>koin</w> <w>koin</w>
<w>kotatsu</w> <w>kotatsu</w>
<w>manga</w> <w>manga</w>

1
.idea/gradle.xml generated
View File

@@ -15,6 +15,7 @@
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" /> <option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
</profile>
</component>

6
.idea/kotlinc.xml generated Normal file
View File

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

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

9
.idea/vcs.xml generated
View File

@@ -1,5 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GitSharedSettings">
<option name="FORCE_PUSH_PROHIBITED_PATTERNS">
<list>
<option value="master" />
<option value="devel" />
<option value="legacy" />
</list>
</option>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>

View File

@@ -2,13 +2,13 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android minSdk](https://img.shields.io/badge/android-5.0+-brightgreen) ![GitHub top language](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669)
### Download ### Download
Latest unstable build: [get here](https://github.com/nv95/Kotatsu/releases/latest) Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Stable release: _Coming soon_ Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
### Main Features ### Main Features
@@ -20,16 +20,18 @@ Stable release: _Coming soon_
* Tablet-optimized modern UI * Tablet-optimized modern UI
* Reading third-party comics from CBZ * Reading third-party comics from CBZ
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters
### Coming Features ### Coming Features
* Checking for new chapters * Updates feed
* Global search
### Screenshots ### Screenshots
| ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/75573590-d467f180-5a65-11ea-8338-a34af4679ed6.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/75573612-dcc02c80-5a65-11ea-9afb-293dadfb3cfd.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/75573621-e0ec4a00-5a65-11ea-92b9-72ab90281a2b.png) | | ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/80315102-3478db00-87fe-11ea-9ce8-4bbd1c254b2b.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/80315110-3f337000-87fe-11ea-95df-944c196b6667.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/80315121-49ee0500-87fe-11ea-8d9b-537a041bbf2f.png) |
|---|---|---| |---|---|---|
| ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/75573629-e34ea400-5a65-11ea-86a1-4496032ac0f0.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/75573632-e5186780-5a65-11ea-81b0-7c296157709c.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/75573639-e6e22b00-5a65-11ea-84a6-6257f532fd2c.png) | | ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/80315130-55d9c700-87fe-11ea-8350-2c8452906eb7.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/80315135-612cf280-87fe-11ea-984c-aa18567d5bbc.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/80315146-6be78780-87fe-11ea-8439-ca1ca578172b.png) |
### License ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -1,10 +1,11 @@
apply plugin: 'com.android.application' plugins {
apply plugin: 'kotlin-android' id 'com.android.application'
apply plugin: 'kotlin-android-extensions' id 'kotlin-android'
apply plugin: 'kotlin-kapt' id 'kotlin-android-extensions'
id 'kotlin-kapt'
}
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger() def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
android { android {
compileSdkVersion 29 compileSdkVersion 29
@@ -15,32 +16,28 @@ android {
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 29
versionCode gitCommits versionCode gitCommits
versionName '0.1.4' versionName '0.5-b2'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
kapt { kapt {
arguments { arguments {
arg('room.schemaLocation', "$projectDir/schemas".toString()) arg 'room.schemaLocation', "$projectDir/schemas".toString()
} }
} }
} }
archivesBaseName = "kotatsu_${gitCommits}"
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
} }
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
release { release {
multiDexEnabled false
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -61,17 +58,20 @@ androidExtensions {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6'
implementation 'androidx.core:core-ktx:1.3.0-alpha02' implementation 'androidx.core:core-ktx:1.5.0-alpha01'
implementation 'androidx.fragment:fragment-ktx:1.2.3' implementation 'androidx.activity:activity-ktx:1.2.0-alpha06'
implementation 'androidx.appcompat:appcompat:1.2.0-alpha03' implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha06'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha05'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta7'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.preference:preference:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.2.0-alpha05' implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.4.0-rc01'
implementation 'com.google.android.material:material:1.3.0-alpha01'
implementation 'androidx.room:room-runtime:2.2.5' implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5' implementation 'androidx.room:room-ktx:2.2.5'
@@ -83,19 +83,19 @@ dependencies {
implementation 'com.github.moxy-community:moxy-ktx:2.1.2' implementation 'com.github.moxy-community:moxy-ktx:2.1.2'
kapt 'com.github.moxy-community:moxy-compiler:2.1.2' kapt 'com.github.moxy-community:moxy-compiler:2.1.2'
implementation 'com.squareup.okhttp3:okhttp:4.4.1' implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.okio:okio:2.5.0' implementation 'com.squareup.okio:okio:2.6.0'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.4' implementation 'org.koin:koin-android:2.1.5'
implementation 'io.coil-kt:coil:0.9.5' implementation 'io.coil-kt:coil:0.11.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0' implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.1.2' debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.2.0'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.1.2' releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20190722' testImplementation 'org.json:json:20200518'
} }

View File

@@ -9,4 +9,5 @@
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository { -keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
public <init>(...); public <init>(...);
} }
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View File

@@ -8,7 +8,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -21,7 +23,7 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity android:name=".ui.main.MainActivity"> <activity android:name=".ui.list.MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -44,21 +46,47 @@
android:label="@string/settings" /> android:label="@string/settings" />
<activity <activity
android:name=".ui.reader.SimpleSettingsActivity" android:name=".ui.reader.SimpleSettingsActivity"
android:label="@string/settings" /> android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".ui.browser.BrowserActivity" />
<activity <activity
android:name=".ui.browser.BrowserActivity" android:name=".ui.utils.CrashActivity"
android:launchMode="singleInstance" /> android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name="org.koitharu.kotatsu.ui.list.favourites.categories.CategoriesActivity"
android:label="@string/favourites_categories"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".ui.widget.shelf.ShelfConfigActivity"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".ui.search.global.GlobalSearchActivity"
android:label="@string/search" />
<service <service
android:name=".ui.download.DownloadService" android:name=".ui.download.DownloadService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name=".ui.settings.AppUpdateService" /> <service android:name=".ui.settings.AppUpdateService" />
<service
android:name=".ui.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".ui.widget.recent.RecentWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider <provider
android:name=".ui.search.MangaSuggestionsProvider" android:name=".ui.search.MangaSuggestionsProvider"
android:authorities="${applicationId}.MangaSuggestionsProvider" android:authorities="${applicationId}.MangaSuggestionsProvider"
android:exported="false" /> android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:authorities="${applicationId}.files"
@@ -69,6 +97,27 @@
android:resource="@xml/filepaths" /> android:resource="@xml/filepaths" />
</provider> </provider>
<receiver
android:name=".ui.widget.shelf.ShelfWidgetProvider"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_shelf" />
</receiver>
<receiver
android:name=".ui.widget.recent.RecentWidgetProvider"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -3,8 +3,11 @@ package org.koitharu.kotatsu
import android.app.Application import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import coil.Coil import coil.Coil
import coil.ImageLoader import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.util.CoilUtils import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -13,9 +16,9 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.DatabasePrePopulateCallback
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.local.CbzFetcher import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
@@ -24,6 +27,10 @@ import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePers
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.utils.AppCrashHandler
import org.koitharu.kotatsu.ui.widget.WidgetUpdater
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -41,10 +48,14 @@ class KotatsuApp : Application() {
super.onCreate() super.onCreate()
initKoin() initKoin()
initCoil() initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
initErrorHandler() initErrorHandler()
} }
AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme) AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme)
val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater)
HistoryRepository.subscribe(widgetUpdater)
} }
private fun initKoin() { private fun initKoin() {
@@ -76,16 +87,19 @@ class KotatsuApp : Application() {
} }
private fun initCoil() { private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) { Coil.setImageLoader(
okHttpClient { ImageLoaderBuilder(applicationContext)
okHttp() .okHttpClient(
.cache(CoilUtils.createDefaultCache(applicationContext)) okHttp()
.build() .cache(CoilUtils.createDefaultCache(applicationContext))
} .build()
componentRegistry { ).componentRegistry(
add(CbzFetcher()) ComponentRegistry.Builder()
} .add(CbzFetcher())
}) .build()
)
.build()
)
} }
private fun initErrorHandler() { private fun initErrorHandler() {
@@ -111,5 +125,6 @@ class KotatsuApp : Application() {
applicationContext, applicationContext,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
).addMigrations(Migration1To2, Migration2To3) ).addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5, Migration5To6)
.addCallback(DatabasePrePopulateCallback(resources))
} }

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db
import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later))
)
}
}

View File

@@ -9,12 +9,25 @@ import org.koitharu.kotatsu.core.db.entity.FavouriteCategoryEntity
@Dao @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@Query("SELECT category_id,title,created_at FROM favourite_categories ORDER BY :orderBy") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(orderBy: String): List<FavouriteCategoryEntity> abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@Query("DELETE FROM favourite_categories WHERE category_id = :id") @Query("DELETE FROM favourite_categories WHERE category_id = :id")
abstract suspend fun delete(id: Long) abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun update(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1
}
} }

View File

@@ -3,13 +3,29 @@ package org.koitharu.kotatsu.core.db
import androidx.room.* import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.FavouriteEntity import org.koitharu.kotatsu.core.db.entity.FavouriteEntity
import org.koitharu.kotatsu.core.db.entity.FavouriteManga import org.koitharu.kotatsu.core.db.entity.FavouriteManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao {
@Transaction @Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction @Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id") @Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db
import androidx.room.* import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.HistoryEntity import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.HistoryWithManga import org.koitharu.kotatsu.core.db.entity.HistoryWithManga
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao @Dao
@@ -15,6 +16,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM history WHERE manga_id = :id") @Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity? abstract suspend fun find(id: Long): HistoryEntity?
@@ -33,10 +37,11 @@ abstract class HistoryDao {
suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt) suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
@Transaction @Transaction
open suspend fun upsert(entity: HistoryEntity) { open suspend fun upsert(entity: HistoryEntity): Boolean {
if (update(entity) == 0) { return if (update(entity) == 0) {
insert(entity) insert(entity)
} true
} else false
} }
} }

View File

@@ -7,20 +7,25 @@ import org.koitharu.kotatsu.core.db.entity.*
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
], version = 3 TrackEntity::class, TrackLogEntity::class
], version = 6
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
abstract fun historyDao(): HistoryDao abstract val historyDao: HistoryDao
abstract fun tagsDao(): TagsDao abstract val tagsDao: TagsDao
abstract fun mangaDao(): MangaDao abstract val mangaDao: MangaDao
abstract fun favouritesDao(): FavouritesDao abstract val favouritesDao: FavouritesDao
abstract fun preferencesDao(): PreferencesDao abstract val preferencesDao: PreferencesDao
abstract fun favouriteCategoriesDao(): FavouriteCategoriesDao abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao
} }

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga
@Dao
interface TrackLogsDao {
@Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga>
@Query("DELETE FROM track_logs")
suspend fun clear()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun cleanup()
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
}

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.TrackEntity
@Dao
abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity?
@Query("DELETE FROM tracks")
abstract suspend fun clear()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: TrackEntity): Long
@Update
abstract suspend fun update(entity: TrackEntity): Int
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
abstract suspend fun cleanup()
@Transaction
open suspend fun upsert(entity: TrackEntity) {
if (update(entity) == 0) {
insert(entity)
}
}
}

View File

@@ -11,12 +11,14 @@ data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String @ColumnInfo(name = "title") val title: String
) { ) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory( fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(), id = id ?: categoryId.toLong(),
title = title, title = title,
sortKey = sortKey,
createdAt = Date(createdAt) createdAt = Date(createdAt)
) )
} }

View File

@@ -32,6 +32,6 @@ data class HistoryEntity(
updatedAt = Date(updatedAt), updatedAt = Date(updatedAt),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll scroll = scroll.toInt()
) )
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "tracks", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TrackEntity (
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "track_logs", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class TrackLogEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import java.util.*
data class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.map { x -> x.toMangaTag() }.toSet()),
createdAt = Date(trackLog.createdAt)
)
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration3To4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
}
}

View File

@@ -1,5 +1,3 @@
package org.koitharu.kotatsu.core.exceptions package org.koitharu.kotatsu.core.exceptions
import java.lang.NullPointerException
class MangaNotFoundException(s: String? = null) : RuntimeException(s) class MangaNotFoundException(s: String? = null) : RuntimeException(s)

View File

@@ -9,11 +9,6 @@ data class AppVersion(
val name: String, val name: String,
val url: String, val url: String,
val apkSize: Long, val apkSize: Long,
val apkUrl: String val apkUrl: String,
) : Parcelable { val description: String
) : Parcelable
fun isGreaterThen(version: String) {
val thisParts = name.substringBeforeLast('-').split('.')
val parts = version.substringBeforeLast('-').split('.')
}
}

View File

@@ -22,7 +22,8 @@ class GithubRepository : KoinComponent {
url = json.getString("html_url"), url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"), name = json.getString("name").removePrefix("v"),
apkSize = asset.getLong("size"), apkSize = asset.getLong("size"),
apkUrl = asset.getString("browser_download_url") apkUrl = asset.getString("browser_download_url"),
description = json.getString("body")
) )
} }
} }

View File

@@ -37,7 +37,7 @@ class SetCookieCache : CookieCache {
override fun iterator(): MutableIterator<Cookie> = SetCookieCacheIterator() override fun iterator(): MutableIterator<Cookie> = SetCookieCacheIterator()
private inner class SetCookieCacheIterator() : MutableIterator<Cookie> { private inner class SetCookieCacheIterator : MutableIterator<Cookie> {
private val iterator = cookies.iterator() private val iterator = cookies.iterator()

View File

@@ -8,5 +8,6 @@ import java.util.*
data class FavouriteCategory( data class FavouriteCategory(
val id: Long, val id: Long,
val title: String, val title: String,
val sortKey: Int,
val createdAt: Date val createdAt: Date
) : Parcelable ) : Parcelable

View File

@@ -10,5 +10,5 @@ data class MangaHistory(
val updatedAt: Date, val updatedAt: Date,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Float val scroll: Int
) : Parcelable ) : Parcelable

View File

@@ -20,5 +20,8 @@ enum class MangaSource(
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java), MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java),
DESUME("Desu.me", "ru", DesuMeRepository::class.java), DESUME("Desu.me", "ru", DesuMeRepository::class.java),
HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java), HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java),
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java) YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java),
MANGATOWN("MangaTown", "en", MangaTownRepository::class.java),
MANGALIB("MangaLib", "ru", MangaLibRepository::class.java)
// HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java)
} }

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class MangaTracking (
val manga: Manga,
val knownChaptersCount: Int,
val lastChapterId: Long,
val lastNotifiedChapterId: Long,
val lastCheck: Date?
): Parcelable

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
import java.util.*
@Parcelize
data class TrackingLogItem (
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date
): Parcelable

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
@@ -14,6 +15,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@@ -29,8 +31,8 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
sortOrder: SortOrder?, sortOrder: SortOrder?,
tag: MangaTag? tag: MangaTag?
): List<Manga> { ): List<Manga> {
val files = context.getExternalFilesDirs("manga") val files = getAvailableStorageDirs(context)
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() } .flatMap { x -> x.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getFromFile(x) } } return files.mapNotNull { x -> safe { getFromFile(x) } }
} }
@@ -40,69 +42,85 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val file = Uri.parse(chapter.url).toFile() val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file) val zip = ZipFile(file)
val pattern = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
?.getChapterNamesPattern(chapter) var entries = zip.entries().asSequence()
val entries = if (pattern != null) { entries = if (index != null) {
zip.entries().asSequence() val pattern = index.getChapterNamesPattern(chapter)
.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else { } else {
zip.entries().asSequence().filter { x -> !x.isDirectory } val parent = uri.fragment.orEmpty()
}.toList().sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) entries.filter { x ->
return entries.map { x -> !x.isDirectory && x.name.substringBeforeLast(
val uri = zipUri(file, x.name) File.separatorChar,
MangaPage( ""
id = uri.longHashCode(), ) == parent
url = uri, }
source = MangaSource.LOCAL
)
} }
return entries
.toList()
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
source = MangaSource.LOCAL
)
}
} }
fun delete(manga: Manga): Boolean { fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile() val file = Uri.parse(manga.url).toFile()
return file.delete() return file.delete()
} }
@SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga { fun getFromFile(file: File): Manga {
val zip = ZipFile(file) val zip = ZipFile(file)
val fileUri = file.toUri().toString() val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) val index = entry?.let(zip::readText)?.let(::MangaIndex)
return index?.let { val info = index?.getMangaInfo()
it.getMangaInfo()?.let { x -> if (index != null && info != null) {
x.copy( return info.copy(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(
file,
entryName = it.getCoverEntry()
?: findFirstEntry(zip.entries())?.name.orEmpty()
),
chapters = x.chapters?.map { c -> c.copy(url = fileUri) }
)
}
} ?: run {
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries())?.name.orEmpty()), url = fileUri,
chapters = listOf( coverUrl = zipUri(
MangaChapter( file,
id = file.absolutePath.longHashCode(), entryName = index.getCoverEntry()
url = fileUri, ?: findFirstEntry(zip.entries())?.name.orEmpty()
number = 1, ),
source = MangaSource.LOCAL, chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
name = title
)
)
) )
} }
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val chapters = HashSet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = file.toUri().buildUpon()
return Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s,
number = i + 1,
source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString()
)
}
)
} }
fun getRemoteManga(localManga: Manga): Manga? { fun getRemoteManga(localManga: Manga): Manga? {
@@ -133,9 +151,24 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
companion object { companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean { fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT) val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf {
(it.exists() || it.mkdir()) && it.canWrite()
}
}
} }
} }

View File

@@ -1,18 +1,15 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.domain.MangaLoaderContext import org.koitharu.kotatsu.domain.MangaLoaderContext
abstract class RemoteMangaRepository : MangaRepository, KoinComponent { abstract class RemoteMangaRepository(protected val loaderContext: MangaLoaderContext) : MangaRepository {
protected abstract val source: MangaSource protected abstract val source: MangaSource
protected val loaderContext by inject<MangaLoaderContext>()
protected val conf by lazy(LazyThreadSafetyMode.NONE) { protected val conf by lazy(LazyThreadSafetyMode.NONE) {
loaderContext.getSettings(source) loaderContext.getSettings(source)
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.* import java.util.*

View File

@@ -4,9 +4,12 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class ChanRepository : RemoteMangaRepository() { abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(
loaderContext
) {
protected abstract val defaultDomain: String protected abstract val defaultDomain: String

View File

@@ -4,9 +4,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapIndexed
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.parseJson
class DesuMeRepository : RemoteMangaRepository() { class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.DESUME override val source = MangaSource.DESUME

View File

@@ -4,9 +4,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository : RemoteMangaRepository() { abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected abstract val defaultDomain: String protected abstract val defaultDomain: String
@@ -28,7 +30,11 @@ abstract class GroupleRepository : RemoteMangaRepository() {
"https://$domain/search", "https://$domain/search",
mapOf("q" to query, "offset" to offset.toString()) mapOf("q" to query, "offset" to offset.toString())
) )
tag == null -> loaderContext.httpGet("https://$domain/list?sortType=${getSortKey(sortOrder)}&offset=$offset") tag == null -> loaderContext.httpGet(
"https://$domain/list?sortType=${getSortKey(
sortOrder
)}&offset=$offset"
)
else -> loaderContext.httpGet( else -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${getSortKey( "https://$domain/list/genre/${tag.key}?sortType=${getSortKey(
sortOrder sortOrder
@@ -85,19 +91,29 @@ abstract class GroupleRepository : RemoteMangaRepository() {
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(defaultDomain) val domain = conf.getDomain(defaultDomain)
val doc = loaderContext.httpGet(manga.url).parseHtml() val doc = loaderContext.httpGet(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox") ?: throw ParseException("Cannot find root") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
return manga.copy( return manga.copy(
description = root.selectFirst("div.manga-description")?.html(), description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr( largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full" "data-full"
), ),
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
},
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a -> ?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href = val href =
a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter( MangaChapter(
id = href.longHashCode(), id = href.longHashCode(),
name = a.ownText(), name = a.ownText().removePrefix(manga.title).trim(),
number = i + 1, number = i + 1,
url = href, url = href,
source = source source = source

View File

@@ -5,11 +5,12 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain import org.koitharu.kotatsu.utils.ext.withDomain
class HenChanRepository : ChanRepository() { class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "h-chan.me" override val defaultDomain = "h-chan.me"
override val source = MangaSource.HENCHAN override val source = MangaSource.HENCHAN

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.parser.site
/*
class HentaiLibRepository(loaderContext: MangaLoaderContext) : MangaLibRepository(loaderContext) {
protected override val defaultDomain = "hentailib.me"
override val source = MangaSource.HENTAILIB
}*/

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class MangaChanRepository : ChanRepository() { class MangaChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "manga-chan.me" override val defaultDomain = "manga-chan.me"
override val source = MangaSource.MANGACHAN override val source = MangaSource.MANGACHAN

View File

@@ -0,0 +1,223 @@
package org.koitharu.kotatsu.core.parser.site
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected open val defaultDomain = "mangalib.me"
override val source = MangaSource.MANGALIB
override val sortOrders = setOf(
SortOrder.RATING,
SortOrder.ALPHABETICAL,
SortOrder.POPULARITY,
SortOrder.UPDATED,
SortOrder.NEWEST
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return search(query)
}
val domain = conf.getDomain(defaultDomain)
val page = (offset / 60f).toIntUp()
val url = buildString {
append("https://")
append(domain)
append("/manga-list?dir=")
append(getSortKey(sortOrder))
append("&page=")
append(page)
if (tag != null) {
append("&includeGenres[]=")
append(tag.key)
}
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found")
val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap")
return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.attr("href").withDomain(domain)
Manga(
id = href.longHashCode(),
title = card.selectFirst("h3").text(),
coverUrl = a.attr("data-src").withDomain(domain),
altTitle = null,
author = null,
rating = Manga.NO_RATING,
url = href,
tags = emptySet(),
state = null,
source = source
)
}
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.url + "?section=info").parseHtml()
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found")
val title = root.selectFirst("div.media-header__wrap")?.children()
val info = root.selectFirst("div.media-content")
val chaptersDoc = loaderContext.httpGet(manga.url + "?section=chapters").parseHtml()
val scripts = chaptersDoc.body().select("script")
var chapters: ArrayList<MangaChapter>? = null
scripts@ for (script in scripts) {
val raw = script.html().lines()
for (line in raw) {
if (line.startsWith("window.__CHAPTERS_DATA__")) {
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
val list = json.getJSONArray("list")
val total = list.length()
chapters = ArrayList(total)
for (i in 0 until total) {
val item = list.getJSONObject(i)
val url = buildString {
append(manga.url)
append("/v")
append(item.getInt("chapter_volume"))
append("/c")
append(item.getString("chapter_number"))
append('/')
append(item.getJSONArray("teams").getJSONObject(0).getString("slug"))
}
var name = item.getString("chapter_name")
if (name.isNullOrBlank() || name == "null") {
name = "Том " + item.getInt("chapter_volume") +
" Глава " + item.getString("chapter_number")
}
chapters.add(
MangaChapter(
id = url.longHashCode(),
url = url,
source = source,
number = total - i,
name = name
)
)
}
chapters.reverse()
break@scripts
}
}
}
return manga.copy(
title = title?.getOrNull(0)?.text()?.takeUnless(String::isBlank) ?: manga.title,
altTitle = title?.getOrNull(1)?.text()?.substringBefore('/')?.trim(),
rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
author = info.getElementsMatchingOwnText("Автор").firstOrNull()
?.nextElementSibling()?.text() ?: manga.author,
tags = info.getElementsMatchingOwnText("Жанры")?.firstOrNull()
?.nextElementSibling()?.select("a")?.mapNotNull { a ->
MangaTag(
title = a.text().capitalize(),
key = a.attr("href").substringAfterLast('='),
source = source
)
}?.toSet() ?: manga.tags,
description = info.getElementsMatchingOwnText("Описание")?.firstOrNull()
?.nextElementSibling()?.html(),
chapters = chapters
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url).parseHtml()
val scripts = doc.head().select("script")
val pg = doc.body().getElementById("pg").html().substringAfter('=').substringBeforeLast(';')
val pages = JSONArray(pg)
for (script in scripts) {
val raw = script.html().trim()
if (raw.startsWith("window.__info")) {
val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';'))
val domain = json.getJSONObject("servers").run {
getStringOrNull("main") ?: getString(
json.getJSONObject("img").getString("server")
)
}
val url = json.getJSONObject("img").getString("url")
return pages.map { x ->
val pageUrl = "$domain$url${x.getString("u")}"
MangaPage(
id = pageUrl.longHashCode(),
source = source,
url = pageUrl
)
}
}
}
throw ParseException("Script with info not found")
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(defaultDomain)
val url = "https://$domain/manga-list"
val doc = loaderContext.httpGet(url).parseHtml()
val scripts = doc.body().select("script")
for (script in scripts) {
val raw = script.html().trim()
if (raw.startsWith("window.__DATA")) {
val json = JSONObject(raw.substringAfter('=').substringBeforeLast(';'))
val genres = json.getJSONObject("filters").getJSONArray("genres")
val result = HashSet<MangaTag>(genres.length())
for (x in genres) {
result += MangaTag(
source = source,
key = x.getInt("id").toString(),
title = x.getString("name").capitalize()
)
}
return result
}
}
throw ParseException("Script with genres not found")
}
private fun getSortKey(sortOrder: SortOrder?) = when (sortOrder) {
SortOrder.RATING -> "desc&sort=rate"
SortOrder.ALPHABETICAL -> "asc&sort=name"
SortOrder.POPULARITY -> "desc&sort=views"
SortOrder.UPDATED -> "desc&sort=last_chapter_at"
SortOrder.NEWEST -> "desc&sort=created_at"
else -> "desc&sort=last_chapter_at"
}
private suspend fun search(query: String): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val json = loaderContext.httpGet("https://$domain/search?query=${query.urlEncoded()}")
.parseJsonArray()
return json.map { jo ->
val url = "https://$domain/${jo.getString("slug")}"
Manga(
id = url.longHashCode(),
url = url,
title = jo.getString("rus_name"),
altTitle = jo.getString("name"),
author = null,
tags = emptySet(),
rating = Manga.NO_RATING,
state = null,
source = source,
coverUrl = "https://$domain/uploads/cover/${jo.getString("slug")}/${jo.getString("cover")}/cover_thumb.jpg"
)
}
}
}

View File

@@ -0,0 +1,173 @@
package org.koitharu.kotatsu.core.parser.site
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
class MangaTownRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.MANGATOWN
override val sortOrders = setOf(
SortOrder.ALPHABETICAL,
SortOrder.RATING,
SortOrder.POPULARITY,
SortOrder.UPDATED
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val scheme = if (ssl) "https" else "http"
val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az"
SortOrder.RATING -> "?rating.za"
SortOrder.UPDATED -> "?last_chapter_time.za"
else -> ""
}
val page = (offset / 30) + 1
val url = when {
!query.isNullOrEmpty() -> "$scheme://$domain/search?name=${query.urlEncoded()}"
tag != null -> "$scheme://$domain/directory/${tag.key}/$page.htm$sortKey"
else -> "$scheme://$domain/directory/$page.htm$sortKey"
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list")
?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover")
val href = a.attr("href").withDomain(domain, ssl)
val views = li.select("p.view")
val status = views.findOwnText { x -> x.startsWith("Status:") }
?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT)
Manga(
id = href.longHashCode(),
title = a.attr("title"),
coverUrl = a.selectFirst("img").attr("src"),
source = MangaSource.MANGATOWN,
altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b")
?.ownText()?.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
largeCoverUrl = null,
author = views.findText { x -> x.startsWith("Author:") }?.substringAfter(':')
?.trim(),
state = when (status) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
},
tags = li.selectFirst("p.keyWord")?.select("a")?.mapNotNull tags@{ x ->
MangaTag(
title = x.attr("title"),
key = x.attr("href").parseTagKey() ?: return@tags null,
source = MangaSource.MANGATOWN
)
}?.toSet().orEmpty(),
url = href
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root = doc.body().selectFirst("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root")
val info = root.selectFirst("div.detail_info").selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
return manga.copy(
tags = manga.tags + info.select("li").find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a ->
MangaTag(
title = a.attr("title"),
key = a.attr("href").parseTagKey() ?: return@mapNotNull null,
source = MangaSource.MANGATOWN
)
}.orEmpty(),
description = info.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li ->
val href = li.selectFirst("a").attr("href").withDomain(domain, ssl)
val name = li.select("span").filter { it.className().isEmpty() }.joinToString(" - ") { it.text() }.trim()
MangaChapter(
id = href.longHashCode(),
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(chapter.url).parseHtml()
val root = doc.body().selectFirst("div.page_select")
?: throw ParseException("Cannot find root")
return root.selectFirst("select").select("option").mapNotNull {
val href = it.attr("value").withDomain(domain, ssl)
if (href.endsWith("featured.html")) {
return@mapNotNull null
}
MangaPage(
id = href.longHashCode(),
url = href,
source = MangaSource.MANGATOWN
)
}
}
override suspend fun getPageFullUrl(page: MangaPage): String {
val domain = conf.getDomain(DOMAIN)
val ssl = conf.isUseSsl(false)
val doc = loaderContext.httpGet(page.url).parseHtml()
return doc.getElementById("image").attr("src").withDomain(domain, ssl)
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(DOMAIN)
val doc = loaderContext.httpGet("http://$domain/directory/").parseHtml()
val root = doc.body().selectFirst("aside.right")
.getElementsContainingOwnText("Genres")
.first()
.nextElementSibling()
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a") ?: return@mapNotNull null
val key = a.attr("href").parseTagKey()
if (key.isNullOrEmpty()) {
return@mapNotNull null
}
MangaTag(
source = MangaSource.MANGATOWN,
key = key,
title = a.text()
)
}.toSet()
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain, R.string.key_parser_ssl)
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
private companion object {
@Language("RegExp")
val TAG_REGEX = Regex("[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+-[^\\-]+")
const val DOMAIN = "www.mangatown.com"
}
}

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class MintMangaRepository : GroupleRepository() { class MintMangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val source = MangaSource.MINTMANGA override val source = MangaSource.MINTMANGA
override val defaultDomain: String = "mintmanga.live" override val defaultDomain: String = "mintmanga.live"

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class ReadmangaRepository : GroupleRepository() { class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "readmanga.me" override val defaultDomain = "readmanga.me"
override val source = MangaSource.READMANGA_RU override val source = MangaSource.READMANGA_RU

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
class SelfMangaRepository : GroupleRepository() { class SelfMangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "selfmanga.ru" override val defaultDomain = "selfmanga.ru"
override val source = MangaSource.SELFMANGA override val source = MangaSource.SELFMANGA

View File

@@ -4,11 +4,12 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain import org.koitharu.kotatsu.utils.ext.withDomain
class YaoiChanRepository : ChanRepository() { class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val source = MangaSource.YAOICHAN override val source = MangaSource.YAOICHAN
override val defaultDomain = "yaoi-chan.me" override val defaultDomain = "yaoi-chan.me"

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED
}

View File

@@ -3,10 +3,14 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.* import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) : class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) :
SharedPreferences by prefs { SharedPreferences by prefs {
@@ -22,6 +26,12 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
ListMode.DETAILED_LIST ListMode.DETAILED_LIST
) )
var defaultSection by EnumPreferenceDelegate(
AppSection::class.java,
resources.getString(R.string.key_app_section),
AppSection.HISTORY
)
val theme by StringIntPreferenceDelegate( val theme by StringIntPreferenceDelegate(
resources.getString(R.string.key_theme), resources.getString(R.string.key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@@ -52,6 +62,31 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
0L 0L
) )
val trackerNotifications by BoolPreferenceDelegate(
resources.getString(R.string.key_tracker_notifications),
true
)
var notificationSound by StringPreferenceDelegate(
resources.getString(R.string.key_notifications_sound),
Settings.System.DEFAULT_NOTIFICATION_URI.toString()
)
val notificationVibrate by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_vibrate),
false
)
val notificationLight by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_light),
true
)
val readerAnimation by BoolPreferenceDelegate(
resources.getString(R.string.key_reader_animation),
false
)
private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order)) private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order))
var sourcesOrder: List<Int> var sourcesOrder: List<Int>
@@ -62,6 +97,24 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden)) var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun getStorageDir(context: Context): File? {
val value = prefs.getString(context.getString(R.string.key_local_storage), null)?.let {
File(it)
}?.takeIf { it.exists() && it.canWrite() }
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
}
fun setStorageDir(context: Context, file: File?) {
val key = context.getString(R.string.key_local_storage)
prefs.edit {
if (file == null) {
remove(key)
} else {
putString(key, file.path)
}
}
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import org.koitharu.kotatsu.utils.delegates.prefs.LongPreferenceDelegate
class AppWidgetConfig private constructor(
private val prefs: SharedPreferences,
val widgetId: Int
) : SharedPreferences by prefs {
var categoryId by LongPreferenceDelegate(CATEGORY_ID, 0L)
companion object {
private const val CATEGORY_ID = "cat_id"
fun getInstance(context: Context, widgetId: Int) = AppWidgetConfig(
context.getSharedPreferences(
"appwidget_$widgetId",
Context.MODE_PRIVATE
), widgetId
)
}
}

View File

@@ -1,13 +1,6 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
enum class ListMode(val id: Int) { enum class ListMode {
LIST(0), LIST, DETAILED_LIST, GRID;
DETAILED_LIST(1),
GRID(2);
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
}
} }

View File

@@ -8,16 +8,20 @@ interface SourceConfig {
fun getDomain(defaultValue: String): String fun getDomain(defaultValue: String): String
fun isUseSsl(defaultValue: Boolean): Boolean
private class PrefSourceConfig(context: Context, source: MangaSource) : SourceConfig { private class PrefSourceConfig(context: Context, source: MangaSource) : SourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
private val keyDomain = context.getString(R.string.key_parser_domain) private val keyDomain = context.getString(R.string.key_parser_domain)
private val keySsl = context.getString(R.string.key_parser_ssl)
override fun getDomain(defaultValue: String) = prefs.getString(keyDomain, defaultValue) override fun getDomain(defaultValue: String) = prefs.getString(keyDomain, defaultValue)
?.takeUnless(String::isBlank) ?.takeUnless(String::isBlank)
?: defaultValue ?: defaultValue
override fun isUseSsl(defaultValue: Boolean) = prefs.getBoolean(keySsl, defaultValue)
} }
companion object { companion object {

View File

@@ -17,9 +17,9 @@ class MangaDataRepository : KoinComponent {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) { suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.preferencesDao().upsert( db.preferencesDao.upsert(
MangaPrefsEntity( MangaPrefsEntity(
mangaId = manga.id, mangaId = manga.id,
mode = mode.id mode = mode.id
@@ -29,18 +29,18 @@ class MangaDataRepository : KoinComponent {
} }
suspend fun getReaderMode(mangaId: Long): ReaderMode? { suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.preferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) } return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
} }
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao().find(mangaId)?.toManga() return db.mangaDao.find(mangaId)?.toManga()
} }
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
} }
} }
} }

View File

@@ -2,13 +2,20 @@ package org.koitharu.kotatsu.domain
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.get import org.koin.core.get
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import java.lang.ref.WeakReference
import java.util.*
object MangaProviderFactory : KoinComponent { object MangaProviderFactory : KoinComponent {
private val loaderContext by inject<MangaLoaderContext>()
private val cache =
EnumMap<MangaSource, WeakReference<MangaRepository>>(MangaSource::class.java)
fun getSources(includeHidden: Boolean): List<MangaSource> { fun getSources(includeHidden: Boolean): List<MangaSource> {
val settings = get<AppSettings>() val settings = get<AppSettings>()
val list = MangaSource.values().toList() - MangaSource.LOCAL val list = MangaSource.values().toList() - MangaSource.LOCAL
@@ -18,7 +25,7 @@ object MangaProviderFactory : KoinComponent {
val e = order.indexOf(x.ordinal) val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e if (e == -1) order.size + x.ordinal else e
} }
return if(includeHidden) { return if (includeHidden) {
sorted sorted
} else { } else {
sorted.filterNot { x -> sorted.filterNot { x ->
@@ -27,9 +34,37 @@ object MangaProviderFactory : KoinComponent {
} }
} }
fun createLocal() = LocalMangaRepository() fun createLocal(): LocalMangaRepository {
var instance = cache[MangaSource.LOCAL]?.get()
if (instance == null) {
synchronized(cache) {
instance = cache[MangaSource.LOCAL]?.get()
if (instance == null) {
instance = LocalMangaRepository()
cache[MangaSource.LOCAL] = WeakReference<MangaRepository>(instance)
}
}
}
return instance as LocalMangaRepository
}
@Throws(Throwable::class)
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
return source.cls.newInstance() var instance = cache[source]?.get()
if (instance == null) {
synchronized(cache) {
instance = cache[source]?.get()
if (instance == null) {
instance = try {
source.cls.getDeclaredConstructor(MangaLoaderContext::class.java)
.newInstance(loaderContext)
} catch (e: NoSuchMethodException) {
source.cls.newInstance()
}
cache[source] = WeakReference(instance!!)
}
}
}
return instance!!
} }
} }

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
class MangaSearchRepository : KoinComponent {
fun globalSearch(query: String, batchSize: Int = 4): Flow<List<Manga>> = flow {
val sources = MangaProviderFactory.getSources(false)
val lists = EnumMap<MangaSource, List<Manga>>(MangaSource::class.java)
var i = 0
while (true) {
var isEmitted = false
for (source in sources) {
val list = lists.getOrPut(source) {
MangaProviderFactory.create(source).getList(0, query, SortOrder.POPULARITY)
}
if (i < list.size) {
emit(list.subList(i, (i + batchSize).coerceAtMost(list.lastIndex)))
isEmitted = true
}
}
i += batchSize
if (!isEmitted) {
return@flow
}
}
}
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.domain package org.koitharu.kotatsu.domain
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size import android.util.Size
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.utils.ext.medianOrNull
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent { object MangaUtils : KoinComponent {
@@ -19,17 +22,28 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size * Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide * @return ReaderMode.WEBTOON if page is wide
*/ */
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? { suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? {
try { try {
val page = pages.medianOrNull() ?: return null val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page) val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val client = get<OkHttpClient>() val uri = Uri.parse(url)
val request = Request.Builder() val size = if (uri.scheme == "cbz") {
.url(url) val zip = ZipFile(uri.schemeSpecificPart)
.get() val entry = zip.getEntry(uri.fragment)
.build() zip.getInputStream(entry).use {
val size = client.newCall(request).await().use { getBitmapSize(it)
getBitmapSize(it.body?.byteStream()) }
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
} }
return when { return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON size.width * 2 < size.height -> ReaderMode.WEBTOON

View File

@@ -18,18 +18,33 @@ class FavouritesRepository : KoinComponent {
private val db: MangaDatabase by inject() private val db: MangaDatabase by inject()
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllManga(offset: Int): List<Manga> { suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao().findAll(offset, 20, "created_at") val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun getAllCategories(): List<FavouriteCategory> { suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao().findAll("created_at") val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() } return entities.map { it.toFavouriteCategory() }
} }
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> { suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao().find(mangaId)?.categories val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty() return entities?.map { it.toFavouriteCategory() }.orEmpty()
} }
@@ -37,29 +52,47 @@ class FavouritesRepository : KoinComponent {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0 categoryId = 0
) )
val id = db.favouriteCategoriesDao().insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id) return entity.toFavouriteCategory(id)
} }
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
}
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao().delete(id) db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.update(id, i)
}
}
notifyCategoriesChanged()
} }
suspend fun addToCategory(manga: Manga, categoryId: Long) { suspend fun addToCategory(manga: Manga, categoryId: Long) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis()) val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao().add(entity) db.favouritesDao.add(entity)
} }
notifyFavouritesChanged(manga.id) notifyFavouritesChanged(manga.id)
} }
suspend fun removeFromCategory(manga: Manga, categoryId: Long) { suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao().delete(categoryId, manga.id) db.favouritesDao.delete(categoryId, manga.id)
notifyFavouritesChanged(manga.id) notifyFavouritesChanged(manga.id)
} }
@@ -72,7 +105,7 @@ class FavouritesRepository : KoinComponent {
} }
fun unsubscribe(listener: OnFavouritesChangeListener) { fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyFavouritesChanged(mangaId: Long) { private suspend fun notifyFavouritesChanged(mangaId: Long) {
@@ -80,5 +113,11 @@ class FavouritesRepository : KoinComponent {
listeners.forEach { x -> x.onFavouritesChanged(mangaId) } listeners.forEach { x -> x.onFavouritesChanged(mangaId) }
} }
} }
private suspend fun notifyCategoriesChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onCategoriesChanged() }
}
}
} }
} }

View File

@@ -3,4 +3,6 @@ package org.koitharu.kotatsu.domain.favourites
interface OnFavouritesChangeListener { interface OnFavouritesChangeListener {
fun onFavouritesChanged(mangaId: Long) fun onFavouritesChanged(mangaId: Long)
fun onCategoriesChanged()
} }

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.domain.history
enum class ChapterExtra { enum class ChapterExtra {
READ, CURRENT, UNREAD, NEW READ, CURRENT, UNREAD, NEW, CHECKED
} }

View File

@@ -11,55 +11,50 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import java.util.* import java.util.*
class HistoryRepository : KoinComponent { class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject() private val db: MangaDatabase by inject()
private val trackingRepository by lazy(::TrackingRepository)
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao().findAll(offset, limit) val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao().upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.historyDao().upsert( db.historyDao.upsert(
HistoryEntity( HistoryEntity(
mangaId = manga.id, mangaId = manga.id,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll scroll = scroll.toFloat() // we migrate to int, but decide to not update database
) )
) )
trackingRepository.upsert(manga)
} }
notifyHistoryChanged() notifyHistoryChanged()
} }
suspend fun getOne(manga: Manga): MangaHistory? { suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao().find(manga.id)?.let { return db.historyDao.find(manga.id)?.toMangaHistory()
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll
)
}
} }
suspend fun clear() { suspend fun clear() {
db.historyDao().clear() db.historyDao.clear()
notifyHistoryChanged() notifyHistoryChanged()
} }
suspend fun delete(manga: Manga) { suspend fun delete(manga: Manga) {
db.historyDao().delete(manga.id) db.historyDao.delete(manga.id)
notifyHistoryChanged() notifyHistoryChanged()
} }
@@ -68,8 +63,8 @@ class HistoryRepository : KoinComponent {
* Useful for replacing saved manga on deleting it with remove source * Useful for replacing saved manga on deleting it with remove source
*/ */
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao().update(MangaEntity.from(alternative)) <= 0) { if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
db.historyDao().delete(manga.id) db.historyDao.delete(manga.id)
notifyHistoryChanged() notifyHistoryChanged()
} }
} }
@@ -83,7 +78,7 @@ class HistoryRepository : KoinComponent {
} }
fun unsubscribe(listener: OnHistoryChangeListener) { fun unsubscribe(listener: OnHistoryChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyHistoryChanged() { private suspend fun notifyHistoryChanged() {

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
@@ -44,12 +45,12 @@ class MangaIndex(source: String?) {
Manga( Manga(
id = json.getLong("id"), id = json.getLong("id"),
title = json.getString("title"), title = json.getString("title"),
altTitle = json.getString("title_alt"), altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"), url = json.getString("url"),
source = source, source = source,
rating = json.getDouble("rating").toFloat(), rating = json.getDouble("rating").toFloat(),
coverUrl = json.getString("cover"), coverUrl = json.getString("cover"),
description = json.getString("description"), description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").map { x -> tags = json.getJSONArray("tags").map { x ->
MangaTag( MangaTag(
title = x.getString("title"), title = x.getString("title"),

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.utils.ext.sub import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.toFileName import org.koitharu.kotatsu.utils.ext.toFileNameSafe
import java.io.File import java.io.File
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@@ -91,7 +91,7 @@ class MangaZip(val file: File) {
const val INDEX_ENTRY = "index.json" const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip { fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileName() + ".cbz" val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name) val file = File(root, name)
return MangaZip(file) return MangaZip(file)
} }

View File

@@ -0,0 +1,99 @@
package org.koitharu.kotatsu.domain.tracking
import androidx.room.withTransaction
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaTracking
import org.koitharu.kotatsu.core.model.TrackingLogItem
import java.util.*
class TrackingRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun getNewChaptersCount(mangaId: Long): Int {
val entity = db.tracksDao.find(mangaId) ?: return 0
return entity.newChapters
}
suspend fun getAllTracks(): List<MangaTracking> {
val favourites = db.favouritesDao.findAllManga()
val history = db.historyDao.findAllManga()
val manga = (favourites + history).distinctBy { it.id }
val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return manga.map { m ->
val track = tracks[m.id]?.singleOrNull()
MangaTracking(
manga = m.toManga(),
knownChaptersCount = track?.totalChapters ?: -1,
lastChapterId = track?.lastChapterId ?: 0L,
lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
)
}
}
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
}
}
suspend fun count() = db.trackLogsDao.count()
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun cleanup() {
db.withTransaction {
db.tracksDao.cleanup()
db.trackLogsDao.cleanup()
}
}
suspend fun storeTrackResult(
mangaId: Long,
knownChaptersCount: Int,
lastChapterId: Long,
newChapters: List<MangaChapter>,
previousTrackChapterId: Long
) {
db.withTransaction {
val entity = TrackEntity(
mangaId = mangaId,
newChapters = newChapters.size,
lastCheck = System.currentTimeMillis(),
lastChapterId = lastChapterId,
totalChapters = knownChaptersCount,
lastNotifiedChapterId = newChapters.lastOrNull()?.id ?: previousTrackChapterId
)
db.tracksDao.upsert(entity)
val foundChapters = newChapters.takeLastWhile { x -> x.id != previousTrackChapterId }
if (foundChapters.isNotEmpty()) {
val logEntity = TrackLogEntity(
mangaId = mangaId,
chapters = foundChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis()
)
db.trackLogsDao.insert(logEntity)
}
}
}
suspend fun upsert(manga: Manga) {
val chapters = manga.chapters ?: return
val entity = TrackEntity(
mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = 0,
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = 0L
)
db.tracksDao.upsert(entity)
}
}

View File

@@ -29,7 +29,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback {
webView.webViewClient = BrowserClient(this) webView.webViewClient = BrowserClient(this)
val url = intent?.dataString val url = intent?.dataString
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
finish() finishAfterTransition()
} else { } else {
webView.loadUrl(url) webView.loadUrl(url)
} }
@@ -43,7 +43,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
webView.stopLoading() webView.stopLoading()
finish() finishAfterTransition()
true true
} }
R.id.action_browser -> { R.id.action_browser -> {

View File

@@ -5,7 +5,7 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder
import moxy.MvpAppCompatDialogFragment import moxy.MvpAppCompatDialogFragment
abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() { abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() {
@@ -18,7 +18,7 @@ abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : Mv
if (view != null) { if (view != null) {
onViewCreated(view, savedInstanceState) onViewCreated(view, savedInstanceState)
} }
return AlertDialog.Builder(requireContext(), theme) return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(view) .setView(view)
.also(::onBuildDialog) .also(::onBuildDialog)
.create() .create()

View File

@@ -1,22 +1,14 @@
package org.koitharu.kotatsu.ui.common package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent { abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) { override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID) super.setContentView(layoutResID)
setupToolbar() setupToolbar()
@@ -35,49 +27,4 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
onBackPressed() onBackPressed()
true true
} else super.onOptionsItemSelected(item) } else super.onOptionsItemSelected(item)
fun requestPermission(permission: String, callback: (Boolean) -> Unit) {
if (ContextCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
) {
callback(true)
} else {
permissionCallback = callback
ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_PERMISSION)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSION) {
grantResults.singleOrNull()?.let {
permissionCallback?.invoke(it == PackageManager.PERMISSION_GRANTED)
}
permissionCallback = null
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
recreate()
return true
}
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
StorageSelectDialog.Builder(this).create().show()
return true
}
return super.onKeyDown(keyCode, event)
}
private companion object {
const val REQUEST_PERMISSION = 30
}
} }

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.ui.common
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import org.koin.core.KoinComponent import org.koin.android.ext.android.inject
import org.koin.core.inject
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), KoinComponent { PreferenceFragmentCompat() {
protected val settings by inject<AppSettings>() protected val settings by inject<AppSettings>()

View File

@@ -7,10 +7,9 @@ import androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import org.koin.core.KoinComponent
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
abstract class BaseService : Service(), KoinComponent, CoroutineScope { abstract class BaseService : Service(), CoroutineScope {
private val job = SupervisorJob() private val job = SupervisorJob()

View File

@@ -6,7 +6,7 @@ import androidx.annotation.DrawableRes
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(private val context: Context) { class ChipsFactory(val context: Context) {
fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0,
tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip { tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip {

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.ui.common
import android.util.ArrayMap
import moxy.MvpPresenter
import java.lang.ref.WeakReference
abstract class SharedPresenterHolder<T : MvpPresenter<*>> {
private val cache = ArrayMap<Int, WeakReference<T>>(3)
fun getInstance(key: Int): T {
var instance = cache[key]?.get()
if (instance == null) {
instance = onCreatePresenter(key)
cache[key] = WeakReference(instance)
}
return instance
}
fun clear(key: Int) {
cache.remove(key)
}
protected abstract fun onCreatePresenter(key: Int): T
}

View File

@@ -8,6 +8,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) : class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
@@ -22,7 +23,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
.inflate(R.layout.dialog_checkbox, null, false) .inflate(R.layout.dialog_checkbox, null, false)
private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox) private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(view) .setView(view)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -2,15 +2,16 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Environment
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.item_storage.view.* import kotlinx.android.synthetic.main.item_storage.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.findParent import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.inflate import org.koitharu.kotatsu.utils.ext.inflate
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File import java.io.File
@@ -20,12 +21,24 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun show() = delegate.show() fun show() = delegate.show()
class Builder(context: Context) { class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val delegate = AlertDialog.Builder(context) private val adapter = VolumesAdapter(context)
.setAdapter(VolumesAdapter(context)) { _, _ -> private val delegate = MaterialAlertDialogBuilder(context)
init {
if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage)
} else {
val checked = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath
}
delegate.setSingleChoiceItems(adapter, checked) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss()
}
} }
}
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId) delegate.setTitle(titleResId)
@@ -37,12 +50,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return this return this
} }
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create()) fun create() = StorageSelectDialog(delegate.create())
} }
private class VolumesAdapter(context: Context): BaseAdapter() { private class VolumesAdapter(context: Context) : BaseAdapter() {
private val volumes = getAvailableVolumes(context) val volumes = getAvailableVolumes(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage) val view = convertView ?: parent.inflate(R.layout.item_storage)
@@ -52,7 +70,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return view return view
} }
override fun getItem(position: Int): Any = volumes[position] override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode() override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode()
@@ -60,15 +78,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
} }
interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object { private companion object {
@JvmStatic @JvmStatic
fun getAvailableVolumes(context: Context): List<Pair<File,String>> = context.getExternalFilesDirs(null).mapNotNull { fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
val root = it.findParent { x -> x.name == "Android" }?.parentFile ?: return@mapNotNull null return LocalMangaRepository.getAvailableStorageDirs(context).map {
root to when { it to it.getStorageName(context)
Environment.isExternalStorageEmulated(root) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(root) -> context.getString(R.string.external_storage)
else -> root.name
} }
} }
} }

View File

@@ -3,28 +3,17 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.text.InputFilter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.dialog_input.view.* import kotlinx.android.synthetic.main.dialog_input.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.showKeyboard
class TextInputDialog private constructor(private val delegate: AlertDialog) : class TextInputDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate { DialogInterface by delegate {
init {
delegate.setOnShowListener {
val view = delegate.findViewById<TextView>(R.id.inputEdit)?:return@setOnShowListener
view.post {
view.requestFocus()
view.showKeyboard()
}
}
}
fun show() = delegate.show() fun show() = delegate.show()
class Builder(context: Context) { class Builder(context: Context) {
@@ -32,12 +21,8 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false) private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(view) .setView(view)
.setOnDismissListener {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0)
}
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId) delegate.setTitle(titleResId)
@@ -54,11 +39,28 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
return this return this
} }
fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
with(view.inputLayout) {
counterMaxLength = maxLength
isCounterEnabled = maxLength > 0
}
if (strict && maxLength > 0) {
view.inputEdit.filters += InputFilter.LengthFilter(maxLength)
}
return this
}
fun setInputType(inputType: Int): Builder { fun setInputType(inputType: Int): Builder {
view.inputEdit.inputType = inputType view.inputEdit.inputType = inputType
return this return this
} }
fun setText(text: String): Builder {
view.inputEdit.setText(text)
view.inputEdit.setSelection(text.length)
return this
}
fun setPositiveButton(@StringRes textId: Int, listener: (DialogInterface, String) -> Unit): Builder { fun setPositiveButton(@StringRes textId: Int, listener: (DialogInterface, String) -> Unit): Builder {
delegate.setPositiveButton(textId) { dialog, _ -> delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, view.inputEdit.text?.toString().orEmpty()) listener(dialog, view.inputEdit.text?.toString().orEmpty())

View File

@@ -80,19 +80,30 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
onDataSetChanged() onDataSetChanged()
} }
override fun onViewRecycled(holder: BaseViewHolder<T, E>) {
holder.onRecycled()
}
final override fun getItemCount() = dataSet.size final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> { final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {
return onCreateViewHolder(parent).setOnItemClickListener(onItemClickListener) return onCreateViewHolder(parent)
.also(this::onViewHolderCreated) }
override fun onViewDetachedFromWindow(holder: BaseViewHolder<T, E>) {
holder.setOnItemClickListener(null)
super.onViewDetachedFromWindow(holder)
}
override fun onViewAttachedToWindow(holder: BaseViewHolder<T, E>) {
super.onViewAttachedToWindow(holder)
holder.setOnItemClickListener(onItemClickListener)
} }
protected open fun onDataSetChanged() = Unit protected open fun onDataSetChanged() = Unit
protected abstract fun getExtra(item: T, position: Int): E protected abstract fun getExtra(item: T, position: Int): E
protected open fun onViewHolderCreated(holder: BaseViewHolder<T, E>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E> protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E>
protected abstract fun onGetItemId(item: T): Long protected abstract fun onGetItemId(item: T): Long

View File

@@ -26,19 +26,29 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
onBind(data, extra) onBind(data, extra)
} }
fun requireData() = boundData ?: throw IllegalStateException("Calling requireData() before bind()") fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?): BaseViewHolder<T, E> {
if (listener != null) {
itemView.setOnClickListener {
listener.onItemClick(boundData ?: return@setOnClickListener, adapterPosition, it)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, adapterPosition, it)
}
}
return this
} }
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?) {
val listenersAdapter = listener?.let { HolderListenersAdapter(it) }
itemView.setOnClickListener(listenersAdapter)
itemView.setOnLongClickListener(listenersAdapter)
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E) abstract fun onBind(data: T, extra: E)
private inner class HolderListenersAdapter(private val listener: OnRecyclerItemClickListener<T>) :
View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
listener.onItemClick(boundData ?: return, bindingAdapterPosition, v)
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(boundData ?: return false, bindingAdapterPosition, v)
}
}
} }

View File

@@ -12,13 +12,15 @@ abstract class BoundsScrollListener(private val offsetTop: Int, private val offs
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
if (firstVisibleItemPosition <= offsetTop) { if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView) onScrolledToStart(recyclerView)
return
} }
val visibleItemCount = layoutManager.childCount val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom && firstVisibleItemPosition >= 0) { if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView) onScrolledToEnd(recyclerView)
} }
} }

View File

@@ -2,24 +2,31 @@ package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
class PaginationScrollListener(offset: Int, private val callback: Callback) : BoundsScrollListener(0, offset) { class PaginationScrollListener(offset: Int, private val callback: Callback) :
BoundsScrollListener(0, offset) {
private var lastTotalCount = 0 private var lastTotalCount = 0
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) { override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = recyclerView.adapter?.itemCount ?: 0 val total = callback.getItemsCount()
if (total > lastTotalCount) { if (total > lastTotalCount) {
callback.onRequestMoreItems(total)
lastTotalCount = total lastTotalCount = total
callback.onRequestMoreItems(total)
} else if (total < lastTotalCount) { } else if (total < lastTotalCount) {
lastTotalCount = total lastTotalCount = total
} }
} }
fun reset() {
lastTotalCount = 0
}
interface Callback { interface Callback {
fun onRequestMoreItems(offset: Int) fun onRequestMoreItems(offset: Int)
fun getItemsCount(): Int
} }
} }

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.ViewGroup
class ProgressBarAdapter : BaseRecyclerAdapter<Boolean, Unit>() {
var isProgressVisible: Boolean
get() = dataSet.isNotEmpty()
set(value) {
if (value == dataSet.isEmpty()) {
if (value) {
appendItem(true)
} else {
removeItemAt(0)
}
}
}
override fun getExtra(item: Boolean, position: Int) = Unit
override fun onCreateViewHolder(parent: ViewGroup) = ProgressBarHolder(parent)
override fun onGetItemId(item: Boolean) = -1L
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.common.list
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_progress.*
import org.koitharu.kotatsu.R
class ProgressBarHolder(parent: ViewGroup) :
BaseViewHolder<Boolean, Unit>(parent, R.layout.item_progress) {
private var pendingVisibility: Int = View.GONE
private val action = Runnable {
progressBar?.visibility = pendingVisibility
pendingVisibility = View.GONE
}
override fun onBind(data: Boolean, extra: Unit) {
val visibility = if (data) {
View.VISIBLE
} else {
View.INVISIBLE
}
if (visibility != progressBar.visibility && visibility != pendingVisibility) {
progressBar.removeCallbacks(action)
pendingVisibility = visibility
progressBar.postDelayed(action, 400)
}
}
override fun onRecycled() {
progressBar.removeCallbacks(action)
super.onRecycled()
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.ui.details package org.koitharu.kotatsu.ui.details
import android.graphics.Color
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.item_chapter.* import kotlinx.android.synthetic.main.item_chapter.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
@@ -14,6 +16,7 @@ class ChapterHolder(parent: ViewGroup) :
override fun onBind(data: MangaChapter, extra: ChapterExtra) { override fun onBind(data: MangaChapter, extra: ChapterExtra) {
textView_title.text = data.name textView_title.text = data.name
textView_number.text = data.number.toString() textView_number.text = data.number.toString()
imageView_check.isVisible = extra == ChapterExtra.CHECKED
when (extra) { when (extra) {
ChapterExtra.UNREAD -> { ChapterExtra.UNREAD -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_default) textView_number.setBackgroundResource(R.drawable.bg_badge_default)
@@ -31,6 +34,10 @@ class ChapterHolder(parent: ViewGroup) :
textView_number.setBackgroundResource(R.drawable.bg_badge_accent) textView_number.setBackgroundResource(R.drawable.bg_badge_accent)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
} }
ChapterExtra.CHECKED -> {
textView_number.background = null
textView_number.setTextColor(Color.TRANSPARENT)
}
} }
} }
} }

View File

@@ -10,23 +10,68 @@ import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChapter>) : class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChapter>) :
BaseRecyclerAdapter<MangaChapter, ChapterExtra>(onItemClickListener) { BaseRecyclerAdapter<MangaChapter, ChapterExtra>(onItemClickListener) {
private val checkedIds = HashSet<Long>()
val checkedItemsCount: Int
get() = checkedIds.size
val checkedItemsIds: Set<Long>
get() = checkedIds
var currentChapterId: Long? = null var currentChapterId: Long? = null
set(value) { set(value) {
field = value field = value
updateCurrentPosition() updateCurrentPosition()
} }
var newChaptersCount: Int = 0
set(value) {
val updated = maxOf(field, value)
field = value
notifyItemRangeChanged(itemCount - updated, updated)
}
var currentChapterPosition = RecyclerView.NO_POSITION var currentChapterPosition = RecyclerView.NO_POSITION
private set private set
fun clearChecked() {
checkedIds.clear()
notifyDataSetChanged()
}
fun checkAll() {
for (item in dataSet) {
checkedIds.add(item.id)
}
notifyDataSetChanged()
}
fun setItemIsChecked(itemId: Long, isChecked: Boolean) {
if ((isChecked && checkedIds.add(itemId)) || (!isChecked && checkedIds.remove(itemId))) {
val pos = findItemPositionById(itemId)
if (pos != RecyclerView.NO_POSITION) {
notifyItemChanged(pos)
}
}
}
fun toggleItemChecked(itemId: Long) {
setItemIsChecked(itemId, itemId !in checkedIds)
}
override fun onCreateViewHolder(parent: ViewGroup) = ChapterHolder(parent) override fun onCreateViewHolder(parent: ViewGroup) = ChapterHolder(parent)
override fun onGetItemId(item: MangaChapter) = item.id override fun onGetItemId(item: MangaChapter) = item.id
override fun getExtra(item: MangaChapter, position: Int): ChapterExtra = when { override fun getExtra(item: MangaChapter, position: Int): ChapterExtra = when {
currentChapterPosition == RecyclerView.NO_POSITION -> ChapterExtra.UNREAD item.id in checkedIds -> ChapterExtra.CHECKED
currentChapterPosition == RecyclerView.NO_POSITION
|| currentChapterPosition < position -> if (position >= itemCount - newChaptersCount) {
ChapterExtra.NEW
} else {
ChapterExtra.UNREAD
}
currentChapterPosition == position -> ChapterExtra.CURRENT currentChapterPosition == position -> ChapterExtra.CURRENT
currentChapterPosition < position -> ChapterExtra.UNREAD
currentChapterPosition > position -> ChapterExtra.READ currentChapterPosition > position -> ChapterExtra.READ
else -> ChapterExtra.UNREAD else -> ChapterExtra.UNREAD
} }

View File

@@ -1,32 +1,38 @@
package org.koitharu.kotatsu.ui.details package org.koitharu.kotatsu.ui.details
import android.app.ActivityOptions
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_chapters.* import kotlinx.android.synthetic.main.fragment_chapters.*
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.download.DownloadService import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.resolveDp
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView, class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> { OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback {
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
private lateinit var adapter: ChaptersAdapter private lateinit var adapter: ChaptersAdapter
private var actionMode: ActionMode? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -44,6 +50,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
override fun onMangaUpdated(manga: Manga) { override fun onMangaUpdated(manga: Manga) {
this.manga = manga this.manga = manga
adapter.replaceData(manga.chapters.orEmpty()) adapter.replaceData(manga.chapters.orEmpty())
scrollToCurrent()
} }
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
@@ -56,34 +63,100 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
override fun onHistoryChanged(history: MangaHistory?) { override fun onHistoryChanged(history: MangaHistory?) {
adapter.currentChapterId = history?.chapterId adapter.currentChapterId = history?.chapterId
scrollToCurrent()
}
override fun onNewChaptersChanged(newChapters: Int) {
adapter.newChaptersCount = newChapters
} }
override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit
override fun onItemClick(item: MangaChapter, position: Int, view: View) { override fun onItemClick(item: MangaChapter, position: Int, view: View) {
if (adapter.checkedItemsCount != 0) {
adapter.toggleItemChecked(item.id)
if (adapter.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
return
}
val options = ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.measuredWidth,
view.measuredHeight
)
startActivity( startActivity(
ReaderActivity.newIntent( ReaderActivity.newIntent(
context ?: return, context ?: return,
manga ?: return, manga ?: return,
item.id item.id
) ), options.toBundle()
) )
} }
override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean { override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
view.showPopupMenu(R.menu.popup_chapter) { if (actionMode == null) {
val ctx = context ?: return@showPopupMenu false actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
val m = manga ?: return@showPopupMenu false
when (it.itemId) {
R.id.action_save_this -> DownloadService.start(ctx, m, setOf(item.id))
R.id.action_save_this_next -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number >= item.number }.map { x -> x.id })
R.id.action_save_this_prev -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number <= item.number }.map { x -> x.id })
else -> return@showPopupMenu false
}
true
} }
return actionMode?.also {
adapter.setItemIsChecked(item.id, true)
it.invalidate()
} != null
}
private fun scrollToCurrent() {
val pos = (recyclerView_chapters.adapter as? ChaptersAdapter)?.currentChapterPosition
?: RecyclerView.NO_POSITION
if (pos != RecyclerView.NO_POSITION) {
(recyclerView_chapters.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(pos, resources.resolveDp(40))
}
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
DownloadService.start(
context ?: return false,
manga ?: return false,
adapter.checkedItemsIds
)
mode.finish()
true
}
R.id.action_select_all -> {
adapter.checkAll()
mode.invalidate()
true
}
else -> false
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
mode.title = manga?.title
return true return true
} }
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter.checkedItemsCount
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
count,
count,
adapter.itemCount
)
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
adapter.clearChecked()
actionMode = null
}
} }

View File

@@ -7,11 +7,15 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.activity_details.* import kotlinx.android.synthetic.main.activity_details.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moxy.MvpDelegate import moxy.MvpDelegate
@@ -25,14 +29,17 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.browser.BrowserActivity import org.koitharu.kotatsu.ui.browser.BrowserActivity
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.download.DownloadService import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ShortcutUtils
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showDialog import org.koitharu.kotatsu.utils.ext.getThemeColor
class MangaDetailsActivity : BaseActivity(), MangaDetailsView { class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
TabLayoutMediator.TabConfigurationStrategy {
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
@@ -40,14 +47,14 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details) setContentView(R.layout.activity_details)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager) pager.adapter = MangaDetailsAdapter(this)
tabs.setupWithViewPager(pager) TabLayoutMediator(tabs, pager, this).attach()
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) { if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let { intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
presenter.loadDetails(it, true) presenter.loadDetails(it, true)
} ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let { } ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let {
presenter.findMangaById(it) presenter.findMangaById(it)
} ?: finish() } ?: finishAfterTransition()
} }
} }
@@ -68,18 +75,29 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
this, getString(R.string._s_deleted_from_local_storage, manga.title), this, getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
finish() finishAfterTransition()
} }
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
if (manga == null) { if (manga == null) {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finish() finishAfterTransition()
} else { } else {
Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
} }
} }
override fun onNewChaptersChanged(newChapters: Int) {
val tab = tabs.getTabAt(1) ?: return
if (newChapters == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.number = newChapters
badge.isVisible = true
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_details, menu) menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
@@ -90,6 +108,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
manga?.source != null && manga?.source != MangaSource.LOCAL manga?.source != null && manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this) ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu) return super.onPrepareOptionsMenu(menu)
@@ -108,14 +128,14 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
} }
R.id.action_delete -> { R.id.action_delete -> {
manga?.let { m -> manga?.let { m ->
showDialog { MaterialAlertDialogBuilder(this)
setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
setMessage(getString(R.string.text_delete_local_manga, m.title)) .setMessage(getString(R.string.text_delete_local_manga, m.title))
setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
presenter.deleteLocal(m) presenter.deleteLocal(m)
} }
setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
} .show()
} }
true true
} }
@@ -123,9 +143,18 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
manga?.let { manga?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) { if (chaptersCount > 5) {
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage(getString(R.string.large_manga_save_confirm, chaptersCount)) .setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it) DownloadService.start(this, it)
@@ -145,7 +174,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
R.id.action_shortcut -> { R.id.action_shortcut -> {
manga?.let { manga?.let {
lifecycleScope.launch { lifecycleScope.launch {
if (!ShortcutUtils.requestPinShortcut(this@MangaDetailsActivity, manga)) { if (!MangaShortcut(it).requestPinShortcut(this@MangaDetailsActivity)) {
Snackbar.make( Snackbar.make(
pager, pager,
R.string.operation_not_supported, R.string.operation_not_supported,
@@ -159,10 +188,31 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = when (position) {
0 -> getString(R.string.details)
1 -> getString(R.string.chapters)
2 -> getString(R.string.related)
else -> null
}
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
pager.isUserInputEnabled = false
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
pager.isUserInputEnabled = true
window?.statusBarColor = getThemeColor(R.attr.colorPrimaryDark)
}
companion object { companion object {
private const val EXTRA_MANGA = "manga" private const val EXTRA_MANGA = "manga"
private const val EXTRA_MANGA_ID = "manga_id" const val EXTRA_MANGA_ID = "manga_id"
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA" const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"

View File

@@ -1,24 +1,17 @@
package org.koitharu.kotatsu.ui.details package org.koitharu.kotatsu.ui.details
import android.content.res.Resources
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentPagerAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import org.koitharu.kotatsu.R
class MangaDetailsAdapter(private val resources: Resources, fm: FragmentManager) : FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getCount() = 2 override fun getItemCount() = 3
override fun getItem(position: Int): Fragment = when(position) { override fun createFragment(position: Int): Fragment = when(position) {
0 -> MangaDetailsFragment() 0 -> MangaDetailsFragment()
1 -> ChaptersFragment() 1 -> ChaptersFragment()
2 -> RelatedMangaFragment()
else -> throw IndexOutOfBoundsException("No fragment for position $position") else -> throw IndexOutOfBoundsException("No fragment for position $position")
} }
override fun getPageTitle(position: Int): CharSequence? = when(position) {
0 -> resources.getString(R.string.details)
1 -> resources.getString(R.string.chapters)
else -> null
}
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.details
import android.text.Spanned import android.text.Spanned
import android.view.View import android.view.View
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.api.load import coil.api.load
@@ -13,17 +14,24 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.main.list.favourites.categories.FavouriteCategoriesDialog import org.koitharu.kotatsu.ui.list.favourites.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.search.MangaSearchSheet import org.koitharu.kotatsu.ui.search.MangaSearchSheet
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.addChips import org.koitharu.kotatsu.utils.ext.addChips
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView, View.OnClickListener { class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
View.OnClickListener,
View.OnLongClickListener {
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
private var manga: Manga? = null private var manga: Manga? = null
private var history: MangaHistory? = null private var history: MangaHistory? = null
@@ -64,9 +72,19 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
onClickListener = this@MangaDetailsFragment onClickListener = this@MangaDetailsFragment
) )
} }
imageView_favourite.setOnClickListener { manga.url.toUri().toFileOrNull()?.let { f ->
FavouriteCategoriesDialog.show(childFragmentManager, manga) chips_tags.addChips(listOf(f)) {
create(
text = FileSizeUtils.formatBytes(context, it.length()),
iconRes = R.drawable.ic_chip_storage,
tag = it,
onClickListener = this@MangaDetailsFragment
)
}
} }
imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this)
updateReadButton() updateReadButton()
} }
@@ -93,12 +111,55 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
override fun onMangaRemoved(manga: Manga) = Unit //handled in activity override fun onMangaRemoved(manga: Manga) = Unit //handled in activity
override fun onNewChaptersChanged(newChapters: Int) = Unit
override fun onClick(v: View) { override fun onClick(v: View) {
if (v is Chip) { when {
when(val tag = v.tag) { v.id == R.id.imageView_favourite -> {
is String -> MangaSearchSheet.show(activity?.supportFragmentManager ?: childFragmentManager, FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
manga?.source ?: return, tag)
} }
v.id == R.id.button_read -> {
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
history
)
)
}
v is Chip -> {
when (val tag = v.tag) {
is String -> MangaSearchSheet.show(activity?.supportFragmentManager
?: childFragmentManager,
manga?.source ?: return, tag)
}
}
}
}
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
if (history == null) {
return false
}
v.showPopupMenu(R.menu.popup_read) {
when (it.itemId) {
R.id.action_read -> {
startActivity(
ReaderActivity.newIntent(
context ?: return@showPopupMenu false,
manga ?: return@showPopupMenu false
)
)
true
}
else -> false
}
}
return true
}
else -> return false
} }
} }
@@ -114,15 +175,6 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
button_read.setText(R.string._continue) button_read.setText(R.string._continue)
button_read.setIconResource(R.drawable.ic_play) button_read.setIconResource(R.drawable.ic_play)
} }
button_read.setOnClickListener {
startActivity(
ReaderActivity.newIntent(
context ?: return@setOnClickListener,
manga ?: return@setOnClickListener,
history
)
)
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.details
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
@@ -13,27 +14,33 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.domain.MangaDataRepository import org.koitharu.kotatsu.domain.MangaDataRepository
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.MangaSearchRepository
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.domain.history.HistoryRepository import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.domain.history.OnHistoryChangeListener import org.koitharu.kotatsu.domain.history.OnHistoryChangeListener
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.common.SharedPresenterHolder
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
import java.io.IOException import java.io.IOException
@InjectViewState @InjectViewState
class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsView>(), class MangaDetailsPresenter private constructor(private val key: Int) :
OnHistoryChangeListener, BasePresenter<MangaDetailsView>(), OnHistoryChangeListener, OnFavouritesChangeListener {
OnFavouritesChangeListener {
private lateinit var historyRepository: HistoryRepository private lateinit var historyRepository: HistoryRepository
private lateinit var favouritesRepository: FavouritesRepository private lateinit var favouritesRepository: FavouritesRepository
private lateinit var trackingRepository: TrackingRepository
private lateinit var searchRepository: MangaSearchRepository
private var manga: Manga? = null private var manga: Manga? = null
override fun onFirstViewAttach() { override fun onFirstViewAttach() {
historyRepository = HistoryRepository() historyRepository = HistoryRepository()
favouritesRepository = FavouritesRepository() favouritesRepository = FavouritesRepository()
trackingRepository = TrackingRepository()
searchRepository = MangaSearchRepository()
super.onFirstViewAttach() super.onFirstViewAttach()
HistoryRepository.subscribe(this) HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this) FavouritesRepository.subscribe(this)
@@ -48,7 +55,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} ?: throw MangaNotFoundException("Cannot find manga by id") } ?: throw MangaNotFoundException("Cannot find manga by id")
viewState.onMangaUpdated(manga) viewState.onMangaUpdated(manga)
loadDetails(manga, true) loadDetails(manga, true)
} catch (_: CancellationException){ } catch (_: CancellationException) {
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -75,7 +82,8 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
viewState.onMangaUpdated(data) viewState.onMangaUpdated(data)
this@MangaDetailsPresenter.manga = data this@MangaDetailsPresenter.manga = data
} catch (_: CancellationException){ viewState.onNewChaptersChanged(trackingRepository.getNewChaptersCount(manga.id))
} catch (_: CancellationException) {
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -143,6 +151,38 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
} }
fun loadRelated() {
val manga = this.manga ?: return
presenterScope.launch {
viewState.onLoadingStateChanged(isLoading = true)
var isFirstCall = true
searchRepository.globalSearch(manga.title)
.map { list ->
list.filter { x -> x.id != manga.id }
}.filterNot { x -> x.isEmpty() }
.flowOn(Dispatchers.IO)
.catch { e ->
if (e is IOException) {
viewState.onError(e)
}
}
.onEmpty {
viewState.onListChanged(emptyList())
viewState.onLoadingStateChanged(isLoading = false)
}.onCompletion {
viewState.onListAppended(emptyList())
}.collect {
if (isFirstCall) {
isFirstCall = false
viewState.onListChanged(it)
viewState.onLoadingStateChanged(isLoading = false)
} else {
viewState.onListAppended(it)
}
}
}
}
override fun onHistoryChanged() { override fun onHistoryChanged() {
loadHistory(manga ?: return) loadHistory(manga ?: return)
} }
@@ -153,21 +193,17 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
} }
override fun onCategoriesChanged() = Unit
override fun onDestroy() { override fun onDestroy() {
HistoryRepository.unsubscribe(this) HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this) FavouritesRepository.unsubscribe(this)
instance = null clear(key)
super.onDestroy() super.onDestroy()
} }
companion object { companion object Holder : SharedPresenterHolder<MangaDetailsPresenter>() {
private var instance: MangaDetailsPresenter? = null override fun onCreatePresenter(key: Int) = MangaDetailsPresenter(key)
fun getInstance(): MangaDetailsPresenter = instance ?: synchronized(this) {
MangaDetailsPresenter().also {
instance = it
}
}
} }
} }

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.ui.details package org.koitharu.kotatsu.ui.details
import moxy.MvpView import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy
import moxy.viewstate.strategy.StateStrategyType
import moxy.viewstate.strategy.alias.AddToEndSingle import moxy.viewstate.strategy.alias.AddToEndSingle
import moxy.viewstate.strategy.alias.OneExecution
import moxy.viewstate.strategy.alias.SingleState import moxy.viewstate.strategy.alias.SingleState
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
@@ -22,4 +23,16 @@ interface MangaDetailsView : BaseMvpView {
@SingleState @SingleState
fun onMangaRemoved(manga: Manga) fun onMangaRemoved(manga: Manga)
@AddToEndSingle
fun onNewChaptersChanged(newChapters: Int)
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListChanged(list: List<Manga>) = Unit
@StateStrategyType(AddToEndStrategy::class, tag = "content")
fun onListAppended(list: List<Manga>) = Unit
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListError(e: Throwable) = Unit
} }

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.ui.details
import android.os.Bundle
import android.view.View
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.list.MangaListFragment
class RelatedMangaFragment : MangaListFragment<Unit>(), MangaDetailsView {
private val presenter by moxyPresenter {
MangaDetailsPresenter.getInstance(activity.hashCode())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isSwipeRefreshEnabled = false
}
override fun onRequestMoreItems(offset: Int) {
if (offset == 0) {
presenter.loadRelated()
}
}
override fun onMangaUpdated(manga: Manga) = Unit
override fun onHistoryChanged(history: MangaHistory?) = Unit
override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit
override fun onMangaRemoved(manga: Manga) = Unit
override fun onNewChaptersChanged(newChapters: Int) = Unit
override fun onListChanged(list: List<Manga>) = super<MangaListFragment>.onListChanged(list)
override fun onListAppended(list: List<Manga>) = super<MangaListFragment>.onListAppended(list)
override fun onListError(e: Throwable) = super<MangaListFragment>.onListError(e)
}

View File

@@ -5,10 +5,11 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
@@ -31,9 +32,13 @@ class DownloadNotification(private val context: Context) {
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
) )
channel.enableVibration(false) channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
} }
fun fillFrom(manga: Manga) { fun fillFrom(manga: Manga) {
@@ -70,10 +75,11 @@ class DownloadNotification(private val context: Context) {
builder.setContentText(e.getDisplayMessage(context.resources)) builder.setContentText(e.getDisplayMessage(context.resources))
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setContentIntent(null) builder.setContentIntent(null)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
} }
fun setLargeIcon(icon: Drawable?) { fun setLargeIcon(icon: Drawable?) {
builder.setLargeIcon((icon as? BitmapDrawable)?.bitmap) builder.setLargeIcon(icon?.toBitmap())
} }
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) { fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
@@ -83,6 +89,12 @@ class DownloadNotification(private val context: Context) {
val percent = (progress / max.toFloat() * 100).roundToInt() val percent = (progress / max.toFloat() * 100).roundToInt()
builder.setProgress(max, progress, false) builder.setProgress(max, progress, false)
builder.setContentText("%d%%".format(percent)) builder.setContentText("%d%%".format(percent))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
}
fun setWaitingForNetwork() {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
} }
fun setPostProcessing() { fun setPostProcessing() {
@@ -96,6 +108,7 @@ class DownloadNotification(private val context: Context) {
builder.setContentIntent(createIntent(context, manga)) builder.setContentIntent(createIntent(context, manga))
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
} }
fun setCancelling() { fun setCancelling() {

View File

@@ -4,17 +4,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.PowerManager import android.os.PowerManager
import android.os.WorkSource
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.Coil import coil.Coil
import coil.api.get import coil.request.GetRequestBuilder
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.inject import okio.IOException
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.local.PagesCache import org.koitharu.kotatsu.core.local.PagesCache
@@ -25,27 +25,28 @@ import org.koitharu.kotatsu.domain.local.MangaZip
import org.koitharu.kotatsu.ui.common.BaseService import org.koitharu.kotatsu.ui.common.BaseService
import org.koitharu.kotatsu.ui.common.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.ui.common.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification private lateinit var notification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var connectivityManager: ConnectivityManager
private val okHttp by inject<OkHttpClient>() private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>() private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val jobs = HashMap<Int, Job>() private val jobs = HashMap<Int, Job>()
private val mutex = Mutex() private val mutex = Mutex()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notification = DownloadNotification(this) notification = DownloadNotification(this)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
} }
@@ -75,18 +76,23 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job { private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) { return launch(Dispatchers.IO) {
mutex.lock() mutex.lock()
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20)) wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
notification.fillFrom(manga) notification.fillFrom(manga)
notification.setCancelId(startId) notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification()) startForeground(DownloadNotification.NOTIFICATION_ID, notification())
} }
val destination = getExternalFilesDir("manga")!! val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: MangaZip? = null
try { try {
val repo = MangaProviderFactory.create(manga.source) val repo = MangaProviderFactory.create(manga.source)
val cover = safe { val cover = safe {
Coil.loader().get(manga.coverUrl) Coil.execute(
GetRequestBuilder(this@DownloadService)
.data(manga.coverUrl)
.build()
).drawable
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
notification.setLargeIcon(cover) notification.setLargeIcon(cover)
@@ -108,23 +114,30 @@ class DownloadService : BaseService() {
if (chaptersIds == null || chapter.id in chaptersIds) { if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter) val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) { for ((pageIndex, page) in pages.withIndex()) {
val url = repo.getPageFullUrl(page) failsafe@ do {
val file = cache[url] ?: downloadPage(url, destination) try {
output.addPage( val url = repo.getPageFullUrl(page)
chapter, val file = cache[url] ?: downloadPage(url, destination)
file, output.addPage(
pageIndex, chapter,
MimeTypeMap.getFileExtensionFromUrl(url) file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
)
} catch (e: IOException) {
notification.setWaitingForNetwork()
notification.update()
connectivityManager.waitForNetwork()
continue@failsafe
}
} while (false)
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
) )
withContext(Dispatchers.Main) { notification.update()
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
)
notification.update()
}
} }
} }
} }
@@ -163,7 +176,9 @@ class DownloadService : BaseService() {
notification.dismiss() notification.dismiss()
stopSelf(startId) stopSelf(startId)
} }
wakeLock.release() if (wakeLock.isHeld) {
wakeLock.release()
}
mutex.unlock() mutex.unlock()
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -55,6 +55,9 @@ class ListModeSelectDialog : AlertDialogFragment(R.layout.dialog_list_mode), Vie
private const val TAG = "ListModeSelectDialog" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) fun show(fm: FragmentManager) = ListModeSelectDialog()
.show(fm,
TAG
)
} }
} }

View File

@@ -1,15 +1,16 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import android.app.ActivityOptions
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
@@ -19,17 +20,21 @@ import moxy.ktx.moxyPresenter
import org.koin.core.inject import org.koin.core.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment import org.koitharu.kotatsu.ui.list.favourites.FavouritesContainerFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment import org.koitharu.kotatsu.ui.list.feed.FeedFragment
import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment import org.koitharu.kotatsu.ui.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment import org.koitharu.kotatsu.ui.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.reader.ReaderState import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.settings.AppUpdateService import org.koitharu.kotatsu.ui.search.SearchHelper
import org.koitharu.kotatsu.ui.settings.AppUpdateChecker
import org.koitharu.kotatsu.ui.settings.SettingsActivity import org.koitharu.kotatsu.ui.settings.SettingsActivity
import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
@@ -44,7 +49,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
drawerToggle = drawerToggle =
ActionBarDrawerToggle(this, drawer, toolbar, R.string.open_menu, R.string.close_menu) ActionBarDrawerToggle(this, drawer, toolbar, R.string.open_menu, R.string.close_menu)
drawer.addDrawerListener(drawerToggle) drawer.addDrawerListener(drawerToggle)
@@ -63,12 +67,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentById(R.id.container)?.let {
fab.isVisible = it is HistoryListFragment fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
navigationView.setCheckedItem(R.id.nav_history) openDefaultSection()
setPrimaryFragment(HistoryListFragment.newInstance())
}
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
} }
TrackWorker.setup(applicationContext)
AppUpdateChecker(this).invoke()
} }
override fun onDestroy() { override fun onDestroy() {
@@ -87,8 +89,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
drawerToggle.onConfigurationChanged(newConfig) drawerToggle.onConfigurationChanged(newConfig)
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu) menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
SearchHelper.setupSearchView(menuItem)
}
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -103,9 +108,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { } else when (item.itemId) {
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance()) R.id.nav_history -> {
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance()) settings.defaultSection = AppSection.HISTORY
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance()) setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
settings.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
settings.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_feed -> {
settings.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> { R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this)) startActivity(SettingsActivity.newIntent(this))
return true return true
@@ -117,7 +135,16 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
override fun onOpenReader(state: ReaderState) { override fun onOpenReader(state: ReaderState) {
startActivity(ReaderActivity.newIntent(this, state)) val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
fab, 0, 0, fab.measuredWidth, fab.measuredHeight
)
} else {
ActivityOptions.makeScaleUpAnimation(
fab, 0, 0, fab.measuredWidth, fab.measuredHeight
)
}
startActivity(ReaderActivity.newIntent(this, state), options?.toBundle())
} }
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
@@ -155,6 +182,27 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun openDefaultSection() {
when(settings.defaultSection) {
AppSection.LOCAL -> {
navigationView.setCheckedItem(R.id.nav_local_storage)
setPrimaryFragment(LocalListFragment.newInstance())
}
AppSection.FAVOURITES -> {
navigationView.setCheckedItem(R.id.nav_favourites)
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
AppSection.HISTORY -> {
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
AppSection.FEED -> {
navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance())
}
}
}
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.ui.main package org.koitharu.kotatsu.ui.list
import moxy.viewstate.strategy.alias.OneExecution import moxy.viewstate.strategy.alias.OneExecution
import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.ui.common.BaseMvpView import org.koitharu.kotatsu.ui.common.BaseMvpView
import org.koitharu.kotatsu.ui.reader.ReaderState import org.koitharu.kotatsu.ui.reader.ReaderState

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup import android.view.ViewGroup
import coil.api.clear
import coil.api.load import coil.api.load
import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_grid.* import kotlinx.android.synthetic.main.item_manga_grid.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
@@ -11,15 +11,17 @@ import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class MangaGridHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(parent, R.layout.item_manga_grid) { class MangaGridHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(parent, R.layout.item_manga_grid) {
private var coverRequest: RequestDisposable? = null
override fun onBind(data: Manga, extra: MangaHistory?) { override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose() imageView_cover.clear()
textView_title.text = data.title textView_title.text = data.title
coverRequest = imageView_cover.load(data.coverUrl) { imageView_cover.load(data.coverUrl) {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
} }
} }
override fun onRecycled() {
imageView_cover.clear()
}
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
@@ -14,7 +14,9 @@ class MangaListAdapter(onItemClickListener: OnRecyclerItemClickListener<Manga>)
override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) { override fun onCreateViewHolder(parent: ViewGroup) = when(listMode) {
ListMode.LIST -> MangaListHolder(parent) ListMode.LIST -> MangaListHolder(parent)
ListMode.DETAILED_LIST -> MangaListDetailsHolder(parent) ListMode.DETAILED_LIST -> MangaListDetailsHolder(
parent
)
ListMode.GRID -> MangaGridHolder(parent) ListMode.GRID -> MangaGridHolder(parent)
} }

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