Compare commits

...

72 Commits
v0.3 ... v0.5

Author SHA1 Message Date
Koitharu
2374c96009 Fix source preferences summaries 2020-07-19 11:44:12 +03:00
Koitharu
2dd51117e9 Remove unused resources 2020-07-16 19:27:59 +03:00
Koitharu
6c5f3c7d97 Fix readmanga search 2020-07-16 19:20:59 +03:00
Koitharu
626bb20edb Fix global search 2020-07-12 13:48:52 +03:00
Koitharu
d363869dab Fix cbz thumbnails 2020-07-10 07:22:16 +03:00
Koitharu
774f33c63d Get remote manga for local in tracker 2020-07-10 07:14:41 +03:00
Koitharu
079427346a Update dependencies 2020-07-10 07:09:41 +03:00
Koitharu
a1a3125834 Fix crash on manga downloading 2020-07-06 20:05:41 +03:00
Koitharu
fc9c8f8a79 Update readme 2020-07-05 17:30:44 +03:00
Koitharu
c06923dbdf Merge branch 'devel' 2020-07-05 17:14:37 +03:00
Koitharu
66ca51cc73 Fix MangaTown endless search 2020-07-05 17:09:38 +03:00
Koitharu
bf45480366 Update henchan default domain 2020-07-05 17:01:43 +03:00
Koitharu
28618e394e Fix *chan search 2020-07-05 16:59:20 +03:00
Koitharu
9762a466ce Fix Mangalib pages loading 2020-07-05 16:45:34 +03:00
Koitharu
367a97a95b Fix page error processing 2020-07-01 19:37:15 +03:00
Koitharu
c3ab197aa0 Fix some StrictMode warnings 2020-07-01 19:17:08 +03:00
Koitharu
a0aa33a499 Option to clear updates feed 2020-06-29 13:43:01 +03:00
Koitharu
b27bc86141 Fix search history preference 2020-06-29 13:28:25 +03:00
Koitharu
84ef2af82f Fix MangaDetailsPresenter sharing 2020-06-29 13:26:28 +03:00
Koitharu
a2f09d8763 Restore download after network error 2020-06-29 09:48:17 +03:00
Koitharu
79058440a1 Fix grid span count #11 2020-06-25 19:40:50 +03:00
Koitharu
7f9cfdbf7a Show app update in dialog 2020-06-20 12:10:42 +03:00
Koitharu
85f7477450 Cleaning up traks 2020-06-20 11:35:42 +03:00
Koitharu
0e08d75626 Update dependencies 2020-06-14 17:12:30 +03:00
Koitharu
1b4a65f476 Update pages thumbnails list 2020-06-11 19:59:54 +03:00
Koitharu
2e69395ade Update ui 2020-06-08 19:22:56 +03:00
Koitharu
3f61f13b7b Show related manga 2020-06-07 20:14:44 +03:00
Koitharu
10a0f0ad53 Fix empty tracker log records 2020-06-05 19:25:10 +03:00
Koitharu
680fc66f21 Tracker fixes 2020-06-03 18:52:00 +03:00
Koitharu
e01b74ee3d Pagination loading indicator 2020-05-31 12:10:43 +03:00
Koitharu
3539e6a892 Global search 2020-05-30 11:18:14 +03:00
Koitharu
ff56f5a343 Fix updates feed 2020-05-30 10:25:48 +03:00
Koitharu
9ce43a39c8 Global search 2020-05-30 09:48:04 +03:00
Koitharu
0e3aa3f380 Update readme 2020-05-24 13:25:38 +03:00
Koitharu
7927bf0c9a Refactor 2020-05-24 12:58:17 +03:00
Koitharu
aec2d71688 Manga updates feed 2020-05-22 20:28:14 +03:00
Koitharu
140a0f4d66 Log manga tracking 2020-05-22 19:20:08 +03:00
Koitharu
7cf57535ab Update dependencies 2020-05-21 21:27:21 +03:00
Koitharu
31fe924157 Merge branch 'master' into devel 2020-05-21 21:20:39 +03:00
Koitharu
6d193baa69 Increment version 2020-05-21 20:48:01 +03:00
Koitharu
3bd7b54405 Capitalize MangaLib genres 2020-05-21 20:47:10 +03:00
Koitharu
d99450c5a3 Prepopulate favourite categories 2020-05-21 20:43:24 +03:00
Koitharu
6444122c0a Update database: add tracklogs table 2020-05-21 20:30:10 +03:00
Koitharu
fe14ccb5ec Update readme 2020-05-20 20:05:51 +03:00
Koitharu
e38e5fdf0f Small enhancements 2020-05-20 19:51:28 +03:00
Koitharu
c1c2b11bd8 Show local manga size 2020-05-20 19:31:17 +03:00
Koitharu
7d147b3c37 Check wakelock is held in download service 2020-05-20 19:04:31 +03:00
Koitharu
260ff32cd1 Detect chapters in cbz if index missing 2020-05-20 18:59:30 +03:00
Koitharu
ccc5f3e423 Fix webtoon scroll 2020-05-17 17:25:18 +03:00
Koitharu
8b32a60743 Misc fixes 2020-05-16 10:20:55 +03:00
Koitharu
c1faf2fe06 Remember last opened section 2020-05-16 09:07:09 +03:00
Koitharu
3588270742 Show favourites by categories and manager categories order 2020-05-16 08:49:34 +03:00
Koitharu
01607ec1e2 Fix MangaLib provider 2020-05-13 20:17:37 +03:00
Koitharu
50f8cb9193 Action mode chapters selection 2020-05-11 17:30:59 +03:00
Koitharu
0100974508 Optimize webtoon scroll 2020-05-11 14:04:51 +03:00
Koitharu
b438898456 Add mangalib source 2020-05-10 18:52:00 +03:00
Koitharu
c3c43dce3d Update dependencies and fix deprecations 2020-05-10 13:38:14 +03:00
Koitharu
e33dfd63e4 Downgrade kotlin 2020-05-10 12:36:14 +03:00
Koitharu
1927500f5a Configure shelf appwidget 2020-05-09 14:15:13 +03:00
Koitharu
f9ccd0851d Small refactor 2020-05-09 12:33:09 +03:00
Koitharu
23412e5c17 Optimize webtoon scroll 2020-05-09 10:16:46 +03:00
Koitharu
1b7c8355ec Refactor: fix deprecations 2020-05-03 18:04:26 +03:00
Koitharu
8378b3dd90 Disable save action for local chapters 2020-05-03 17:31:33 +03:00
Koitharu
9ff5bb6352 Update readme 2020-04-26 20:43:26 +03:00
Koitharu
b2bb1d22df Fix default storage 2020-04-26 20:28:15 +03:00
Koitharu
34acf5bb55 Fix json null string 2020-04-26 20:25:46 +03:00
Koitharu
5af32898f8 Add MangaDex source 2020-04-26 20:22:49 +03:00
Koitharu
ef7108f6c9 Fix typo 2020-04-26 16:48:57 +03:00
Koitharu
941d992793 Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-26 16:38:32 +03:00
Koitharu
de9a07a680 Select storage where save manga 2020-04-26 12:22:36 +03:00
Koitharu
0dc74f9188 Update readme 2020-04-25 18:06:11 +03:00
Koitharu
f95cf9b231 Reset new chapters on reading 2020-04-25 17:22:20 +03:00
247 changed files with 4034 additions and 1251 deletions

4
.idea/compiler.xml generated
View File

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

View File

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

View File

@@ -1,6 +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"?>
<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" />
</component>
<component name="ProjectType">

9
.idea/vcs.xml generated
View File

@@ -1,5 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>

View File

@@ -8,6 +8,8 @@ Kotatsu is a free and open source manga reader for Android.
Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
### Main Features
* Online manga catalogues
@@ -18,13 +20,15 @@ Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
* Tablet-optimized modern UI
* Reading third-party comics from CBZ
* Standard and Webtoon-optimized reader
* Checking for new chapters
* Notifications about new chapters
* Updates feed
* Global search
### 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
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
@@ -37,4 +41,4 @@ published by the Free Software Foundation, either version 3 of the License, or
### Disclaimer
The developers of this application does not have any affiliation with the content providers available.
The developers of this application does not have any affiliation with the content providers available.

View File

@@ -1,10 +1,11 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
}
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
android {
compileSdkVersion 29
@@ -15,29 +16,28 @@ android {
minSdkVersion 21
targetSdkVersion 29
versionCode gitCommits
versionName '0.3'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
versionName '0.5'
kapt {
arguments {
arg('room.schemaLocation', "$projectDir/schemas".toString())
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
}
archivesBaseName = "kotatsu_${gitCommits}"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
}
buildTypes {
debug {
applicationIdSuffix = '.debug'
}
release {
multiDexEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -58,19 +58,22 @@ androidExtensions {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'
implementation 'androidx.core:core-ktx:1.3.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.2.4'
implementation 'androidx.appcompat:appcompat:1.2.0-beta01'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02'
implementation 'androidx.core:core-ktx:1.5.0-alpha01'
implementation 'androidx.activity:activity-ktx:1.2.0-alpha06'
implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha05'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta8'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha04'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.3.4'
implementation 'com.google.android.material:material:1.2.0-alpha06'
implementation 'androidx.work:work-runtime-ktx:2.4.0-rc01'
implementation 'com.google.android.material:material:1.3.0-alpha01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.0-alpha05'
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
@@ -82,19 +85,19 @@ dependencies {
implementation 'com.github.moxy-community:moxy-ktx:2.1.2'
kapt 'com.github.moxy-community:moxy-compiler:2.1.2'
implementation 'com.squareup.okhttp3:okhttp:4.5.0'
implementation 'com.squareup.okio:okio:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.8.0'
implementation 'com.squareup.okio:okio:2.7.0'
implementation 'org.jsoup:jsoup:1.13.1'
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.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.1.2'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.1.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.2.0'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
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.* { *; }
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
public <init>(...);
}
}
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View File

@@ -23,7 +23,7 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute">
<activity android:name=".ui.main.MainActivity">
<activity android:name=".ui.list.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -59,9 +59,18 @@
android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".ui.main.list.favourites.categories.CategoriesActivity"
android:windowSoftInputMode="stateAlwaysHidden"
android:label="@string/favourites_categories" />
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
android:name=".ui.download.DownloadService"
@@ -87,21 +96,25 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
<receiver android:name=".ui.widget.shelf.ShelfWidgetProvider"
<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"
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_shelf" />
</receiver>
<receiver android:name=".ui.widget.recent.RecentWidgetProvider"
<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"
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu
import android.app.Application
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import coil.Coil
import coil.ImageLoader
import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -13,15 +15,15 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
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.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
import org.koitharu.kotatsu.core.local.cookies.cache.SetCookieCache
import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePersistor
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
@@ -44,6 +46,19 @@ class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build())
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build())
}
initKoin()
initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
@@ -73,7 +88,7 @@ class KotatsuApp : Application() {
single {
MangaLoaderContext()
}
factory {
single {
AppSettings(applicationContext)
}
single {
@@ -85,16 +100,19 @@ class KotatsuApp : Application() {
}
private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) {
okHttpClient {
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
}
componentRegistry {
add(CbzFetcher())
}
})
Coil.setImageLoader(
ImageLoaderBuilder(applicationContext)
.okHttpClient(
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
).componentRegistry(
ComponentRegistry.Builder()
.add(CbzFetcher())
.build()
)
.build()
)
}
private fun initErrorHandler() {
@@ -120,5 +138,6 @@ class KotatsuApp : Application() {
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(Migration1To2, Migration2To3, Migration3To4)
).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,8 +9,8 @@ import org.koitharu.kotatsu.core.db.entity.FavouriteCategoryEntity
@Dao
abstract class FavouriteCategoriesDao {
@Query("SELECT category_id,title,created_at FROM favourite_categories ORDER BY :orderBy")
abstract suspend fun findAll(orderBy: String): List<FavouriteCategoryEntity>
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -20,4 +20,14 @@ abstract class FavouriteCategoriesDao {
@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

@@ -9,8 +9,20 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
abstract class FavouritesDao {
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga>
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at")
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>

View File

@@ -7,8 +7,9 @@ import org.koitharu.kotatsu.core.db.entity.*
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class
], version = 4
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class
], version = 6
)
abstract class MangaDatabase : RoomDatabase() {
@@ -25,4 +26,6 @@ abstract class MangaDatabase : RoomDatabase() {
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

@@ -25,11 +25,13 @@ abstract class TracksDao {
@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)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String
) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
createdAt = Date(createdAt)
)
}

View File

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

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 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
import java.lang.NullPointerException
class MangaNotFoundException(s: String? = null) : RuntimeException(s)

View File

@@ -9,5 +9,6 @@ data class AppVersion(
val name: String,
val url: String,
val apkSize: Long,
val apkUrl: String
val apkUrl: String,
val description: String
) : Parcelable

View File

@@ -22,7 +22,8 @@ class GithubRepository : KoinComponent {
url = json.getString("html_url"),
name = json.getString("name").removePrefix("v"),
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()
private inner class SetCookieCacheIterator() : MutableIterator<Cookie> {
private inner class SetCookieCacheIterator : MutableIterator<Cookie> {
private val iterator = cookies.iterator()

View File

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

View File

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

View File

@@ -20,5 +20,8 @@ enum class MangaSource(
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java),
DESUME("Desu.me", "ru", DesuMeRepository::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,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,7 +1,10 @@
package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koin.core.KoinComponent
@@ -14,6 +17,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -29,8 +33,8 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val files = context.getExternalFilesDirs("manga")
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() }
val files = getAvailableStorageDirs(context)
.flatMap { x -> x.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getFromFile(x) } }
}
@@ -40,69 +44,84 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
@Suppress("BlockingMethodInNonBlockingContext")
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 pattern = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
?.getChapterNamesPattern(chapter)
val entries = if (pattern != null) {
zip.entries().asSequence()
.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
zip.entries().asSequence().filter { x -> !x.isDirectory }
}.toList().sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return entries.map { x ->
val uri = zipUri(file, x.name)
MangaPage(
id = uri.longHashCode(),
url = uri,
source = MangaSource.LOCAL
)
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
""
) == parent
}
}
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 {
val file = Uri.parse(manga.url).toFile()
return file.delete()
}
fun getFromFile(file: File): Manga {
val zip = ZipFile(file)
@SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
return index?.let {
it.getMangaInfo()?.let { x ->
x.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,
val info = index?.getMangaInfo()
if (index != null && info != null) {
return info.copy(
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries())?.name.orEmpty()),
chapters = listOf(
MangaChapter(
id = file.absolutePath.longHashCode(),
url = fileUri,
number = 1,
source = MangaSource.LOCAL,
name = title
)
)
url = fileUri,
coverUrl = zipUri(
file,
entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
),
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = file.toUri().buildUpon()
Manga(
id = file.absolutePath.longHashCode(),
title = title,
url = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(file, findFirstEntry(zip.entries(), isImage = true)?.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? {
@@ -118,11 +137,19 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()
private fun findFirstEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
private fun findFirstEntry(entries: Enumeration<out ZipEntry>, isImage: Boolean): ZipEntry? {
val list = entries.toList()
.filterNot { it.isDirectory }
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
return list.firstOrNull()
return if (isImage) {
val map = MimeTypeMap.getSingleton()
list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
} else {
list.firstOrNull()
}
}
override val sortOrders = emptySet<SortOrder>()
@@ -133,9 +160,24 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
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
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
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 val loaderContext by inject<MangaLoaderContext>()
protected val conf by lazy(LazyThreadSafetyMode.NONE) {
loaderContext.getSettings(source)
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.os.Build
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
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.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
abstract class ChanRepository : RemoteMangaRepository() {
abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(
loaderContext
) {
protected abstract val defaultDomain: String
@@ -20,7 +23,12 @@ abstract class ChanRepository : RemoteMangaRepository() {
): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val url = when {
query != null -> "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
!query.isNullOrEmpty() -> {
if (offset != 0) {
return emptyList()
}
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
}
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
}

View File

@@ -4,9 +4,13 @@ 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.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

View File

@@ -4,9 +4,11 @@ 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.*
abstract class GroupleRepository : RemoteMangaRepository() {
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected abstract val defaultDomain: String
@@ -20,16 +22,19 @@ abstract class GroupleRepository : RemoteMangaRepository() {
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?,
tag: MangaTag?
): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val doc = when {
!query.isNullOrEmpty() -> loaderContext.httpPost(
"https://$domain/search",
mapOf("q" to query, "offset" to offset.toString())
mapOf("q" to query.urlEncoded(), "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(
"https://$domain/list/genre/${tag.key}?sortType=${getSortKey(
sortOrder
@@ -108,7 +113,7 @@ abstract class GroupleRepository : RemoteMangaRepository() {
a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.ownText(),
name = a.ownText().removePrefix(manga.title).trim(),
number = i + 1,
url = href,
source = source

View File

@@ -5,13 +5,14 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
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.parseHtml
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 = "henchan.pro"
override val source = MangaSource.HENCHAN
override suspend fun getDetails(manga: Manga): Manga {

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
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 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,178 @@
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() -> {
if (offset != 0) {
return emptyList()
}
"$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
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 defaultDomain: String = "mintmanga.live"

View File

@@ -1,9 +1,10 @@
package org.koitharu.kotatsu.core.parser.site
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.live"
override val source = MangaSource.READMANGA_RU
}

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.parser.site
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 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.MangaChapter
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.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain
class YaoiChanRepository : ChanRepository() {
class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val source = MangaSource.YAOICHAN
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

@@ -5,9 +5,12 @@ import android.content.SharedPreferences
import android.content.res.Resources
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) :
SharedPreferences by prefs {
@@ -23,6 +26,12 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
ListMode.DETAILED_LIST
)
var defaultSection by EnumPreferenceDelegate(
AppSection::class.java,
resources.getString(R.string.key_app_section),
AppSection.HISTORY
)
val theme by StringIntPreferenceDelegate(
resources.getString(R.string.key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@@ -88,6 +97,24 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
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) {
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
enum class ListMode(val id: Int) {
enum class ListMode {
LIST(0),
DETAILED_LIST(1),
GRID(2);
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
}
LIST, DETAILED_LIST, GRID;
}

View File

@@ -8,16 +8,20 @@ interface SourceConfig {
fun getDomain(defaultValue: String): String
fun isUseSsl(defaultValue: Boolean): Boolean
private class PrefSourceConfig(context: Context, source: MangaSource) : SourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
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)
?.takeUnless(String::isBlank)
?: defaultValue
override fun isUseSsl(defaultValue: Boolean) = prefs.getBoolean(keySsl, defaultValue)
}
companion object {

View File

@@ -2,13 +2,20 @@ package org.koitharu.kotatsu.domain
import org.koin.core.KoinComponent
import org.koin.core.get
import org.koin.core.inject
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.lang.ref.WeakReference
import java.util.*
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> {
val settings = get<AppSettings>()
val list = MangaSource.values().toList() - MangaSource.LOCAL
@@ -18,7 +25,7 @@ object MangaProviderFactory : KoinComponent {
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
}
return if(includeHidden) {
return if (includeHidden) {
sorted
} else {
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 {
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,39 @@
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) {
try {
MangaProviderFactory.create(source).getList(0, query, SortOrder.POPULARITY)
} catch (e: Throwable) {
e.printStackTrace()
emptyList<Manga>()
}
}
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
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient
import okhttp3.Request
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.medianOrNull
import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent {
@@ -19,17 +22,28 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? {
try {
val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
val size = client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
}
return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.favourites
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -12,19 +13,33 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import java.util.*
class FavouritesRepository : KoinComponent {
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> {
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()) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll("created_at")
val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() }
}
@@ -37,17 +52,32 @@ class FavouritesRepository : KoinComponent {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0
)
val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
}
suspend fun removeCategory(id: Long) {
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) {
@@ -68,14 +98,14 @@ class FavouritesRepository : KoinComponent {
companion object {
private val listeners = HashSet<OnFavouritesChangeListener>()
private val listeners = ArraySet<OnFavouritesChangeListener>()
fun subscribe(listener: OnFavouritesChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners += listener
listeners -= listener
}
private suspend fun notifyFavouritesChanged(mangaId: Long) {
@@ -83,5 +113,11 @@ class FavouritesRepository : KoinComponent {
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 {
fun onFavouritesChanged(mangaId: Long)
fun onCategoriesChanged()
}

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.history
import androidx.collection.ArraySet
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -17,44 +18,35 @@ import java.util.*
class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject()
private val trackingRepository by lazy(::TrackingRepository)
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit)
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)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
if (db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll
)
db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
)
) {
TrackingRepository().insertOrNothing(manga)
}
)
trackingRepository.upsert(manga)
}
notifyHistoryChanged()
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao.find(manga.id)?.let {
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll
)
}
return db.historyDao.find(manga.id)?.toMangaHistory()
}
suspend fun clear() {
@@ -80,14 +72,14 @@ class HistoryRepository : KoinComponent {
companion object {
private val listeners = HashSet<OnHistoryChangeListener>()
private val listeners = ArraySet<OnHistoryChangeListener>()
fun subscribe(listener: OnHistoryChangeListener) {
listeners += listener
}
fun unsubscribe(listener: OnHistoryChangeListener) {
listeners += listener
listeners -= listener
}
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.MangaSource
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.safe
@@ -44,12 +45,12 @@ class MangaIndex(source: String?) {
Manga(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getString("title_alt"),
altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"),
source = source,
rating = json.getDouble("rating").toFloat(),
coverUrl = json.getString("cover"),
description = json.getString("description"),
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").map { x ->
MangaTag(
title = x.getString("title"),

View File

@@ -1,11 +1,13 @@
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.model.Manga
import org.koitharu.kotatsu.core.model.MangaTracking
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.domain.MangaProviderFactory
import java.util.*
class TrackingRepository : KoinComponent {
@@ -20,12 +22,16 @@ class TrackingRepository : KoinComponent {
suspend fun getAllTracks(): List<MangaTracking> {
val favourites = db.favouritesDao.findAllManga()
val history = db.historyDao.findAllManga()
val manga = (favourites + history).distinctBy { it.id }
val mangas = (favourites + history).distinctBy { it.id }
val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return manga.map { m ->
val track = tracks[m.id]?.singleOrNull()
return mangas.mapNotNull { me ->
var manga = me.toManga()
if (manga.source == MangaSource.LOCAL) {
manga = MangaProviderFactory.createLocal().getRemoteManga(manga) ?: return@mapNotNull null
}
val track = tracks[manga.id]?.singleOrNull()
MangaTracking(
manga = m.toManga(),
manga = manga,
knownChaptersCount = track?.totalChapters ?: -1,
lastChapterId = track?.lastChapterId ?: 0L,
lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
@@ -34,25 +40,53 @@ class TrackingRepository : KoinComponent {
}
}
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: Int,
lastNotifiedChapterId: Long
newChapters: List<MangaChapter>,
previousTrackChapterId: Long
) {
val entity = TrackEntity(
mangaId = mangaId,
newChapters = newChapters,
lastCheck = System.currentTimeMillis(),
lastChapterId = lastChapterId,
totalChapters = knownChaptersCount,
lastNotifiedChapterId = lastNotifiedChapterId
)
db.tracksDao.upsert(entity)
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 insertOrNothing(manga: Manga) {
suspend fun upsert(manga: Manga) {
val chapters = manga.chapters ?: return
val entity = TrackEntity(
mangaId = manga.id,
@@ -62,6 +96,6 @@ class TrackingRepository : KoinComponent {
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = 0L
)
db.tracksDao.insert(entity)
db.tracksDao.upsert(entity)
}
}

View File

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

View File

@@ -5,7 +5,7 @@ import android.os.Bundle
import android.view.View
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import moxy.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) {
onViewCreated(view, savedInstanceState)
}
return AlertDialog.Builder(requireContext(), theme)
return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(view)
.also(::onBuildDialog)
.create()

View File

@@ -1,21 +1,14 @@
package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID)
setupToolbar()
@@ -34,49 +27,4 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
onBackPressed()
true
} 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) {
throw StackOverflowError("test")
return true
}
return super.onKeyDown(keyCode, event)
}
private companion object {
const val REQUEST_PERMISSION = 30
}
}

View File

@@ -6,7 +6,7 @@ import androidx.annotation.DrawableRes
import com.google.android.material.chip.Chip
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,
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.appcompat.app.AlertDialog
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
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)
private val checkBox = view.findViewById<MaterialCheckBox>(android.R.id.checkbox)
private val delegate = AlertDialog.Builder(context)
private val delegate = MaterialAlertDialogBuilder(context)
.setView(view)
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.DialogInterface
import android.os.Environment
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.item_storage.view.*
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.longHashCode
import java.io.File
@@ -20,12 +21,24 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun show() = delegate.show()
class Builder(context: Context) {
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val delegate = AlertDialog.Builder(context)
.setAdapter(VolumesAdapter(context)) { _, _ ->
private val adapter = 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 {
delegate.setTitle(titleResId)
@@ -37,12 +50,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
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 {
val view = convertView ?: parent.inflate(R.layout.item_storage)
@@ -52,7 +70,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
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()
@@ -60,15 +78,17 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
}
interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object {
@JvmStatic
fun getAvailableVolumes(context: Context): List<Pair<File,String>> = context.getExternalFilesDirs(null).mapNotNull {
val root = it.findParent { x -> x.name == "Android" }?.parentFile ?: return@mapNotNull null
root to when {
Environment.isExternalStorageEmulated(root) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(root) -> context.getString(R.string.external_storage)
else -> root.name
fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
return LocalMangaRepository.getAvailableStorageDirs(context).map {
it to it.getStorageName(context)
}
}
}

View File

@@ -7,6 +7,7 @@ import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.dialog_input.view.*
import org.koitharu.kotatsu.R
@@ -20,7 +21,7 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
@SuppressLint("InflateParams")
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)
fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -87,16 +87,23 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {
return onCreateViewHolder(parent).setOnItemClickListener(onItemClickListener)
.also(this::onViewHolderCreated)
return onCreateViewHolder(parent)
}
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 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 onGetItemId(item: T): Long

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.ui.common.list
import android.os.Build
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
@@ -27,26 +26,29 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
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, bindingAdapterPosition, it)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, bindingAdapterPosition, it)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
itemView.setOnContextClickListener {
listener.onItemLongClick(boundData ?: return@setOnContextClickListener false, bindingAdapterPosition, 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)
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)
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
return
}
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom && firstVisibleItemPosition >= 0) {
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView)
}
}

View File

@@ -2,24 +2,31 @@ package org.koitharu.kotatsu.ui.common.list
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
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = recyclerView.adapter?.itemCount ?: 0
val total = callback.getItemsCount()
if (total > lastTotalCount) {
callback.onRequestMoreItems(total)
lastTotalCount = total
callback.onRequestMoreItems(total)
} else if (total < lastTotalCount) {
lastTotalCount = total
}
}
fun reset() {
lastTotalCount = 0
}
interface Callback {
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
import android.graphics.Color
import android.view.ViewGroup
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.item_chapter.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaChapter
@@ -14,6 +16,7 @@ class ChapterHolder(parent: ViewGroup) :
override fun onBind(data: MangaChapter, extra: ChapterExtra) {
textView_title.text = data.name
textView_number.text = data.number.toString()
imageView_check.isVisible = extra == ChapterExtra.CHECKED
when (extra) {
ChapterExtra.UNREAD -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_default)
@@ -25,12 +28,16 @@ class ChapterHolder(parent: ViewGroup) :
}
ChapterExtra.CURRENT -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_outline_accent)
textView_number.setTextColor(context.getThemeColor(R.attr.colorAccent))
textView_number.setTextColor(context.getThemeColor(androidx.appcompat.R.attr.colorAccent))
}
ChapterExtra.NEW -> {
textView_number.setBackgroundResource(R.drawable.bg_badge_accent)
textView_number.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
ChapterExtra.CHECKED -> {
textView_number.background = null
textView_number.setTextColor(Color.TRANSPARENT)
}
}
}
}

View File

@@ -10,6 +10,14 @@ import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChapter>) :
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
set(value) {
field = value
@@ -26,11 +34,37 @@ class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChap
var currentChapterPosition = RecyclerView.NO_POSITION
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 onGetItemId(item: MangaChapter) = item.id
override fun getExtra(item: MangaChapter, position: Int): ChapterExtra = when {
item.id in checkedIds -> ChapterExtra.CHECKED
currentChapterPosition == RecyclerView.NO_POSITION
|| currentChapterPosition < position -> if (position >= itemCount - newChaptersCount) {
ChapterExtra.NEW

View File

@@ -2,7 +2,11 @@ package org.koitharu.kotatsu.ui.details
import android.app.ActivityOptions
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -10,25 +14,25 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_chapters.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
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.core.model.*
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.download.DownloadService
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,
OnRecyclerItemClickListener<MangaChapter> {
OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback {
@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 lateinit var adapter: ChaptersAdapter
private var actionMode: ActionMode? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -69,6 +73,15 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit
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,
@@ -86,20 +99,13 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
}
override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
view.showPopupMenu(R.menu.popup_chapter) {
val ctx = context ?: return@showPopupMenu false
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
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return true
return actionMode?.also {
adapter.setItemIsChecked(item.id, true)
it.invalidate()
} != null
}
private fun scrollToCurrent() {
@@ -107,7 +113,50 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
?: RecyclerView.NO_POSITION
if (pos != RecyclerView.NO_POSITION) {
(recyclerView_chapters.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(pos, 100)
?.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
}
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.MenuItem
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.net.toFile
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.activity_details.*
import kotlinx.coroutines.launch
import moxy.MvpDelegate
@@ -28,10 +32,14 @@ import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
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
@@ -39,14 +47,14 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager)
tabs.setupWithViewPager(pager)
pager.adapter = MangaDetailsAdapter(this)
TabLayoutMediator(tabs, pager, this).attach()
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
presenter.loadDetails(it, true)
} ?: intent?.getLongExtra(EXTRA_MANGA_ID, 0)?.takeUnless { it == 0L }?.let {
presenter.findMangaById(it)
} ?: finish()
} ?: finishAfterTransition()
}
}
@@ -67,13 +75,13 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
this, getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT
).show()
finish()
finishAfterTransition()
}
override fun onError(e: Throwable) {
if (manga == null) {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finish()
finishAfterTransition()
} else {
Snackbar.make(pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
@@ -100,6 +108,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
manga?.source != null && manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu)
@@ -118,7 +128,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
}
R.id.action_delete -> {
manga?.let { m ->
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
@@ -133,9 +143,18 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
manga?.let {
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
AlertDialog.Builder(this)
MaterialAlertDialogBuilder(this)
.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)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
@@ -169,6 +188,27 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
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(androidx.appcompat.R.attr.colorPrimaryDark)
}
companion object {
private const val EXTRA_MANGA = "manga"

View File

@@ -1,24 +1,17 @@
package org.koitharu.kotatsu.ui.details
import android.content.res.Resources
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import org.koitharu.kotatsu.R
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
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()
1 -> ChaptersFragment()
2 -> RelatedMangaFragment()
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,23 +2,30 @@ package org.koitharu.kotatsu.ui.details
import android.text.Spanned
import android.view.View
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import coil.api.load
import com.google.android.material.chip.Chip
import kotlinx.android.synthetic.main.fragment_details.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
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.common.BaseFragment
import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.ui.list.favourites.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.ui.reader.ReaderActivity
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.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
@@ -26,7 +33,9 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
View.OnLongClickListener {
@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 history: MangaHistory? = null
@@ -67,6 +76,21 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
onClickListener = this@MangaDetailsFragment
)
}
manga.url.toUri().toFileOrNull()?.let { f ->
lifecycleScope.launch {
val size = withContext(Dispatchers.IO) {
f.length()
}
chips_tags.addChips(listOf(f)) {
create(
text = FileSizeUtils.formatBytes(context, size),
iconRes = R.drawable.ic_chip_storage,
tag = it,
onClickListener = this@MangaDetailsFragment
)
}
}
}
imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this)
@@ -123,8 +147,8 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
}
override fun onLongClick(v: View): Boolean {
when {
v.id == R.id.button_read -> {
when (v.id) {
R.id.button_read -> {
if (history == null) {
return false
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.details
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
@@ -13,23 +14,25 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.domain.MangaDataRepository
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.OnFavouritesChangeListener
import org.koitharu.kotatsu.domain.history.HistoryRepository
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.SharedPresenterHolder
import org.koitharu.kotatsu.utils.ext.safe
import java.io.IOException
@InjectViewState
class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsView>(),
OnHistoryChangeListener,
OnFavouritesChangeListener {
class MangaDetailsPresenter private constructor(private val key: Int) :
BasePresenter<MangaDetailsView>(), OnHistoryChangeListener, OnFavouritesChangeListener {
private lateinit var historyRepository: HistoryRepository
private lateinit var favouritesRepository: FavouritesRepository
private lateinit var trackingRepository: TrackingRepository
private lateinit var searchRepository: MangaSearchRepository
private var manga: Manga? = null
@@ -37,6 +40,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
historyRepository = HistoryRepository()
favouritesRepository = FavouritesRepository()
trackingRepository = TrackingRepository()
searchRepository = MangaSearchRepository()
super.onFirstViewAttach()
HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this)
@@ -51,7 +55,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} ?: throw MangaNotFoundException("Cannot find manga by id")
viewState.onMangaUpdated(manga)
loadDetails(manga, true)
} catch (_: CancellationException){
} catch (_: CancellationException) {
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
@@ -79,7 +83,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
viewState.onMangaUpdated(data)
this@MangaDetailsPresenter.manga = data
viewState.onNewChaptersChanged(trackingRepository.getNewChaptersCount(manga.id))
} catch (_: CancellationException){
} catch (_: CancellationException) {
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
@@ -147,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() {
loadHistory(manga ?: return)
}
@@ -157,21 +193,17 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
}
}
override fun onCategoriesChanged() = Unit
override fun onDestroy() {
HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this)
instance = null
clear(key)
super.onDestroy()
}
companion object {
companion object Holder : SharedPresenterHolder<MangaDetailsPresenter>() {
private var instance: MangaDetailsPresenter? = null
fun getInstance(): MangaDetailsPresenter = instance ?: synchronized(this) {
MangaDetailsPresenter().also {
instance = it
}
}
override fun onCreatePresenter(key: Int) = MangaDetailsPresenter(key)
}
}

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.ui.details
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.SingleState
import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -23,4 +26,13 @@ interface MangaDetailsView : BaseMvpView {
@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

@@ -92,6 +92,11 @@ class DownloadNotification(private val context: Context) {
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
}
fun setWaitingForNetwork() {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
}
fun setPostProcessing() {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))

View File

@@ -8,11 +8,12 @@ import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.ContextCompat
import coil.Coil
import coil.api.get
import coil.request.GetRequestBuilder
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
@@ -24,27 +25,28 @@ import org.koitharu.kotatsu.domain.local.MangaZip
import org.koitharu.kotatsu.ui.common.BaseService
import org.koitharu.kotatsu.ui.common.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.*
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.absoluteValue
class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var connectivityManager: ConnectivityManager
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val jobs = HashMap<Int, Job>()
private val mutex = Mutex()
override fun onCreate() {
super.onCreate()
notification = DownloadNotification(this)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
}
@@ -74,23 +76,26 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) {
mutex.lock()
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20))
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
notification.fillFrom(manga)
notification.setCancelId(startId)
withContext(Dispatchers.Main) {
notification.fillFrom(manga)
notification.setCancelId(startId)
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
try {
val repo = MangaProviderFactory.create(manga.source)
val cover = safe {
Coil.loader().get(manga.coverUrl)
}
withContext(Dispatchers.Main) {
notification.setLargeIcon(cover)
notification.update()
Coil.execute(
GetRequestBuilder(this@DownloadService)
.data(manga.coverUrl)
.build()
).drawable
}
notification.setLargeIcon(cover)
notification.update()
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
@@ -107,51 +112,52 @@ class DownloadService : BaseService() {
if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
val url = repo.getPageFullUrl(page)
val file = cache[url] ?: downloadPage(url, destination)
output.addPage(
chapter,
file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
failsafe@ do {
try {
val url = repo.getPageFullUrl(page)
val file = cache[url] ?: downloadPage(url, destination)
output.addPage(
chapter,
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.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
)
notification.update()
}
notification.update()
}
}
}
withContext(Dispatchers.Main) {
notification.setCancelId(0)
notification.setPostProcessing()
notification.update()
}
notification.setCancelId(0)
notification.setPostProcessing()
notification.update()
output.compress()
val result = MangaProviderFactory.createLocal().getFromFile(output.file)
withContext(Dispatchers.Main) {
notification.setDone(result)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
}
notification.setDone(result)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
} catch (_: CancellationException) {
withContext(Dispatchers.Main + NonCancellable) {
withContext(NonCancellable) {
notification.setCancelling()
notification.setCancelId(0)
notification.update()
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
notification.setError(e)
notification.setCancelId(0)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
}
notification.setError(e)
notification.setCancelId(0)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
} finally {
withContext(NonCancellable) {
jobs.remove(startId)
@@ -162,7 +168,9 @@ class DownloadService : BaseService() {
notification.dismiss()
stopSelf(startId)
}
wakeLock.release()
if (wakeLock.isHeld) {
wakeLock.release()
}
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.view.View
@@ -55,6 +55,9 @@ class ListModeSelectDialog : AlertDialogFragment(R.layout.dialog_list_mode), Vie
private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG)
fun show(fm: FragmentManager) = ListModeSelectDialog()
.show(fm,
TAG
)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main
package org.koitharu.kotatsu.ui.list
import android.app.ActivityOptions
import android.content.SharedPreferences
@@ -11,7 +11,6 @@ import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.fragment.app.Fragment
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.navigation.NavigationView
@@ -21,20 +20,24 @@ import moxy.ktx.moxyPresenter
import org.koin.core.inject
import org.koitharu.kotatsu.R
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.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.list.favourites.FavouritesContainerFragment
import org.koitharu.kotatsu.ui.list.feed.FeedFragment
import org.koitharu.kotatsu.ui.list.history.HistoryListFragment
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.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.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener,
SharedPreferences.OnSharedPreferenceChangeListener, MainView {
@@ -43,6 +46,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
private val settings by inject<AppSettings>()
private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -65,16 +69,14 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
supportFragmentManager.findFragmentById(R.id.container)?.let {
fab.isVisible = it is HistoryListFragment
} ?: run {
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
openDefaultSection()
}
TrackWorker.setup(applicationContext)
AppUpdateChecker(this).invoke()
}
override fun onDestroy() {
closeable?.close()
settings.unsubscribe(this)
super.onDestroy()
}
@@ -90,8 +92,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
drawerToggle.onConfigurationChanged(newConfig)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem)
}
return super.onCreateOptionsMenu(menu)
}
@@ -106,9 +111,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) {
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance())
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance())
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance())
R.id.nav_history -> {
settings.defaultSection = AppSection.HISTORY
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 -> {
startActivity(SettingsActivity.newIntent(this))
return true
@@ -167,6 +185,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) {
supportFragmentManager.beginTransaction()
.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.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 org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.ui.common.BaseMvpView
import org.koitharu.kotatsu.ui.reader.ReaderState

View File

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

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list
package org.koitharu.kotatsu.ui.list
import android.annotation.SuppressLint
import android.view.ViewGroup

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list
package org.koitharu.kotatsu.ui.list
import android.content.SharedPreferences
import android.os.Bundle
@@ -9,10 +9,7 @@ import androidx.core.view.GravityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.*
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.*
@@ -28,23 +25,31 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.ProgressBarAdapter
import org.koitharu.kotatsu.ui.common.list.decor.ItemTypeDividerDecoration
import org.koitharu.kotatsu.ui.common.list.decor.SectionItemDecoration
import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.ui.main.list.filter.FilterAdapter
import org.koitharu.kotatsu.ui.main.list.filter.OnFilterChangedListener
import org.koitharu.kotatsu.ui.list.filter.FilterAdapter
import org.koitharu.kotatsu.ui.list.filter.OnFilterChangedListener
import org.koitharu.kotatsu.utils.UiUtils
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), MangaListView<E>,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list),
MangaListView<E>, PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, OnFilterChangedListener,
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
private val settings by inject<AppSettings>()
private val adapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(true)
.setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
.build()
private var adapter: MangaListAdapter? = null
private var progressAdapter: ProgressBarAdapter? = null
private var paginationListener : PaginationScrollListener? = null
protected var isSwipeRefreshEnabled = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -55,30 +60,29 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
super.onViewCreated(view, savedInstanceState)
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
adapter = MangaListAdapter(this)
progressAdapter = ProgressBarAdapter()
paginationListener = PaginationScrollListener(4, this)
recyclerView.setHasFixedSize(true)
initListMode(settings.listMode)
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
recyclerView.addOnScrollListener(paginationListener!!)
swipeRefreshLayout.setOnRefreshListener(this)
recyclerView_filter.setHasFixedSize(true)
recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context))
recyclerView_filter.addItemDecoration(SectionItemDecoration(false, this))
settings.subscribe(this)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onDestroyView() {
adapter = null
progressAdapter = null
paginationListener = null
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_list, menu)
super.onCreateOptionsMenu(menu, inflater)
@@ -122,10 +126,12 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
}
final override fun onRefresh() {
swipeRefreshLayout.isRefreshing = true
onRequestMoreItems(0)
}
override fun onListChanged(list: List<Manga>) {
paginationListener?.reset()
adapter?.replaceData(list)
if (list.isEmpty()) {
setUpEmptyListHolder()
@@ -133,11 +139,16 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
} else {
layout_holder.isVisible = false
}
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners()
}
override fun onListAppended(list: List<Manga>) {
adapter?.appendData(list)
progressAdapter?.isProgressVisible = list.isNotEmpty()
if (list.isNotEmpty()) {
layout_holder.isVisible = false
}
recyclerView.callOnScrollListeners()
}
@@ -178,10 +189,11 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
override fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems
swipeRefreshLayout.isRefreshing = isLoading && hasItems
swipeRefreshLayout.isEnabled = !progressBar.isVisible
swipeRefreshLayout.isEnabled = isSwipeRefreshEnabled && !progressBar.isVisible
if (isLoading) {
layout_holder.isVisible = false
} else {
swipeRefreshLayout.isRefreshing = false
}
}
@@ -234,11 +246,18 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver)
adapter?.listMode = mode
recyclerView.layoutManager = when (mode) {
ListMode.GRID -> GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx))
ListMode.GRID -> {
GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position < getItemsCount())
1 else this@apply.spanCount
}
}
}
else -> LinearLayoutManager(ctx)
}
recyclerView.recycledViewPool.clear()
recyclerView.adapter = adapter
recyclerView.adapter = ConcatAdapter(adapterConfig, adapter, progressAdapter)
recyclerView.addItemDecoration(
when (mode) {
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)
@@ -255,6 +274,8 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView.firstItem = position
}
override fun getItemsCount() = adapter?.itemCount ?: 0
final override fun isSection(position: Int): Boolean {
return position == 0 || recyclerView_filter.adapter?.run {
getItemViewType(position) != getItemViewType(position - 1)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list
package org.koitharu.kotatsu.ui.list
import android.view.ViewGroup
import coil.api.clear

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list
package org.koitharu.kotatsu.ui.list
import android.content.SharedPreferences
import android.os.Bundle
@@ -7,10 +7,7 @@ import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.*
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.snackbar.Snackbar
@@ -27,22 +24,30 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BaseBottomSheet
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.ProgressBarAdapter
import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.UiUtils
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaListView<E>,
abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list),
MangaListView<E>,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener {
private val settings by inject<AppSettings>()
private val adapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(true)
.setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
.build()
private var adapter: MangaListAdapter? = null
private var progressAdapter: ProgressBarAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = MangaListAdapter(this)
progressAdapter = ProgressBarAdapter()
initListMode(settings.listMode)
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
@@ -57,21 +62,18 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
textView_title.isVisible = false
appbar.elevation = resources.getDimension(R.dimen.elevation_large)
}
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onDestroyView() {
settings.unsubscribe(this)
adapter = null
progressAdapter = null
super.onDestroyView()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
protected fun setTitle(title: CharSequence) {
toolbar.title = title
textView_title.text = title
@@ -126,6 +128,7 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
override fun onListChanged(list: List<Manga>) {
adapter?.replaceData(list)
textView_holder.isVisible = list.isEmpty()
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners()
}
@@ -134,6 +137,7 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
if (list.isNotEmpty()) {
textView_holder.isVisible = false
}
progressAdapter?.isProgressVisible = list.isNotEmpty()
recyclerView.callOnScrollListeners()
}
@@ -141,6 +145,8 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
}
override fun getItemsCount() = adapter?.itemCount ?: 0
override fun onInitFilter(
sortOrders: List<SortOrder>,
tags: List<MangaTag>,
@@ -174,10 +180,17 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver)
adapter?.listMode = mode
recyclerView.layoutManager = when (mode) {
ListMode.GRID -> GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx))
ListMode.GRID -> {
GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position < getItemsCount())
1 else this@apply.spanCount
}
}
}
else -> LinearLayoutManager(ctx)
}
recyclerView.adapter = adapter
recyclerView.adapter = ConcatAdapter(adapterConfig, adapter, progressAdapter)
recyclerView.addItemDecoration(
when (mode) {
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list
package org.koitharu.kotatsu.ui.list
import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.ui.list.favourites
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.fragment_favourites.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.list.favourites.categories.CategoriesActivity
import org.koitharu.kotatsu.ui.list.favourites.categories.FavouriteCategoriesPresenter
import org.koitharu.kotatsu.ui.list.favourites.categories.FavouriteCategoriesView
import java.util.*
import kotlin.collections.ArrayList
class FavouritesContainerFragment : BaseFragment(R.layout.fragment_favourites), FavouriteCategoriesView,
OnFavouritesChangeListener {
private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this)
pager.adapter = adapter
TabLayoutMediator(tabs, pager, adapter).attach()
FavouritesRepository.subscribe(this)
}
override fun onDestroyView() {
FavouritesRepository.unsubscribe(this)
super.onDestroyView()
}
override fun onCategoriesChanged(categories: List<FavouriteCategory>) {
val data = ArrayList<FavouriteCategory>(categories.size + 1)
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date())
data += categories
(pager.adapter as? FavouritesPagerAdapter)?.replaceData(data)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_favourites, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_categories -> {
context?.let {
startActivity(CategoriesActivity.newIntent(it))
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun getTitle(): CharSequence? {
return getString(R.string.favourites)
}
override fun onCheckedCategoriesChanged(checkedIds: Set<Int>) = Unit
override fun onError(e: Throwable) {
Snackbar.make(pager, e.message ?: return, Snackbar.LENGTH_LONG).show()
}
override fun onFavouritesChanged(mangaId: Long) = Unit
override fun onCategoriesChanged() {
presenter.loadAllCategories()
}
companion object {
fun newInstance() = FavouritesContainerFragment()
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.ui.list.favourites
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.list.MangaListFragment
import org.koitharu.kotatsu.ui.list.MangaListView
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment<Unit>(),
MangaListView<Unit> {
private val presenter by moxyPresenter(factory = ::FavouritesListPresenter)
private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(categoryId, offset)
}
override fun setUpEmptyListHolder() {
textView_holder.setText(if (categoryId == 0L) {
R.string.you_have_not_favourites_yet
} else {
R.string.favourites_category_empty
})
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
companion object {
private const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {
putLong(ARG_CATEGORY_ID, categoryId)
}
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list.favourites
package org.koitharu.kotatsu.ui.list.favourites
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -9,7 +9,7 @@ import moxy.presenterScope
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
import org.koitharu.kotatsu.ui.list.MangaListView
@InjectViewState
class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() {
@@ -21,12 +21,16 @@ class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() {
super.onFirstViewAttach()
}
fun loadList(offset: Int) {
fun loadList(categoryId: Long, offset: Int) {
presenterScope.launch {
viewState.onLoadingStateChanged(true)
try {
val list = withContext(Dispatchers.IO) {
repository.getAllManga(offset = offset)
if (categoryId == 0L) {
repository.getAllManga(offset = offset)
} else {
repository.getManga(categoryId = categoryId, offset = offset)
}
}
if (offset == 0) {
viewState.onListChanged(list)

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.ui.list.favourites
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.AdapterUpdater
import org.koitharu.kotatsu.utils.ext.replaceWith
class FavouritesPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment), TabLayoutMediator.TabConfigurationStrategy {
private val dataSet = ArrayList<FavouriteCategory>()
override fun getItemCount() = dataSet.size
override fun createFragment(position: Int): Fragment {
val item = dataSet[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = dataSet[position]
tab.text = item.title
}
fun replaceData(data: List<FavouriteCategory>) {
val updater = AdapterUpdater(dataSet, data, FavouriteCategory::id)
dataSet.replaceWith(data)
updater(this)
}
}

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