Compare commits

..

32 Commits
v0.3 ... v0.4

Author SHA1 Message Date
6d193baa69 Increment version 2020-05-21 20:48:01 +03:00
3bd7b54405 Capitalize MangaLib genres 2020-05-21 20:47:10 +03:00
d99450c5a3 Prepopulate favourite categories 2020-05-21 20:43:24 +03:00
fe14ccb5ec Update readme 2020-05-20 20:05:51 +03:00
e38e5fdf0f Small enhancements 2020-05-20 19:51:28 +03:00
c1c2b11bd8 Show local manga size 2020-05-20 19:31:17 +03:00
7d147b3c37 Check wakelock is held in download service 2020-05-20 19:04:31 +03:00
260ff32cd1 Detect chapters in cbz if index missing 2020-05-20 18:59:30 +03:00
ccc5f3e423 Fix webtoon scroll 2020-05-17 17:25:18 +03:00
8b32a60743 Misc fixes 2020-05-16 10:20:55 +03:00
c1faf2fe06 Remember last opened section 2020-05-16 09:07:09 +03:00
3588270742 Show favourites by categories and manager categories order 2020-05-16 08:49:34 +03:00
01607ec1e2 Fix MangaLib provider 2020-05-13 20:17:37 +03:00
50f8cb9193 Action mode chapters selection 2020-05-11 17:30:59 +03:00
0100974508 Optimize webtoon scroll 2020-05-11 14:04:51 +03:00
b438898456 Add mangalib source 2020-05-10 18:52:00 +03:00
c3c43dce3d Update dependencies and fix deprecations 2020-05-10 13:38:14 +03:00
e33dfd63e4 Downgrade kotlin 2020-05-10 12:36:14 +03:00
1927500f5a Configure shelf appwidget 2020-05-09 14:15:13 +03:00
f9ccd0851d Small refactor 2020-05-09 12:33:09 +03:00
23412e5c17 Optimize webtoon scroll 2020-05-09 10:16:46 +03:00
1b7c8355ec Refactor: fix deprecations 2020-05-03 18:04:26 +03:00
8378b3dd90 Disable save action for local chapters 2020-05-03 17:31:33 +03:00
9ff5bb6352 Update readme 2020-04-26 20:43:26 +03:00
b2bb1d22df Fix default storage 2020-04-26 20:28:15 +03:00
34acf5bb55 Fix json null string 2020-04-26 20:25:46 +03:00
5af32898f8 Add MangaDex source 2020-04-26 20:22:49 +03:00
ef7108f6c9 Fix typo 2020-04-26 16:48:57 +03:00
941d992793 Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-26 16:38:32 +03:00
de9a07a680 Select storage where save manga 2020-04-26 12:22:36 +03:00
0dc74f9188 Update readme 2020-04-25 18:06:11 +03:00
f95cf9b231 Reset new chapters on reading 2020-04-25 17:22:20 +03:00
149 changed files with 2203 additions and 598 deletions

View File

@@ -1,6 +1,7 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <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" /> <inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
</profile> </profile>
</component> </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>

9
.idea/vcs.xml generated
View File

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

View File

@@ -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) 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.3-legacy)
### Main Features ### Main Features
* Online manga catalogues * Online manga catalogues
@@ -18,13 +20,17 @@ Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
* Tablet-optimized modern UI * Tablet-optimized modern UI
* Reading third-party comics from CBZ * Reading third-party comics from CBZ
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Checking for new chapters * Notifications about new chapters
### Coming Features
* Global search
### Screenshots ### Screenshots
| ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/75573590-d467f180-5a65-11ea-8338-a34af4679ed6.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/75573612-dcc02c80-5a65-11ea-9afb-293dadfb3cfd.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/75573621-e0ec4a00-5a65-11ea-92b9-72ab90281a2b.png) | | ![Screenshot_20200226-210337](https://user-images.githubusercontent.com/8948226/80315102-3478db00-87fe-11ea-9ce8-4bbd1c254b2b.png) | ![Screenshot_20200226-210310](https://user-images.githubusercontent.com/8948226/80315110-3f337000-87fe-11ea-95df-944c196b6667.png) | ![Screenshot_20200226-210232](https://user-images.githubusercontent.com/8948226/80315121-49ee0500-87fe-11ea-8d9b-537a041bbf2f.png) |
|---|---|---| |---|---|---|
| ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/75573629-e34ea400-5a65-11ea-86a1-4496032ac0f0.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/75573632-e5186780-5a65-11ea-81b0-7c296157709c.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/75573639-e6e22b00-5a65-11ea-84a6-6257f532fd2c.png) | | ![Screenshot_20200226-210405](https://user-images.githubusercontent.com/8948226/80315130-55d9c700-87fe-11ea-8350-2c8452906eb7.png) | ![Screenshot_20200226-210151](https://user-images.githubusercontent.com/8948226/80315135-612cf280-87fe-11ea-984c-aa18567d5bbc.png) | ![Screenshot_20200226-210223](https://user-images.githubusercontent.com/8948226/80315146-6be78780-87fe-11ea-8439-ca1ca578172b.png) |
### License ### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -1,7 +1,9 @@
apply plugin: 'com.android.application' plugins {
apply plugin: 'kotlin-android' id 'com.android.application'
apply plugin: 'kotlin-android-extensions' id 'kotlin-android'
apply plugin: 'kotlin-kapt' id 'kotlin-android-extensions'
id 'kotlin-kapt'
}
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger() def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim() def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
@@ -15,17 +17,16 @@ android {
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 29
versionCode gitCommits versionCode gitCommits
versionName '0.3' versionName '0.4'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\"" buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
kapt { kapt {
arguments { arguments {
arg('room.schemaLocation', "$projectDir/schemas".toString()) arg 'room.schemaLocation', "$projectDir/schemas".toString()
} }
} }
} }
archivesBaseName = "kotatsu_${gitCommits}"
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
@@ -38,6 +39,7 @@ android {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
} }
release { release {
multiDexEnabled false
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -58,15 +60,17 @@ androidExtensions {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6'
implementation 'androidx.core:core-ktx:1.3.0-rc01' implementation 'androidx.core:core-ktx:1.3.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.2.4' implementation 'androidx.appcompat:appcompat:1.2.0-rc01'
implementation 'androidx.appcompat:appcompat:1.2.0-beta01' implementation 'androidx.activity:activity-ktx:1.2.0-alpha04'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4' implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha04'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha02'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta6'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02' implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha03'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.3.4' implementation 'androidx.work:work-runtime-ktx:2.3.4'
@@ -82,18 +86,18 @@ dependencies {
implementation 'com.github.moxy-community:moxy-ktx:2.1.2' implementation 'com.github.moxy-community:moxy-ktx:2.1.2'
kapt 'com.github.moxy-community:moxy-compiler:2.1.2' kapt 'com.github.moxy-community:moxy-compiler:2.1.2'
implementation 'com.squareup.okhttp3:okhttp:4.5.0' implementation 'com.squareup.okhttp3:okhttp:4.7.2'
implementation 'com.squareup.okio:okio:2.5.0' implementation 'com.squareup.okio:okio:2.6.0'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.5' implementation 'org.koin:koin-android:2.1.5'
implementation 'io.coil-kt:coil:0.9.5' implementation 'io.coil-kt:coil:0.11.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0' implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.1.2' debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.2.0'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.1.2' releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20190722' testImplementation 'org.json:json:20190722'

View File

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

View File

@@ -60,8 +60,15 @@
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name=".ui.main.list.favourites.categories.CategoriesActivity" android:name=".ui.main.list.favourites.categories.CategoriesActivity"
android:windowSoftInputMode="stateAlwaysHidden" android:label="@string/favourites_categories"
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>
<service <service
android:name=".ui.download.DownloadService" android:name=".ui.download.DownloadService"
@@ -87,21 +94,25 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" /> android:resource="@xml/filepaths" />
</provider> </provider>
<receiver android:name=".ui.widget.shelf.ShelfWidgetProvider" <receiver
android:name=".ui.widget.shelf.ShelfWidgetProvider"
android:label="@string/manga_shelf"> android:label="@string/manga_shelf">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" <meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_shelf" /> android:resource="@xml/widget_shelf" />
</receiver> </receiver>
<receiver android:name=".ui.widget.recent.RecentWidgetProvider" <receiver
android:name=".ui.widget.recent.RecentWidgetProvider"
android:label="@string/recent_manga"> android:label="@string/recent_manga">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" <meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" /> android:resource="@xml/widget_recent" />
</receiver> </receiver>

View File

@@ -4,7 +4,8 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room import androidx.room.Room
import coil.Coil import coil.Coil
import coil.ImageLoader import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.util.CoilUtils import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -13,10 +14,12 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.DatabasePrePopulateCallback
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
import org.koitharu.kotatsu.core.local.CbzFetcher import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
@@ -85,16 +88,19 @@ class KotatsuApp : Application() {
} }
private fun initCoil() { private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) { Coil.setImageLoader(
okHttpClient { ImageLoaderBuilder(applicationContext)
okHttp() .okHttpClient(
.cache(CoilUtils.createDefaultCache(applicationContext)) okHttp()
.build() .cache(CoilUtils.createDefaultCache(applicationContext))
} .build()
componentRegistry { ).componentRegistry(
add(CbzFetcher()) ComponentRegistry.Builder()
} .add(CbzFetcher())
}) .build()
)
.build()
)
} }
private fun initErrorHandler() { private fun initErrorHandler() {
@@ -120,5 +126,6 @@ class KotatsuApp : Application() {
applicationContext, applicationContext,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
).addMigrations(Migration1To2, Migration2To3, Migration3To4) ).addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5)
.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 @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@Query("SELECT category_id,title,created_at FROM favourite_categories ORDER BY :orderBy") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(orderBy: String): List<FavouriteCategoryEntity> abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -20,4 +20,14 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String) 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 { abstract class FavouritesDao {
@Transaction @Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.*
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class
], version = 4 ], version = 5
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository : RemoteMangaRepository() { abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected abstract val defaultDomain: String protected abstract val defaultDomain: String
@@ -20,7 +22,7 @@ abstract class GroupleRepository : RemoteMangaRepository() {
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, sortOrder: SortOrder?,
tag: MangaTag?, tag: MangaTag?
): List<Manga> { ): List<Manga> {
val domain = conf.getDomain(defaultDomain) val domain = conf.getDomain(defaultDomain)
val doc = when { val doc = when {
@@ -28,8 +30,11 @@ abstract class GroupleRepository : RemoteMangaRepository() {
"https://$domain/search", "https://$domain/search",
mapOf("q" to query, "offset" to offset.toString()) mapOf("q" to query, "offset" to offset.toString())
) )
tag == null -> loaderContext.httpGet("https://$domain/list?sortType=${getSortKey( tag == null -> loaderContext.httpGet(
sortOrder)}&offset=$offset") "https://$domain/list?sortType=${getSortKey(
sortOrder
)}&offset=$offset"
)
else -> loaderContext.httpGet( else -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${getSortKey( "https://$domain/list/genre/${tag.key}?sortType=${getSortKey(
sortOrder sortOrder

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,12 @@ import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.* import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) : class AppSettings private constructor(resources: Resources, private val prefs: SharedPreferences) :
SharedPreferences by prefs { SharedPreferences by prefs {
@@ -23,6 +26,12 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
ListMode.DETAILED_LIST ListMode.DETAILED_LIST
) )
var defaultSection by EnumPreferenceDelegate(
AppSection::class.java,
resources.getString(R.string.key_app_section),
AppSection.HISTORY
)
val theme by StringIntPreferenceDelegate( val theme by StringIntPreferenceDelegate(
resources.getString(R.string.key_theme), resources.getString(R.string.key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@@ -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)) var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun getStorageDir(context: Context): File? {
val value = prefs.getString(context.getString(R.string.key_local_storage), null)?.let {
File(it)
}?.takeIf { it.exists() && it.canWrite() }
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
}
fun setStorageDir(context: Context, file: File?) {
val key = context.getString(R.string.key_local_storage)
prefs.edit {
if (file == null) {
remove(key)
} else {
putString(key, file.path)
}
}
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.domain package org.koitharu.kotatsu.domain
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size import android.util.Size
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -12,6 +13,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.utils.ext.medianOrNull
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent { object MangaUtils : KoinComponent {
@@ -23,13 +25,22 @@ object MangaUtils : KoinComponent {
try { try {
val page = pages.medianOrNull() ?: return null val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page) val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val client = get<OkHttpClient>() val uri = Uri.parse(url)
val request = Request.Builder() val size = if (uri.scheme == "cbz") {
.url(url) val zip = ZipFile(uri.schemeSpecificPart)
.get() val entry = zip.getEntry(uri.fragment)
.build() zip.getInputStream(entry).use {
val size = client.newCall(request).await().use { getBitmapSize(it)
getBitmapSize(it.body?.byteStream()) }
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
} }
return when { return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON size.width * 2 < size.height -> ReaderMode.WEBTOON

View File

@@ -18,13 +18,28 @@ class FavouritesRepository : KoinComponent {
private val db: MangaDatabase by inject() private val db: MangaDatabase by inject()
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllManga(offset: Int): List<Manga> { suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(offset, 20, "created_at") val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun getAllCategories(): List<FavouriteCategory> { suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll("created_at") val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() } return entities.map { it.toFavouriteCategory() }
} }
@@ -37,17 +52,32 @@ class FavouritesRepository : KoinComponent {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0 categoryId = 0
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
notifyCategoriesChanged()
return entity.toFavouriteCategory(id) return entity.toFavouriteCategory(id)
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title) db.favouriteCategoriesDao.update(id, title)
notifyCategoriesChanged()
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
notifyCategoriesChanged()
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.update(id, i)
}
}
notifyCategoriesChanged()
} }
suspend fun addToCategory(manga: Manga, categoryId: Long) { suspend fun addToCategory(manga: Manga, categoryId: Long) {
@@ -75,7 +105,7 @@ class FavouritesRepository : KoinComponent {
} }
fun unsubscribe(listener: OnFavouritesChangeListener) { fun unsubscribe(listener: OnFavouritesChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyFavouritesChanged(mangaId: Long) { private suspend fun notifyFavouritesChanged(mangaId: Long) {
@@ -83,5 +113,11 @@ class FavouritesRepository : KoinComponent {
listeners.forEach { x -> x.onFavouritesChanged(mangaId) } listeners.forEach { x -> x.onFavouritesChanged(mangaId) }
} }
} }
private suspend fun notifyCategoriesChanged() {
withContext(Dispatchers.Main) {
listeners.forEach { x -> x.onCategoriesChanged() }
}
}
} }
} }

View File

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

View File

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

View File

@@ -17,44 +17,35 @@ import java.util.*
class HistoryRepository : KoinComponent { class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject() private val db: MangaDatabase by inject()
private val trackingRepository by lazy(::TrackingRepository)
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit) val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) } return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags) db.mangaDao.upsert(MangaEntity.from(manga), tags)
if (db.historyDao.upsert( db.historyDao.upsert(
HistoryEntity( HistoryEntity(
mangaId = manga.id, mangaId = manga.id,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = chapterId, chapterId = chapterId,
page = page, page = page,
scroll = scroll scroll = scroll.toFloat() // we migrate to int, but decide to not update database
)
) )
) { )
TrackingRepository().insertOrNothing(manga) trackingRepository.upsert(manga)
}
} }
notifyHistoryChanged() notifyHistoryChanged()
} }
suspend fun getOne(manga: Manga): MangaHistory? { suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao.find(manga.id)?.let { return db.historyDao.find(manga.id)?.toMangaHistory()
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll
)
}
} }
suspend fun clear() { suspend fun clear() {
@@ -87,7 +78,7 @@ class HistoryRepository : KoinComponent {
} }
fun unsubscribe(listener: OnHistoryChangeListener) { fun unsubscribe(listener: OnHistoryChangeListener) {
listeners += listener listeners -= listener
} }
private suspend fun notifyHistoryChanged() { private suspend fun notifyHistoryChanged() {

View File

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

View File

@@ -52,7 +52,7 @@ class TrackingRepository : KoinComponent {
db.tracksDao.upsert(entity) db.tracksDao.upsert(entity)
} }
suspend fun insertOrNothing(manga: Manga) { suspend fun upsert(manga: Manga) {
val chapters = manga.chapters ?: return val chapters = manga.chapters ?: return
val entity = TrackEntity( val entity = TrackEntity(
mangaId = manga.id, mangaId = manga.id,
@@ -62,6 +62,6 @@ class TrackingRepository : KoinComponent {
lastCheck = System.currentTimeMillis(), lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = 0L lastNotifiedChapterId = 0L
) )
db.tracksDao.insert(entity) db.tracksDao.upsert(entity)
} }
} }

View File

@@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import moxy.MvpAppCompatDialogFragment import moxy.MvpAppCompatDialogFragment
abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() { abstract class AlertDialogFragment(@LayoutRes private val layoutResId: Int) : MvpAppCompatDialogFragment() {

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.ui.common package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -14,8 +11,6 @@ import org.koitharu.kotatsu.R
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent { abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) { override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID) super.setContentView(layoutResID)
setupToolbar() setupToolbar()
@@ -35,48 +30,12 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
true true
} else super.onOptionsItemSelected(item) } else super.onOptionsItemSelected(item)
fun requestPermission(permission: String, callback: (Boolean) -> Unit) {
if (ContextCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
) {
callback(true)
} else {
permissionCallback = callback
ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_PERMISSION)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSION) {
grantResults.singleOrNull()?.let {
permissionCallback?.invoke(it == PackageManager.PERMISSION_GRANTED)
}
permissionCallback = null
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing //TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
recreate() recreate()
return true return true
} }
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
throw StackOverflowError("test")
return true
}
return super.onKeyDown(keyCode, event) 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 com.google.android.material.chip.Chip
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(private val context: Context) { class ChipsFactory(val context: Context) {
fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, fun create(convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0,
tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip { tag: Any? = null, onClickListener: View.OnClickListener? = null): Chip {

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,11 @@ package org.koitharu.kotatsu.ui.details
import android.app.ActivityOptions import android.app.ActivityOptions
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -10,18 +14,15 @@ import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_chapters.* import kotlinx.android.synthetic.main.fragment_chapters.*
import moxy.ktx.moxyPresenter import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.download.DownloadService import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.resolveDp
class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView, class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsView,
OnRecyclerItemClickListener<MangaChapter> { OnRecyclerItemClickListener<MangaChapter>, ActionMode.Callback {
@Suppress("unused") @Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance)
@@ -29,6 +30,7 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
private var manga: Manga? = null private var manga: Manga? = null
private lateinit var adapter: ChaptersAdapter private lateinit var adapter: ChaptersAdapter
private var actionMode: ActionMode? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -69,6 +71,15 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit override fun onFavouriteChanged(categories: List<FavouriteCategory>) = Unit
override fun onItemClick(item: MangaChapter, position: Int, view: View) { override fun onItemClick(item: MangaChapter, position: Int, view: View) {
if (adapter.checkedItemsCount != 0) {
adapter.toggleItemChecked(item.id)
if (adapter.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
return
}
val options = ActivityOptions.makeScaleUpAnimation( val options = ActivityOptions.makeScaleUpAnimation(
view, view,
0, 0,
@@ -86,20 +97,13 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
} }
override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean { override fun onItemLongClick(item: MangaChapter, position: Int, view: View): Boolean {
view.showPopupMenu(R.menu.popup_chapter) { if (actionMode == null) {
val ctx = context ?: return@showPopupMenu false actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
val m = manga ?: return@showPopupMenu false
when (it.itemId) {
R.id.action_save_this -> DownloadService.start(ctx, m, setOf(item.id))
R.id.action_save_this_next -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number >= item.number }.map { x -> x.id })
R.id.action_save_this_prev -> DownloadService.start(ctx, m, m.chapters.orEmpty()
.filter { x -> x.number <= item.number }.map { x -> x.id })
else -> return@showPopupMenu false
}
true
} }
return true return actionMode?.also {
adapter.setItemIsChecked(item.id, true)
it.invalidate()
} != null
} }
private fun scrollToCurrent() { private fun scrollToCurrent() {
@@ -107,7 +111,50 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
?: RecyclerView.NO_POSITION ?: RecyclerView.NO_POSITION
if (pos != RecyclerView.NO_POSITION) { if (pos != RecyclerView.NO_POSITION) {
(recyclerView_chapters.layoutManager as? LinearLayoutManager) (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

@@ -8,10 +8,13 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.activity_details.* import kotlinx.android.synthetic.main.activity_details.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moxy.MvpDelegate import moxy.MvpDelegate
@@ -29,7 +32,8 @@ import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class MangaDetailsActivity : BaseActivity(), MangaDetailsView { class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
TabLayoutMediator.TabConfigurationStrategy {
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance) private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance)
@@ -39,8 +43,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details) setContentView(R.layout.activity_details)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager) pager.adapter = MangaDetailsAdapter(this)
tabs.setupWithViewPager(pager) TabLayoutMediator(tabs, pager, this).attach()
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) { if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let { intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
presenter.loadDetails(it, true) presenter.loadDetails(it, true)
@@ -100,6 +104,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
manga?.source != null && manga?.source != MangaSource.LOCAL manga?.source != null && manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible = menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this) ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu) return super.onPrepareOptionsMenu(menu)
@@ -135,7 +141,16 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
if (chaptersCount > 5) { if (chaptersCount > 5) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage(getString(R.string.large_manga_save_confirm, chaptersCount)) .setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it) DownloadService.start(this, it)
@@ -169,6 +184,24 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = when (position) {
0 -> getString(R.string.details)
1 -> getString(R.string.chapters)
else -> null
}
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
pager.isUserInputEnabled = false
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
pager.isUserInputEnabled = true
}
companion object { companion object {
private const val EXTRA_MANGA = "manga" private const val EXTRA_MANGA = "manga"

View File

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

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.details
import android.text.Spanned import android.text.Spanned
import android.view.View import android.view.View
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.api.load import coil.api.load
@@ -16,9 +17,11 @@ import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.ui.reader.ReaderActivity import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.search.MangaSearchSheet import org.koitharu.kotatsu.ui.search.MangaSearchSheet
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.addChips import org.koitharu.kotatsu.utils.ext.addChips
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import kotlin.math.roundToInt import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView, class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
@@ -67,6 +70,16 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
onClickListener = this@MangaDetailsFragment onClickListener = this@MangaDetailsFragment
) )
} }
manga.url.toUri().toFileOrNull()?.let { f ->
chips_tags.addChips(listOf(f)) {
create(
text = FileSizeUtils.formatBytes(context, it.length()),
iconRes = R.drawable.ic_chip_storage,
tag = it,
onClickListener = this@MangaDetailsFragment
)
}
}
imageView_favourite.setOnClickListener(this) imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this) button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this) button_read.setOnLongClickListener(this)
@@ -123,8 +136,8 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
} }
override fun onLongClick(v: View): Boolean { override fun onLongClick(v: View): Boolean {
when { when (v.id) {
v.id == R.id.button_read -> { R.id.button_read -> {
if (history == null) { if (history == null) {
return false return false
} }

View File

@@ -157,6 +157,8 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
} }
} }
override fun onCategoriesChanged() = Unit
override fun onDestroy() { override fun onDestroy() {
HistoryRepository.unsubscribe(this) HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this) FavouritesRepository.unsubscribe(this)

View File

@@ -8,7 +8,7 @@ import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil.Coil import coil.Coil
import coil.api.get import coil.request.GetRequestBuilder
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
class DownloadService : BaseService() { class DownloadService : BaseService() {
@@ -39,6 +40,7 @@ class DownloadService : BaseService() {
private val okHttp by inject<OkHttpClient>() private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>() private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val jobs = HashMap<Int, Job>() private val jobs = HashMap<Int, Job>()
private val mutex = Mutex() private val mutex = Mutex()
@@ -74,18 +76,21 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job { private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) { return launch(Dispatchers.IO) {
mutex.lock() mutex.lock()
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20)) wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
notification.fillFrom(manga) notification.fillFrom(manga)
notification.setCancelId(startId) notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification()) startForeground(DownloadNotification.NOTIFICATION_ID, notification())
} }
val destination = getExternalFilesDir("manga")!! val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null var output: MangaZip? = null
try { try {
val repo = MangaProviderFactory.create(manga.source) val repo = MangaProviderFactory.create(manga.source)
val cover = safe { val cover = safe {
Coil.loader().get(manga.coverUrl) Coil.execute(GetRequestBuilder(this@DownloadService)
.data(manga.coverUrl)
.build()).drawable
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
notification.setLargeIcon(cover) notification.setLargeIcon(cover)
@@ -162,7 +167,9 @@ class DownloadService : BaseService() {
notification.dismiss() notification.dismiss()
stopSelf(startId) stopSelf(startId)
} }
wakeLock.release() if (wakeLock.isHeld) {
wakeLock.release()
}
mutex.unlock() mutex.unlock()
} }
} }

View File

@@ -21,10 +21,11 @@ import moxy.ktx.moxyPresenter
import org.koin.core.inject import org.koin.core.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BaseActivity import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesContainerFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment 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.local.LocalListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
@@ -65,8 +66,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentById(R.id.container)?.let {
fab.isVisible = it is HistoryListFragment fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
navigationView.setCheckedItem(R.id.nav_history) openDefaultSection()
setPrimaryFragment(HistoryListFragment.newInstance())
} }
drawer.postDelayed(2000) { drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext) AppUpdateService.startIfRequired(applicationContext)
@@ -106,9 +106,18 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { } else when (item.itemId) {
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance()) R.id.nav_history -> {
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance()) settings.defaultSection = AppSection.HISTORY
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance()) setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
settings.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
settings.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_action_settings -> { R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this)) startActivity(SettingsActivity.newIntent(this))
return true return true
@@ -167,6 +176,23 @@ 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())
}
}
}
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment)

View File

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

View File

@@ -64,6 +64,9 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context)) recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context))
recyclerView_filter.addItemDecoration(SectionItemDecoration(false, this)) recyclerView_filter.addItemDecoration(SectionItemDecoration(false, this))
settings.subscribe(this) settings.subscribe(this)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -72,13 +75,6 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
super.onDestroyView() 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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_list, menu) inflater.inflate(R.menu.opt_list, menu)
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)

View File

@@ -57,6 +57,9 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
textView_title.isVisible = false textView_title.isVisible = false
appbar.elevation = resources.getDimension(R.dimen.elevation_large) appbar.elevation = resources.getDimension(R.dimen.elevation_large)
} }
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -65,13 +68,6 @@ abstract class MangaListSheet<E> : BaseBottomSheet(R.layout.sheet_list), MangaLi
super.onDestroyView() 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) { protected fun setTitle(title: CharSequence) {
toolbar.title = title toolbar.title = title
textView_title.text = title textView_title.text = title

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.ui.main.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.main.list.favourites.categories.CategoriesActivity
import org.koitharu.kotatsu.ui.main.list.favourites.categories.FavouriteCategoriesPresenter
import org.koitharu.kotatsu.ui.main.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

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

View File

@@ -21,12 +21,16 @@ class FavouritesListPresenter : BasePresenter<MangaListView<Unit>>() {
super.onFirstViewAttach() super.onFirstViewAttach()
} }
fun loadList(offset: Int) { fun loadList(categoryId: Long, offset: Int) {
presenterScope.launch { presenterScope.launch {
viewState.onLoadingStateChanged(true) viewState.onLoadingStateChanged(true)
try { try {
val list = withContext(Dispatchers.IO) { 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) { if (offset == 0) {
viewState.onListChanged(list) viewState.onListChanged(list)

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.ui.main.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)
}
}

View File

@@ -10,6 +10,7 @@ import android.view.View
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_categories.* import kotlinx.android.synthetic.main.activity_categories.*
@@ -28,6 +29,7 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter) private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter)
private lateinit var adapter: CategoriesAdapter private lateinit var adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -38,6 +40,8 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
recyclerView.adapter = adapter recyclerView.adapter = adapter
fab_add.setOnClickListener(this) fab_add.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
reorderHelper.attachToRecyclerView(recyclerView)
} }
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -56,6 +60,11 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
} }
} }
override fun onItemLongClick(item: FavouriteCategory, position: Int, view: View): Boolean {
reorderHelper.startDrag(recyclerView.findViewHolderForAdapterPosition(position) ?: return false)
return true
}
override fun onCategoriesChanged(categories: List<FavouriteCategory>) { override fun onCategoriesChanged(categories: List<FavouriteCategory>) {
adapter.replaceData(categories) adapter.replaceData(categories)
textView_holder.isVisible = categories.isEmpty() textView_holder.isVisible = categories.isEmpty()
@@ -106,6 +115,25 @@ class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<Favourite
.show() .show()
} }
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos)
presenter.storeCategoriesOrder(adapter.items.map { it.id })
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
}
companion object { companion object {
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java) fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)

View File

@@ -1,17 +1,40 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories package org.koitharu.kotatsu.ui.main.list.favourites.categories
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_category.*
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.CategoryCheckableHolder
class CategoriesAdapter(onItemClickListener: OnRecyclerItemClickListener<FavouriteCategory>? = null) : class CategoriesAdapter(private val onItemClickListener: OnRecyclerItemClickListener<FavouriteCategory>) :
BaseRecyclerAdapter<FavouriteCategory, Unit>(onItemClickListener) { BaseRecyclerAdapter<FavouriteCategory, Unit>() {
override fun onCreateViewHolder(parent: ViewGroup) = CategoryHolder(parent) override fun onCreateViewHolder(parent: ViewGroup) = CategoryHolder(parent)
override fun onGetItemId(item: FavouriteCategory) = item.id override fun onGetItemId(item: FavouriteCategory) = item.id
override fun getExtra(item: FavouriteCategory, position: Int) = Unit override fun getExtra(item: FavouriteCategory, position: Int) = Unit
@SuppressLint("ClickableViewAccessibility")
override fun onViewHolderCreated(holder: BaseViewHolder<FavouriteCategory, Unit>) {
holder.imageView_more.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), holder.bindingAdapterPosition, v)
}
holder.imageView_handle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onItemClickListener.onItemLongClick(holder.requireData(), holder.bindingAdapterPosition, v)
} else {
false
}
}
}
fun moveItem(oldPos: Int, newPos: Int) {
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
}
} }

View File

@@ -10,6 +10,6 @@ class CategoryHolder(parent: ViewGroup) :
BaseViewHolder<FavouriteCategory, Unit>(parent, R.layout.item_category) { BaseViewHolder<FavouriteCategory, Unit>(parent, R.layout.item_category) {
override fun onBind(data: FavouriteCategory, extra: Unit) { override fun onBind(data: FavouriteCategory, extra: Unit) {
textView.text = data.title textView_title.text = data.title
} }
} }

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.ui.main.list.favourites.categories
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import moxy.InjectViewState import moxy.InjectViewState
import moxy.presenterScope import moxy.presenterScope
@@ -14,6 +16,9 @@ import org.koitharu.kotatsu.ui.common.BasePresenter
class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() { class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
private lateinit var repository: FavouritesRepository private lateinit var repository: FavouritesRepository
private val reorderMutex by lazy {
Mutex()
}
override fun onFirstViewAttach() { override fun onFirstViewAttach() {
repository = FavouritesRepository() repository = FavouritesRepository()
@@ -28,7 +33,7 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
repository.getAllCategories() repository.getAllCategories()
} }
viewState.onCategoriesChanged(categories) viewState.onCategoriesChanged(categories)
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -44,7 +49,7 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
repository.getCategories(manga.id) repository.getCategories(manga.id)
} }
viewState.onCheckedCategoriesChanged(categories.map { it.id.toInt() }.toSet()) viewState.onCheckedCategoriesChanged(categories.map { it.id.toInt() }.toSet())
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -61,7 +66,7 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
repository.getAllCategories() repository.getAllCategories()
} }
viewState.onCategoriesChanged(categories) viewState.onCategoriesChanged(categories)
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -78,7 +83,7 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
repository.getAllCategories() repository.getAllCategories()
} }
viewState.onCategoriesChanged(categories) viewState.onCategoriesChanged(categories)
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -95,7 +100,22 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
repository.getAllCategories() repository.getAllCategories()
} }
viewState.onCategoriesChanged(categories) viewState.onCategoriesChanged(categories)
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
}
}
}
fun storeCategoriesOrder(orderedIds: List<Long>) {
presenterScope.launch {
try {
reorderMutex.withLock {
repository.reorderCategories(orderedIds)
}
} catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -107,10 +127,10 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
fun addToCategory(manga: Manga, categoryId: Long) { fun addToCategory(manga: Manga, categoryId: Long) {
presenterScope.launch { presenterScope.launch {
try { try {
val categories = withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
repository.addToCategory(manga,categoryId) repository.addToCategory(manga,categoryId)
} }
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }
@@ -122,10 +142,10 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
fun removeFromCategory(manga: Manga, categoryId: Long) { fun removeFromCategory(manga: Manga, categoryId: Long) {
presenterScope.launch { presenterScope.launch {
try { try {
val categories = withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
repository.removeFromCategory(manga, categoryId) repository.removeFromCategory(manga, categoryId)
} }
} catch (e: Exception) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
} }

View File

@@ -1,11 +1,12 @@
package org.koitharu.kotatsu.ui.main.list.local package org.koitharu.kotatsu.ui.main.list.local
import android.app.Activity
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.net.Uri
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.* import kotlinx.android.synthetic.main.fragment_list.*
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize import org.koitharu.kotatsu.utils.ext.ellipsize
import java.io.File import java.io.File
class LocalListFragment : MangaListFragment<File>() { class LocalListFragment : MangaListFragment<File>(), ActivityResultCallback<Uri> {
private val presenter by moxyPresenter(factory = ::LocalListPresenter) private val presenter by moxyPresenter(factory = ::LocalListPresenter)
@@ -35,11 +36,9 @@ class LocalListFragment : MangaListFragment<File>() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_import -> { R.id.action_import -> {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
try { try {
startActivityForResult(intent, REQUEST_IMPORT) registerForActivityResult(ActivityResultContracts.OpenDocument(), this)
.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@@ -63,13 +62,9 @@ class LocalListFragment : MangaListFragment<File>() {
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(result: Uri?) {
super.onActivityResult(requestCode, resultCode, data) if (result != null) {
when (requestCode) { presenter.importFile(context?.applicationContext ?: return, result)
REQUEST_IMPORT -> if (resultCode == Activity.RESULT_OK) {
val uri = data?.data ?: return
presenter.importFile(context?.applicationContext ?: return, uri)
}
} }
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.history.HistoryRepository import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.common.BasePresenter import org.koitharu.kotatsu.ui.common.BasePresenter
@@ -64,7 +65,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
if (!LocalMangaRepository.isFileSupported(name)) { if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = context.getExternalFilesDir("manga")?.sub(name) val dest = get<AppSettings>().getStorageDir(context)?.sub(name)
?: throw IOException("External files dir unavailable") ?: throw IOException("External files dir unavailable")
context.contentResolver.openInputStream(uri)?.use { source -> context.contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output -> dest.outputStream().use { output ->

View File

@@ -51,13 +51,12 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
.cacheControl(CacheUtils.CONTROL_DISABLED) .cacheControl(CacheUtils.CONTROL_DISABLED)
.build() .build()
okHttp.newCall(request).await().use { response -> okHttp.newCall(request).await().use { response ->
val body = response.body!! val body = response.body
val type = body.contentType() checkNotNull(body) {
check(type?.type == "image") { "Null response"
"Unexpected content type ${type?.type}/${type?.subtype}"
} }
cache.put(url) { out -> cache.put(url) { out ->
response.body!!.byteStream().copyTo(out) body.byteStream().copyTo(out)
} }
} }
} }

View File

@@ -4,12 +4,16 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.postDelayed import androidx.core.view.postDelayed
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -42,7 +46,8 @@ import org.koitharu.kotatsu.utils.ext.*
class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnChapterChangeListener, class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnChapterChangeListener,
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ReaderListener, SharedPreferences.OnSharedPreferenceChangeListener { ReaderListener, SharedPreferences.OnSharedPreferenceChangeListener,
View.OnApplyWindowInsetsListener, ActivityResultCallback<Boolean> {
private val presenter by moxyPresenter(factory = ::ReaderPresenter) private val presenter by moxyPresenter(factory = ::ReaderPresenter)
private val settings by inject<AppSettings>() private val settings by inject<AppSettings>()
@@ -65,7 +70,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
toolbar_bottom.inflateMenu(R.menu.opt_reader_bottom) toolbar_bottom.inflateMenu(R.menu.opt_reader_bottom)
toolbar_bottom.setOnMenuItemClickListener(::onOptionsItemSelected) toolbar_bottom.setOnMenuItemClickListener(::onOptionsItemSelected)
state = savedInstanceState?.getParcelable(EXTRA_STATE) @Suppress("RemoveExplicitTypeArguments")
state = savedInstanceState?.getParcelable<ReaderState>(EXTRA_STATE)
?: intent.getParcelableExtra<ReaderState>(EXTRA_STATE) ?: intent.getParcelableExtra<ReaderState>(EXTRA_STATE)
?: let { ?: let {
Toast.makeText(this, R.string.error_occurred, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_occurred, Toast.LENGTH_SHORT).show()
@@ -79,10 +85,7 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
getString(R.string.chapter_d_of_d, state.chapter?.number ?: 0, size) getString(R.string.chapter_d_of_d, state.chapter?.number ?: 0, size)
} }
appbar_bottom.setOnApplyWindowInsetsListener { view, insets -> rootLayout.setOnApplyWindowInsetsListener(this)
view.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
settings.subscribe(this) settings.subscribe(this)
loadSettings() loadSettings()
@@ -180,13 +183,17 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
} }
R.id.action_save_page -> { R.id.action_save_page -> {
if (reader?.hasItems == true) { if (reader?.hasItems == true) {
requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) { if (ContextCompat.checkSelfPermission(
if (it) { this,
presenter.savePage( Manifest.permission.WRITE_EXTERNAL_STORAGE
resolver = contentResolver, ) == PackageManager.PERMISSION_GRANTED
page = reader?.currentPage ?: return@requestPermission ) {
) onActivityResult(true)
} } else {
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
this
).launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} }
} else { } else {
showWaitWhileLoading() showWaitWhileLoading()
@@ -196,7 +203,16 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun saveState(chapterId: Long, page: Int, scroll: Float) { override fun onActivityResult(result: Boolean) {
if (result) {
presenter.savePage(
resolver = contentResolver,
page = reader?.currentPage ?: return
)
}
}
override fun saveState(chapterId: Long, page: Int, scroll: Int) {
state = state.copy(chapterId = chapterId, page = page, scroll = scroll) state = state.copy(chapterId = chapterId, page = page, scroll = scroll)
ReaderPresenter.saveState(state) ReaderPresenter.saveState(state)
} }
@@ -226,13 +242,11 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
setUiIsVisible(!appbar_top.isVisible) setUiIsVisible(!appbar_top.isVisible)
} }
GridTouchHelper.AREA_TOP, GridTouchHelper.AREA_TOP,
GridTouchHelper.AREA_LEFT, GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
-> if (isTapSwitchEnabled) {
reader?.switchPageBy(-1) reader?.switchPageBy(-1)
} }
GridTouchHelper.AREA_BOTTOM, GridTouchHelper.AREA_BOTTOM,
GridTouchHelper.AREA_RIGHT, GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
-> if (isTapSwitchEnabled) {
reader?.switchPageBy(1) reader?.switchPageBy(1)
} }
} }
@@ -270,15 +284,13 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT -> {
-> {
reader?.switchPageBy(1) reader?.switchPageBy(1)
true true
} }
KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT -> {
-> {
reader?.switchPageBy(-1) reader?.switchPageBy(-1)
true true
} }
@@ -299,7 +311,7 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
state = state.copy( state = state.copy(
chapterId = chapter.id, chapterId = chapter.id,
page = 0, page = 0,
scroll = 0f scroll = 0
) )
reader?.updateState(chapterId = chapter.id) reader?.updateState(chapterId = chapter.id)
} }
@@ -356,6 +368,11 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
} }
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets {
appbar_top.updatePadding(top = insets.systemWindowInsetTop)
appbar_bottom.updatePadding(bottom = insets.systemWindowInsetBottom)
return insets.consumeSystemWindowInsets()
}
private fun loadSettings() { private fun loadSettings() {
settings.readerPageSwitch.let { settings.readerPageSwitch.let {
@@ -378,7 +395,7 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
chapterId = if (chapterId == -1L) manga.chapters?.firstOrNull()?.id chapterId = if (chapterId == -1L) manga.chapters?.firstOrNull()?.id
?: -1 else chapterId, ?: -1 else chapterId,
page = 0, page = 0,
scroll = 0f scroll = 0
) )
) )

View File

@@ -7,5 +7,5 @@ interface ReaderListener : BaseMvpView {
fun onPageChanged(chapter: MangaChapter, page: Int, total: Int) fun onPageChanged(chapter: MangaChapter, page: Int, total: Int)
fun saveState(chapterId: Long, page: Int, scroll: Float) fun saveState(chapterId: Long, page: Int, scroll: Int)
} }

View File

@@ -11,7 +11,7 @@ data class ReaderState(
val manga: Manga, val manga: Manga,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Float val scroll: Int
) : Parcelable { ) : Parcelable {
@IgnoredOnParcel @IgnoredOnParcel

View File

@@ -46,10 +46,6 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapter = onCreateAdapter(pages) adapter = onCreateAdapter(pages)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
@Suppress("RemoveExplicitTypeArguments") @Suppress("RemoveExplicitTypeArguments")
val state = savedInstanceState?.getParcelable<ReaderState>(ARG_STATE) val state = savedInstanceState?.getParcelable<ReaderState>(ARG_STATE)
?: requireArguments().getParcelable<ReaderState>(ARG_STATE)!! ?: requireArguments().getParcelable<ReaderState>(ARG_STATE)!!
@@ -58,7 +54,7 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
pages.addLast(state.chapterId, it) pages.addLast(state.chapterId, it)
adapter?.notifyDataSetChanged() adapter?.notifyDataSetChanged()
setCurrentItem(state.page, false) setCurrentItem(state.page, false)
if (state.scroll != 0f) { if (state.scroll != 0) {
restorePageScroll(state.page, state.scroll) restorePageScroll(state.page, state.scroll)
} }
} }
@@ -200,7 +196,7 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
if (pageId == 0L) { if (pageId == 0L) {
0 0
} else { } else {
it.indexOfFirst { it.id == pageId }.coerceAtLeast(0) it.indexOfFirst { x -> x.id == pageId }.coerceAtLeast(0)
}, false }, false
) )
} }
@@ -221,9 +217,9 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
protected abstract fun getCurrentItem(): Int protected abstract fun getCurrentItem(): Int
protected abstract fun getCurrentPageScroll(): Float protected abstract fun getCurrentPageScroll(): Int
protected abstract fun restorePageScroll(position: Int, scroll: Float) protected abstract fun restorePageScroll(position: Int, scroll: Int)
protected abstract fun setCurrentItem(position: Int, isSmooth: Boolean) protected abstract fun setCurrentItem(position: Int, isSmooth: Boolean)

View File

@@ -9,6 +9,7 @@ import kotlinx.android.synthetic.main.item_page.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.reader.PageLoader import org.koitharu.kotatsu.ui.reader.PageLoader
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -43,7 +44,8 @@ class PageHolder(parent: ViewGroup, private val loader: PageLoader) :
ssiv.recycle() ssiv.recycle()
try { try {
val uri = withContext(Dispatchers.IO) { val uri = withContext(Dispatchers.IO) {
loader.loadFile(data.url, force) val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
loader.loadFile(pageUrl, force)
}.toUri() }.toUri()
ssiv.setImage(ImageSource.uri(uri)) ssiv.setImage(ImageSource.uri(uri))
} catch (e: CancellationException) { } catch (e: CancellationException) {

View File

@@ -62,9 +62,9 @@ class PagerReaderFragment : AbstractReader(R.layout.fragment_reader_standard),
pager.setCurrentItem(position, isSmooth) pager.setCurrentItem(position, isSmooth)
} }
override fun getCurrentPageScroll() = 0f override fun getCurrentPageScroll() = 0
override fun restorePageScroll(position: Int, scroll: Float) = Unit override fun restorePageScroll(position: Int, scroll: Int) = Unit
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) { when (key) {

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.ui.reader.thumbnails
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.Coil import coil.Coil
import coil.api.get import coil.request.GetRequestBuilder
import coil.size.PixelSize import coil.size.PixelSize
import coil.size.Size import coil.size.Size
import kotlinx.android.synthetic.main.item_page_thumb.* import kotlinx.android.synthetic.main.item_page_thumb.*
@@ -38,9 +38,10 @@ class PageThumbnailHolder(parent: ViewGroup, private val scope: CoroutineScope)
val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data) val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
extra[pageUrl]?.toUri()?.toString() ?: pageUrl extra[pageUrl]?.toUri()?.toString() ?: pageUrl
} }
val drawable = Coil.get(url) { val drawable = Coil.execute(GetRequestBuilder(context)
size(thumbSize) .data(url)
} .size(thumbSize)
.build()).drawable
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
imageView_thumb.setImageDrawable(drawable) imageView_thumb.setImageDrawable(drawable)
} }

View File

@@ -1,41 +1,24 @@
package org.koitharu.kotatsu.ui.reader.wetoon package org.koitharu.kotatsu.ui.reader.wetoon
import android.content.Context import android.content.Context
import android.graphics.RectF
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.android.synthetic.main.item_page_webtoon.view.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class WebtoonFrameLayout @JvmOverloads constructor( class WebtoonFrameLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
private val pan = RectF()
private val target by lazy { private val target by lazy {
findViewById<SubsamplingScaleImageView>(R.id.ssiv) findViewById<WebtoonImageView>(R.id.ssiv)
} }
fun dispatchVerticalScroll(dy: Int): Int { fun dispatchVerticalScroll(dy: Int): Int {
target.getPanRemaining(pan) if (dy == 0) {
val c = target.center ?: return 0 return 0
val s = target.scale
return when {
dy > 0 -> {
val delta = minOf(pan.bottom.toInt(), dy)
c.offset(0f, delta.toFloat() / s)
target.setScaleAndCenter(s, c)
delta
}
dy < 0 -> {
val delta = minOf(pan.top.toInt(), -dy)
c.offset(0f, -delta.toFloat() / s)
target.setScaleAndCenter(s, c)
-delta
}
else -> 0
} }
val oldScroll = target.getScroll()
target.scrollBy(dy)
return target.getScroll() - oldScroll
} }
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.ui.reader.wetoon package org.koitharu.kotatsu.ui.reader.wetoon
import android.graphics.PointF
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -10,6 +9,7 @@ import kotlinx.android.synthetic.main.item_page_webtoon.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.reader.PageLoader import org.koitharu.kotatsu.ui.reader.PageLoader
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -20,7 +20,7 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
SubsamplingScaleImageView.OnImageEventListener, CoroutineScope by loader { SubsamplingScaleImageView.OnImageEventListener, CoroutineScope by loader {
private var job: Job? = null private var job: Job? = null
private var scrollToRestore = 0f private var scrollToRestore = 0
init { init {
ssiv.setOnImageEventListener(this) ssiv.setOnImageEventListener(this)
@@ -35,14 +35,15 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
private fun doLoad(data: MangaPage, force: Boolean) { private fun doLoad(data: MangaPage, force: Boolean) {
job?.cancel() job?.cancel()
scrollToRestore = 0f scrollToRestore = 0
job = launch { job = launch {
layout_error.isVisible = false layout_error.isVisible = false
progressBar.isVisible = true progressBar.isVisible = true
ssiv.recycle() ssiv.recycle()
try { try {
val uri = withContext(Dispatchers.IO) { val uri = withContext(Dispatchers.IO) {
loader.loadFile(data.url, force) val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
loader.loadFile(pageUrl, force)
}.toUri() }.toUri()
ssiv.setImage(ImageSource.uri(uri)) ssiv.setImage(ImageSource.uri(uri))
} catch (e: CancellationException) { } catch (e: CancellationException) {
@@ -58,17 +59,11 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
ssiv.recycle() ssiv.recycle()
} }
fun getScrollY() = ssiv.center?.y ?: 0f fun getScrollY() = ssiv.getScroll()
fun restoreScroll(scroll: Float) { fun restoreScroll(scroll: Int) {
if (ssiv.isReady) { if (ssiv.isReady) {
ssiv.setScaleAndCenter( ssiv.scrollTo(scroll)
ssiv.scale,
PointF(
ssiv.sWidth / 2f,
scroll
)
)
} else { } else {
scrollToRestore = scroll scrollToRestore = scroll
} }
@@ -78,17 +73,11 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
ssiv.maxScale = 2f * ssiv.width / ssiv.sWidth.toFloat() ssiv.maxScale = 2f * ssiv.width / ssiv.sWidth.toFloat()
ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
ssiv.minScale = ssiv.width / ssiv.sWidth.toFloat() ssiv.minScale = ssiv.width / ssiv.sWidth.toFloat()
ssiv.setScaleAndCenter( ssiv.scrollTo(when {
ssiv.minScale, scrollToRestore != 0 -> scrollToRestore
PointF( itemView.top < 0 -> ssiv.getScrollRange()
ssiv.sWidth / 2f, else -> 0
when { })
scrollToRestore != 0f -> scrollToRestore
itemView.top < 0 -> ssiv.sHeight.toFloat()
else -> 0f
}
)
)
} }
override fun onImageLoadError(e: Exception) = onError(e) override fun onImageLoadError(e: Exception) = onError(e)

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.ui.reader.wetoon
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.utils.ext.toIntUp
class WebtoonImageView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null) :
SubsamplingScaleImageView(context, attr) {
private val ct = PointF()
private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN
fun scrollBy(delta: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
return
}
val newScroll = scrollPos + delta
scrollToInternal(newScroll.coerceIn(0, maxScroll))
}
fun scrollTo(y: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
return
}
scrollToInternal(y.coerceIn(0, maxScroll))
}
fun getScroll() = scrollPos
fun getScrollRange(): Int {
if (scrollRange == SCROLL_UNKNOWN) {
computeScrollRange()
}
return scrollRange.coerceAtLeast(0)
}
override fun recycle() {
scrollRange = SCROLL_UNKNOWN
scrollPos = 0
super.recycle()
}
private fun scrollToInternal(pos: Int) {
scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
setScaleAndCenter(minScale, ct)
}
private fun computeScrollRange() {
if (!isReady) {
return
}
val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0)
}
private companion object {
const val SCROLL_UNKNOWN = -1
}
}

View File

@@ -61,12 +61,12 @@ class WebtoonReaderFragment : AbstractReader(R.layout.fragment_reader_webtoon) {
) )
} }
override fun getCurrentPageScroll(): Float { override fun getCurrentPageScroll(): Int {
return (recyclerView.findViewHolderForAdapterPosition(getCurrentItem()) as? WebtoonHolder) return (recyclerView.findViewHolderForAdapterPosition(getCurrentItem()) as? WebtoonHolder)
?.getScrollY() ?: 0f ?.getScrollY() ?: 0
} }
override fun restorePageScroll(position: Int, scroll: Float) { override fun restorePageScroll(position: Int, scroll: Int) {
recyclerView.post { recyclerView.post {
val holder = recyclerView.findViewHolderForAdapterPosition(position) ?: return@post val holder = recyclerView.findViewHolderForAdapterPosition(position) ?: return@post
(holder as WebtoonHolder).restoreScroll(scroll) (holder as WebtoonHolder).restoreScroll(scroll)

View File

@@ -2,16 +2,26 @@ package org.koitharu.kotatsu.ui.reader.wetoon
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import androidx.core.view.ViewCompat
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign
class WebtoonRecyclerView @JvmOverloads constructor( class WebtoonRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) { ) : RecyclerView(context, attrs, defStyleAttr) {
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, ViewCompat.TYPE_TOUCH)
override fun startNestedScroll(axes: Int, type: Int): Boolean {
return true
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH)
override fun dispatchNestedPreScroll( override fun dispatchNestedPreScroll(
dx: Int, dx: Int,
dy: Int, dy: Int,
@@ -20,31 +30,47 @@ class WebtoonRecyclerView @JvmOverloads constructor(
type: Int type: Int
): Boolean { ): Boolean {
val consumedY = consumeVerticalScroll(dy) val consumedY = consumeVerticalScroll(dy)
val superRes = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type) if (consumed != null) {
consumed?.set(1, consumed[1] + consumedY) consumed[0] = 0
return superRes || consumedY != 0 consumed[1] = consumedY
} }
return consumedY != 0
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?
): Boolean {
val consumedY = consumeVerticalScroll(dy)
val superRes = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow)
consumed?.set(1, consumed[1] + consumedY)
return superRes || consumedY != 0
} }
private fun consumeVerticalScroll(dy: Int): Int { private fun consumeVerticalScroll(dy: Int): Int {
val child = when { if (childCount == 0) {
dy > 0 -> children.firstOrNull { it is WebtoonFrameLayout } return 0
dy < 0 -> children.lastOrNull { it is WebtoonFrameLayout } }
else -> null when {
} ?: return 0 dy > 0 -> {
var scrollY = dy val child = getChildAt(0) as WebtoonFrameLayout
scrollY -= (child as WebtoonFrameLayout).dispatchVerticalScroll(scrollY) var consumedByChild = child.dispatchVerticalScroll(dy)
return dy - scrollY if (consumedByChild < dy) {
if (childCount > 1) {
val nextChild = getChildAt(1) as WebtoonFrameLayout
val unconsumed = dy - consumedByChild - nextChild.top //will be consumed by scroll
if (unconsumed > 0) {
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
}
}
}
return consumedByChild
}
dy < 0 -> {
val child = getChildAt(childCount - 1) as WebtoonFrameLayout
var consumedByChild = child.dispatchVerticalScroll(dy)
if (consumedByChild > dy) {
if (childCount > 1) {
val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout
val unconsumed = dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll
if (unconsumed < 0) {
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
}
}
}
return consumedByChild
}
}
return 0
} }
} }

View File

@@ -164,7 +164,7 @@ class AppUpdateService : BaseService() {
} }
return try { return try {
val md: MessageDigest = MessageDigest.getInstance("SHA1") val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.getEncoded()) val publicKey: ByteArray = md.digest(c.encoded)
publicKey.byte2HexFormatted() publicKey.byte2HexFormatted()
} catch (e: NoSuchAlgorithmException) { } catch (e: NoSuchAlgorithmException) {
e.printStackTrace() e.printStackTrace()

View File

@@ -16,13 +16,17 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
import org.koitharu.kotatsu.ui.main.list.ListModeSelectDialog import org.koitharu.kotatsu.ui.main.list.ListModeSelectDialog
import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
class MainSettingsFragment : BasePreferenceFragment(R.string.settings), class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main) addPreferencesFromResource(R.xml.pref_main)
@@ -40,15 +44,25 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
findPreference<Preference>(R.string.key_app_update_auto)?.run { findPreference<Preference>(R.string.key_app_update_auto)?.run {
isVisible = AppUpdateService.isUpdateSupported(context) isVisible = AppUpdateService.isUpdateSupported(context)
} }
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) { when (key) {
getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary = getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary =
LIST_MODES[settings.listMode]?.let(::getString) LIST_MODES[settings.listMode]?.let(::getString)
getString(R.string.key_theme) -> { getString(R.string.key_theme) -> {
AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setDefaultNightMode(settings.theme)
} }
getString(R.string.key_local_storage) -> {
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
}
} }
} }
@@ -89,10 +103,23 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
} }
true true
} }
getString(R.string.key_local_storage) -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx),this)
.setTitle(preference.title)
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
override fun onStorageSelected(file: File) {
settings.setStorageDir(context ?: return, file)
}
private companion object { private companion object {
val LIST_MODES = arrayMapOf( val LIST_MODES = arrayMapOf(

View File

@@ -30,6 +30,7 @@ class SettingsActivity : BaseActivity(),
} }
} }
@Suppress("DEPRECATION")
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean { override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fm = supportFragmentManager val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment) val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment)

View File

@@ -47,11 +47,11 @@ class SourcesAdapter(private val onItemClickListener: OnRecyclerItemClickListene
settings.hiddenSources = hiddenItems.map { x -> x.name }.toSet() settings.hiddenSources = hiddenItems.map { x -> x.name }.toSet()
} }
holder.imageView_config.setOnClickListener { v -> holder.imageView_config.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), holder.adapterPosition, v) onItemClickListener.onItemClick(holder.requireData(), holder.bindingAdapterPosition, v)
} }
holder.imageView_handle.setOnTouchListener { v, event -> holder.imageView_handle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onItemClickListener.onItemLongClick(holder.requireData(), holder.adapterPosition, v) onItemClickListener.onItemLongClick(holder.requireData(), holder.bindingAdapterPosition, v)
} else { } else {
false false
} }

View File

@@ -11,8 +11,8 @@ class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(ItemTouchHelper.DO
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder
): Boolean { ): Boolean {
val adapter = recyclerView.adapter as? SourcesAdapter ?: return false val adapter = recyclerView.adapter as? SourcesAdapter ?: return false
val oldPos = viewHolder.adapterPosition val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.adapterPosition val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos) adapter.moveItem(oldPos, newPos)
return true return true
} }

View File

@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.ui.settings.sources
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_settings_sources.* import kotlinx.android.synthetic.main.fragment_settings_sources.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
@@ -27,6 +29,7 @@ class SourcesSettingsFragment : BaseFragment(R.layout.fragment_settings_sources)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
recyclerView.addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
recyclerView.adapter = SourcesAdapter(this) recyclerView.adapter = SourcesAdapter(this)
reorderHelper.attachToRecyclerView(recyclerView) reorderHelper.attachToRecyclerView(recyclerView)
} }
@@ -42,6 +45,6 @@ class SourcesSettingsFragment : BaseFragment(R.layout.fragment_settings_sources)
override fun onItemLongClick(item: MangaSource, position: Int, view: View): Boolean { override fun onItemLongClick(item: MangaSource, position: Int, view: View): Boolean {
reorderHelper.startDrag(recyclerView.findViewHolderForAdapterPosition(position) ?: return false) reorderHelper.startDrag(recyclerView.findViewHolderForAdapterPosition(position) ?: return false)
return super.onItemLongClick(item, position, view) return true
} }
} }

View File

@@ -8,10 +8,9 @@ import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.work.* import androidx.work.*
import coil.Coil import coil.Coil
import coil.api.get import coil.request.GetRequestBuilder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
@@ -24,6 +23,7 @@ import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.tracking.TrackingRepository import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.safe import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -134,11 +134,11 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
newChapters.size, newChapters.size) newChapters.size, newChapters.size)
with(builder) { with(builder) {
setContentText(summary) setContentText(summary)
setContentText(manga.title) setContentTitle(manga.title)
setNumber(newChapters.size) setNumber(newChapters.size)
setLargeIcon(safe { setLargeIcon(Coil.execute(GetRequestBuilder(applicationContext)
Coil.loader().get(manga.coverUrl).toBitmap() .data(manga.coverUrl)
}) .build()).toBitmapOrNull())
setSmallIcon(R.drawable.ic_stat_book_plus) setSmallIcon(R.drawable.ic_stat_book_plus)
val style = NotificationCompat.InboxStyle(this) val style = NotificationCompat.InboxStyle(this)
for (chapter in newChapters) { for (chapter in newChapters) {
@@ -152,6 +152,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
intent, PendingIntent.FLAG_UPDATE_CURRENT)) intent, PendingIntent.FLAG_UPDATE_CURRENT))
setAutoCancel(true) setAutoCancel(true)
color = colorPrimary color = colorPrimary
setShortcutId(manga.id.toString())
priority = NotificationCompat.PRIORITY_DEFAULT priority = NotificationCompat.PRIORITY_DEFAULT
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
builder.setSound(settings.notificationSound.toUriOrNull()) builder.setSound(settings.notificationSound.toUriOrNull())

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.ui.utils
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import java.io.PrintWriter import java.io.PrintWriter
import java.io.StringWriter import java.io.StringWriter
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -22,7 +23,7 @@ class AppCrashHandler(private val applicationContext: Context) : Thread.Uncaught
} catch (t: Throwable) { } catch (t: Throwable) {
t.printStackTrace() t.printStackTrace()
} }
e.printStackTrace() Log.e("CRASH", e.message, e)
exitProcess(1) exitProcess(1)
} }
} }

View File

@@ -20,6 +20,8 @@ class WidgetUpdater(private val context: Context) : OnFavouritesChangeListener,
updateWidget(RecentWidgetProvider::class.java) updateWidget(RecentWidgetProvider::class.java)
} }
override fun onCategoriesChanged() = Unit
private fun updateWidget(cls: Class<*>) { private fun updateWidget(cls: Class<*>) {
val intent = Intent(context, cls) val intent = Intent(context, cls)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE

View File

@@ -4,19 +4,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.RemoteViewsService import android.widget.RemoteViewsService
import androidx.core.graphics.drawable.toBitmap
import coil.Coil import coil.Coil
import coil.api.get import coil.request.GetRequestBuilder
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okio.IOException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.domain.history.HistoryRepository import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.requireBitmap
import java.io.IOException
class RecentListFactory(context: Context, private val intent: Intent) : RemoteViewsService.RemoteViewsFactory { class RecentListFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
private val packageName = context.packageName
private val dataSet = ArrayList<Manga>() private val dataSet = ArrayList<Manga>()
@@ -36,11 +34,13 @@ class RecentListFactory(context: Context, private val intent: Intent) : RemoteVi
override fun hasStableIds() = true override fun hasStableIds() = true
override fun getViewAt(position: Int): RemoteViews { override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(packageName, R.layout.item_recent) val views = RemoteViews(context.packageName, R.layout.item_recent)
val item = dataSet[position] val item = dataSet[position]
try { try {
val cover = runBlocking { val cover = runBlocking {
Coil.loader().get(item.coverUrl).toBitmap() Coil.execute(GetRequestBuilder(context)
.data(item.coverUrl)
.build()).requireBitmap()
} }
views.setImageViewBitmap(R.id.imageView_cover, cover) views.setImageViewBitmap(R.id.imageView_cover, cover)
} catch (e: IOException) { } catch (e: IOException) {

View File

@@ -6,6 +6,6 @@ import android.widget.RemoteViewsService
class RecentWidgetService : RemoteViewsService() { class RecentWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return RecentListFactory(this, intent) return RecentListFactory(this)
} }
} }

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.ui.widget.shelf
import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class CategorySelectAdapter(onItemClickListener: OnRecyclerItemClickListener<FavouriteCategory>? = null) :
BaseRecyclerAdapter<FavouriteCategory, Boolean>(onItemClickListener) {
var checkedItemId = 0L
private set
fun setCheckedId(id: Long) {
val oldId = checkedItemId
checkedItemId = id
val oldPos = findItemPositionById(oldId)
val newPos = findItemPositionById(id)
if (newPos != -1) {
notifyItemChanged(newPos)
}
if (oldPos != -1) {
notifyItemChanged(oldPos)
}
}
override fun getExtra(item: FavouriteCategory, position: Int) =
checkedItemId == item.id
override fun onCreateViewHolder(parent: ViewGroup) = CategorySelectHolder(
parent
)
override fun onGetItemId(item: FavouriteCategory) = item.id
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.ui.widget.shelf
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_category_checkable.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class CategorySelectHolder(parent: ViewGroup) :
BaseViewHolder<FavouriteCategory, Boolean>(parent, R.layout.item_category_checkable_single) {
override fun onBind(data: FavouriteCategory, extra: Boolean) {
checkedTextView.text = data.title
checkedTextView.isChecked = extra
}
}

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