Compare commits

..

1 Commits

Author SHA1 Message Date
Koitharu
5fe40ac17e Fix old api compatibility 2020-04-25 21:29:46 +03:00
267 changed files with 1109 additions and 3697 deletions

4
.idea/compiler.xml generated
View File

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

View File

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

6
.idea/kotlinc.xml generated
View File

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

2
.idea/misc.xml generated
View File

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

9
.idea/vcs.xml generated
View File

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

View File

@@ -8,8 +8,6 @@ Kotatsu is a free and open source manga reader for Android.
Latest release: [get here](https://github.com/nv95/Kotatsu/releases/latest)
Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy)
### Main Features
* Online manga catalogues
@@ -22,16 +20,11 @@ Legacy build (Android 4.1+): [available here](https://github.com/nv95/Kotatsu/re
* Standard and Webtoon-optimized reader
* Notifications about new chapters
### Coming Features
* Updates feed
* Global search
### Screenshots
| ![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-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-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) |
| ![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) |
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,8 @@ package org.koitharu.kotatsu
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import coil.Coil
import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.ImageLoader
import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -16,9 +13,10 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.DatabasePrePopulateCallback
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
@@ -46,6 +44,7 @@ class KotatsuApp : Application() {
override fun onCreate() {
super.onCreate()
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
initKoin()
initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
@@ -87,19 +86,16 @@ class KotatsuApp : Application() {
}
private fun initCoil() {
Coil.setImageLoader(
ImageLoaderBuilder(applicationContext)
.okHttpClient(
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
).componentRegistry(
ComponentRegistry.Builder()
.add(CbzFetcher())
.build()
)
.build()
)
Coil.setDefaultImageLoader(ImageLoader(applicationContext) {
okHttpClient {
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
}
componentRegistry {
add(CbzFetcher())
}
})
}
private fun initErrorHandler() {
@@ -125,6 +121,5 @@ class KotatsuApp : Application() {
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5, Migration5To6)
.addCallback(DatabasePrePopulateCallback(resources))
).addMigrations(Migration1To2, Migration2To3, Migration3To4)
}

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,8 @@ import org.koitharu.kotatsu.core.db.entity.*
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class
], version = 6
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class
], version = 4
)
abstract class MangaDatabase : RoomDatabase() {
@@ -26,6 +25,4 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract val tracksDao: TracksDao
abstract val trackLogsDao: TrackLogsDao
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,10 +15,10 @@
*/
package org.koitharu.kotatsu.core.local.cookies
import org.koitharu.kotatsu.core.local.cookies.persistence.CookiePersistor
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.local.cookies.cache.CookieCache
import org.koitharu.kotatsu.core.local.cookies.persistence.CookiePersistor
import java.util.*
class PersistentCookieJar(
@@ -72,7 +72,7 @@ class PersistentCookieJar(
fun filterPersistentCookies(cookies: List<Cookie>): List<Cookie> {
val persistentCookies: MutableList<Cookie> = ArrayList()
for (cookie in cookies) {
if (cookie.persistent) {
if (cookie.persistent()) {
persistentCookies.add(cookie)
}
}
@@ -81,7 +81,7 @@ class PersistentCookieJar(
@JvmStatic
fun isCookieExpired(cookie: Cookie): Boolean {
return cookie.expiresAt < System.currentTimeMillis()
return cookie.expiresAt() < System.currentTimeMillis()
}
}
}

View File

@@ -30,18 +30,18 @@ internal class IdentifiableCookie(val cookie: Cookie) {
override fun equals(other: Any?): Boolean {
if (other !is IdentifiableCookie) return false
return other.cookie.name == cookie.name && other.cookie.domain == cookie.domain
&& other.cookie.path == cookie.path && other.cookie.secure == cookie.secure
&& other.cookie.hostOnly == cookie.hostOnly
return other.cookie.name() == cookie.name() && other.cookie.domain() == cookie.domain()
&& other.cookie.path() == cookie.path() && other.cookie.secure() == cookie.secure()
&& other.cookie.hostOnly() == cookie.hostOnly()
}
override fun hashCode(): Int {
var hash = 17
hash = 31 * hash + cookie.name.hashCode()
hash = 31 * hash + cookie.domain.hashCode()
hash = 31 * hash + cookie.path.hashCode()
hash = 31 * hash + if (cookie.secure) 0 else 1
hash = 31 * hash + if (cookie.hostOnly) 0 else 1
hash = 31 * hash + cookie.name().hashCode()
hash = 31 * hash + cookie.domain().hashCode()
hash = 31 * hash + cookie.path().hashCode()
hash = 31 * hash + if (cookie.secure()) 0 else 1
hash = 31 * hash + if (cookie.hostOnly()) 0 else 1
return hash
}

View File

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

View File

@@ -73,14 +73,14 @@ class SerializableCookie : Serializable {
@Throws(IOException::class)
private fun writeObject(out: ObjectOutputStream) {
out.writeObject(cookie!!.name)
out.writeObject(cookie!!.value)
out.writeLong(if (cookie!!.persistent) cookie!!.expiresAt else NON_VALID_EXPIRES_AT)
out.writeObject(cookie!!.domain)
out.writeObject(cookie!!.path)
out.writeBoolean(cookie!!.secure)
out.writeBoolean(cookie!!.httpOnly)
out.writeBoolean(cookie!!.hostOnly)
out.writeObject(cookie!!.name())
out.writeObject(cookie!!.value())
out.writeLong(if (cookie!!.persistent()) cookie!!.expiresAt() else NON_VALID_EXPIRES_AT)
out.writeObject(cookie!!.domain())
out.writeObject(cookie!!.path())
out.writeBoolean(cookie!!.secure())
out.writeBoolean(cookie!!.httpOnly())
out.writeBoolean(cookie!!.hostOnly())
}
@Throws(IOException::class, ClassNotFoundException::class)

View File

@@ -64,7 +64,7 @@ class SharedPrefsCookiePersistor(private val sharedPreferences: SharedPreference
private companion object {
fun createCookieKey(cookie: Cookie): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
return (if (cookie.secure()) "https" else "http") + "://" + cookie.domain() + cookie.path() + "|" + cookie.name()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,9 @@ 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.map
import org.koitharu.kotatsu.utils.ext.mapIndexed
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.parseJson
import org.koitharu.kotatsu.utils.ext.*
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
class DesuMeRepository : RemoteMangaRepository() {
override val source = MangaSource.DESUME

View File

@@ -4,11 +4,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
abstract class GroupleRepository : RemoteMangaRepository() {
protected abstract val defaultDomain: String
@@ -22,7 +20,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tag: MangaTag?,
): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val doc = when {
@@ -30,11 +28,8 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
"https://$domain/search",
mapOf("q" to query, "offset" to offset.toString())
)
tag == null -> loaderContext.httpGet(
"https://$domain/list?sortType=${getSortKey(
sortOrder
)}&offset=$offset"
)
tag == null -> loaderContext.httpGet("https://$domain/list?sortType=${getSortKey(
sortOrder)}&offset=$offset")
else -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${getSortKey(
sortOrder
@@ -113,7 +108,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
a.attr("href")?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.ownText().removePrefix(manga.title).trim(),
name = a.ownText(),
number = i + 1,
url = href,
source = source

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
package org.koitharu.kotatsu.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.annotation.WorkerThread
import android.graphics.Point
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
@@ -14,7 +12,6 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull
import java.io.InputStream
import java.util.zip.ZipFile
object MangaUtils : KoinComponent {
@@ -22,31 +19,20 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineReaderMode(pages: List<MangaPage>): ReaderMode? {
try {
val page = pages.medianOrNull() ?: return null
val url = MangaProviderFactory.create(page.source).getPageFullUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
} else {
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
}
val client = get<OkHttpClient>()
val request = Request.Builder()
.url(url)
.get()
.build()
val size = client.newCall(request).await().use {
getBitmapSize(it.body()?.byteStream())
}
return when {
size.width * 2 < size.height -> ReaderMode.WEBTOON
size.x * 2 < size.y -> ReaderMode.WEBTOON
else -> ReaderMode.STANDARD
}
} catch (e: Exception) {
@@ -58,7 +44,7 @@ object MangaUtils : KoinComponent {
}
@JvmStatic
private fun getBitmapSize(input: InputStream?): Size {
private fun getBitmapSize(input: InputStream?): Point {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
@@ -66,6 +52,6 @@ object MangaUtils : KoinComponent {
val imageHeight: Int = options.outHeight
val imageWidth: Int = options.outWidth
check(imageHeight > 0 && imageWidth > 0)
return Size(imageWidth, imageHeight)
return Point(imageWidth, imageHeight)
}
}

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ class HistoryRepository : KoinComponent {
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)
@@ -36,7 +36,7 @@ class HistoryRepository : KoinComponent {
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
scroll = scroll
)
)
trackingRepository.upsert(manga)
@@ -45,7 +45,15 @@ class HistoryRepository : KoinComponent {
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao.find(manga.id)?.toMangaHistory()
return db.historyDao.find(manga.id)?.let {
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll
)
}
}
suspend fun clear() {
@@ -78,7 +86,7 @@ class HistoryRepository : KoinComponent {
}
fun unsubscribe(listener: OnHistoryChangeListener) {
listeners -= listener
listeners += listener
}
private suspend fun notifyHistoryChanged() {

View File

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

View File

@@ -47,6 +47,9 @@ class MangaZip(val file: File) {
if (!file.exists()) {
return
}
dir.listFiles()?.forEach {
it?.deleteRecursively()
}
ZipInputStream(file.inputStream()).use { input ->
while (true) {
val entry = input.nextEntry ?: return

View File

@@ -1,15 +1,11 @@
package org.koitharu.kotatsu.domain.tracking
import androidx.room.withTransaction
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaTracking
import org.koitharu.kotatsu.core.model.TrackingLogItem
import java.util.*
class TrackingRepository : KoinComponent {
@@ -38,39 +34,22 @@ class TrackingRepository : KoinComponent {
}
}
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
}
}
suspend fun storeTrackResult(
mangaId: Long,
knownChaptersCount: Int,
lastChapterId: Long,
newChapters: List<MangaChapter>,
previousTrackChapterId: Long
newChapters: Int,
lastNotifiedChapterId: Long
) {
db.withTransaction {
val entity = TrackEntity(
mangaId = mangaId,
newChapters = newChapters.size,
lastCheck = System.currentTimeMillis(),
lastChapterId = lastChapterId,
totalChapters = knownChaptersCount,
lastNotifiedChapterId = newChapters.lastOrNull()?.id ?: previousTrackChapterId
)
db.tracksDao.upsert(entity)
val foundChapters = newChapters.takeLastWhile { x -> x.id != previousTrackChapterId }
if (foundChapters.isNotEmpty()) {
val logEntity = TrackLogEntity(
mangaId = mangaId,
chapters = foundChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis()
)
db.trackLogsDao.insert(logEntity)
}
}
val entity = TrackEntity(
mangaId = mangaId,
newChapters = newChapters,
lastCheck = System.currentTimeMillis(),
lastChapterId = lastChapterId,
totalChapters = knownChaptersCount,
lastNotifiedChapterId = lastNotifiedChapterId
)
db.tracksDao.upsert(entity)
}
suspend fun upsert(manga: Manga) {

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.ui.browser
import android.graphics.Bitmap
import android.os.Build
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
@@ -38,6 +40,7 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClient(), Ko
return url?.let(::doRequest)
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
return request?.url?.toString()?.let(::doRequest)
}
@@ -47,11 +50,11 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClient(), Ko
.url(url)
.build()
val response = okHttp.newCall(request).execute()
val ct = response.body?.contentType()
val ct = response.body()?.contentType()
WebResourceResponse(
"${ct?.type}/${ct?.subtype}",
"${ct?.type()}/${ct?.subtype()}",
ct?.charset()?.name() ?: "utf-8",
response.body?.byteStream()
response.body()?.byteStream()
)
}
}

View File

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

View File

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

View File

@@ -1,36 +1,37 @@
package org.koitharu.kotatsu.ui.common
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
abstract class BaseFullscreenActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(window) {
// addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
showSystemUI()
}
protected fun hideSystemUI() {
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // прячем панель навигации
or View.SYSTEM_UI_FLAG_FULLSCREEN // прячем строку состояния
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
window.decorView.systemUiVisibility =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // прячем панель навигации
or View.SYSTEM_UI_FLAG_FULLSCREEN // прячем строку состояния
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
} else {
(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // прячем панель навигации
or View.SYSTEM_UI_FLAG_FULLSCREEN // прячем строку состояния
)
}
}

View File

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

View File

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

View File

@@ -2,43 +2,33 @@ package org.koitharu.kotatsu.ui.common.dialog
import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Environment
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.item_storage.view.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.findParent
import org.koitharu.kotatsu.utils.ext.inflate
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
class Builder(context: Context) {
private val adapter = VolumesAdapter(context)
private val delegate = MaterialAlertDialogBuilder(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 {
delegate.setTitle(titleResId)
@@ -50,17 +40,12 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return this
}
fun setNegativeButton(@StringRes textId: Int): Builder {
delegate.setNegativeButton(textId, null)
return this
}
fun create() = StorageSelectDialog(delegate.create())
}
private class VolumesAdapter(context: Context) : BaseAdapter() {
private class VolumesAdapter(context: Context): BaseAdapter() {
val volumes = getAvailableVolumes(context)
private val volumes = getAvailableVolumes(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage)
@@ -70,7 +55,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
return view
}
override fun getItem(position: Int): Pair<File, String> = volumes[position]
override fun getItem(position: Int): Any = volumes[position]
override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode()
@@ -78,17 +63,15 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
}
interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object {
@JvmStatic
fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
return LocalMangaRepository.getAvailableStorageDirs(context).map {
it to it.getStorageName(context)
fun getAvailableVolumes(context: Context): List<Pair<File,String>> = context.getExternalFilesDirs(null).mapNotNull {
val root = it.findParent { x -> x.name == "Android" }?.parentFile ?: return@mapNotNull null
root to when {
Environment.isExternalStorageEmulated(root) -> context.getString(R.string.internal_storage)
Environment.isExternalStorageRemovable(root) -> context.getString(R.string.external_storage)
else -> root.name
}
}
}

View File

@@ -7,7 +7,6 @@ import android.text.InputFilter
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.dialog_input.view.*
import org.koitharu.kotatsu.R
@@ -21,7 +20,7 @@ class TextInputDialog private constructor(private val delegate: AlertDialog) :
@SuppressLint("InflateParams")
private val view = LayoutInflater.from(context).inflate(R.layout.dialog_input, null, false)
private val delegate = MaterialAlertDialogBuilder(context)
private val delegate = AlertDialog.Builder(context)
.setView(view)
fun setTitle(@StringRes titleResId: Int): Builder {

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.ui.common.list
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.*
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
@@ -12,7 +11,7 @@ class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long)
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
oldList[oldItemPosition]?.equals(newList[newItemPosition]) == true
override fun getOldListSize() = oldList.size

View File

@@ -2,9 +2,10 @@ package org.koitharu.kotatsu.ui.common.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import okhttp3.internal.toImmutableList
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.utils.ext.replaceWith
import java.util.*
import kotlin.collections.ArrayList
abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecyclerItemClickListener<T>? = null) :
RecyclerView.Adapter<BaseViewHolder<T, E>>(),
@@ -12,7 +13,8 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
protected val dataSet = ArrayList<T>() //TODO make private
val items get() = dataSet.toImmutableList()
val items: List<T>
get() = Collections.unmodifiableList(dataSet)
val hasItems get() = dataSet.isNotEmpty()
@@ -87,23 +89,16 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {
return onCreateViewHolder(parent)
}
override fun onViewDetachedFromWindow(holder: BaseViewHolder<T, E>) {
holder.setOnItemClickListener(null)
super.onViewDetachedFromWindow(holder)
}
override fun onViewAttachedToWindow(holder: BaseViewHolder<T, E>) {
super.onViewAttachedToWindow(holder)
holder.setOnItemClickListener(onItemClickListener)
return onCreateViewHolder(parent).setOnItemClickListener(onItemClickListener)
.also(this::onViewHolderCreated)
}
protected open fun onDataSetChanged() = Unit
protected abstract fun getExtra(item: T, position: Int): E
protected open fun onViewHolderCreated(holder: BaseViewHolder<T, E>) = Unit
protected abstract fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<T, E>
protected abstract fun onGetItemId(item: T): Long

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.ui.common.list
import android.os.Build
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
@@ -26,29 +27,26 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
onBind(data, extra)
}
fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
}
fun requireData() = boundData ?: throw IllegalStateException("Calling requireData() before bind()")
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?) {
val listenersAdapter = listener?.let { HolderListenersAdapter(it) }
itemView.setOnClickListener(listenersAdapter)
itemView.setOnLongClickListener(listenersAdapter)
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?): BaseViewHolder<T, E> {
if (listener != null) {
itemView.setOnClickListener {
listener.onItemClick(boundData ?: return@setOnClickListener, bindingAdapterPosition, it)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, bindingAdapterPosition, it)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
itemView.setOnContextClickListener {
listener.onItemLongClick(boundData ?: return@setOnContextClickListener false, bindingAdapterPosition, it)
}
}
}
return this
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E)
private inner class HolderListenersAdapter(private val listener: OnRecyclerItemClickListener<T>) :
View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
listener.onItemClick(boundData ?: return, bindingAdapterPosition, v)
}
override fun onLongClick(v: View): Boolean {
return listener.onItemLongClick(boundData ?: return false, bindingAdapterPosition, v)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,15 +7,11 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
import androidx.appcompat.app.AlertDialog
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.android.synthetic.main.activity_details.*
import kotlinx.coroutines.launch
import moxy.MvpDelegate
@@ -32,10 +28,8 @@ import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
TabLayoutMediator.TabConfigurationStrategy {
class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance)
@@ -45,8 +39,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(this)
TabLayoutMediator(tabs, pager, this).attach()
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager)
tabs.setupWithViewPager(pager)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
presenter.loadDetails(it, true)
@@ -106,8 +100,6 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
manga?.source != null && manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_delete).isVisible =
manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible =
manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible =
ShortcutManagerCompat.isRequestPinShortcutSupported(this)
return super.onPrepareOptionsMenu(menu)
@@ -126,7 +118,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
}
R.id.action_delete -> {
manga?.let { m ->
MaterialAlertDialogBuilder(this)
AlertDialog.Builder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
@@ -141,18 +133,9 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
manga?.let {
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
MaterialAlertDialogBuilder(this)
AlertDialog.Builder(this)
.setTitle(R.string.save_manga)
.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setMessage(getString(R.string.large_manga_save_confirm, chaptersCount))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
@@ -186,27 +169,6 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
else -> super.onOptionsItemSelected(item)
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = when (position) {
0 -> getString(R.string.details)
1 -> getString(R.string.chapters)
2 -> getString(R.string.related)
else -> null
}
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
pager.isUserInputEnabled = false
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
pager.isUserInputEnabled = true
window?.statusBarColor = getThemeColor(R.attr.colorPrimaryDark)
}
companion object {
private const val EXTRA_MANGA = "manga"

View File

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

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.ui.details
import android.text.Spanned
import android.view.View
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible
import coil.api.load
@@ -14,14 +13,12 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.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.search.MangaSearchSheet
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.addChips
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
@@ -70,16 +67,6 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
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)
button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this)
@@ -136,8 +123,8 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
}
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
when {
v.id == R.id.button_read -> {
if (history == null) {
return false
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.ui.details
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
@@ -14,7 +13,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.domain.MangaDataRepository
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.MangaSearchRepository
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.domain.history.HistoryRepository
@@ -32,7 +30,6 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
private lateinit var historyRepository: HistoryRepository
private lateinit var favouritesRepository: FavouritesRepository
private lateinit var trackingRepository: TrackingRepository
private lateinit var searchRepository: MangaSearchRepository
private var manga: Manga? = null
@@ -40,7 +37,6 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
historyRepository = HistoryRepository()
favouritesRepository = FavouritesRepository()
trackingRepository = TrackingRepository()
searchRepository = MangaSearchRepository()
super.onFirstViewAttach()
HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this)
@@ -151,38 +147,6 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
}
}
fun loadRelated() {
val manga = this.manga ?: return
presenterScope.launch {
viewState.onLoadingStateChanged(isLoading = true)
var isFirstCall = true
searchRepository.globalSearch(manga.title)
.map { list ->
list.filter { x -> x.id != manga.id }
}.filterNot { x -> x.isEmpty() }
.flowOn(Dispatchers.IO)
.catch { e ->
if (e is IOException) {
viewState.onError(e)
}
}
.onEmpty {
viewState.onListChanged(emptyList())
viewState.onLoadingStateChanged(isLoading = false)
}.onCompletion {
viewState.onListAppended(emptyList())
}.collect {
if (isFirstCall) {
isFirstCall = false
viewState.onListChanged(it)
viewState.onLoadingStateChanged(isLoading = false)
} else {
viewState.onListAppended(it)
}
}
}
}
override fun onHistoryChanged() {
loadHistory(manga ?: return)
}
@@ -193,8 +157,6 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
}
}
override fun onCategoriesChanged() = Unit
override fun onDestroy() {
HistoryRepository.unsubscribe(this)
FavouritesRepository.unsubscribe(this)

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ class DownloadNotification(private val context: Context) {
fun fillFrom(manga: Manga) {
builder.setContentTitle(manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setTicker(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setLargeIcon(null)
@@ -56,7 +57,7 @@ class DownloadNotification(private val context: Context) {
} else {
val intent = DownloadService.getCancelIntent(context, startId)
builder.addAction(
R.drawable.ic_cross,
R.drawable.ic_cross_compat,
context.getString(android.R.string.cancel),
PendingIntent.getService(
context,

View File

@@ -8,7 +8,7 @@ import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.ContextCompat
import coil.Coil
import coil.request.GetRequestBuilder
import coil.api.get
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.absoluteValue
class DownloadService : BaseService() {
@@ -40,7 +39,6 @@ class DownloadService : BaseService() {
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val jobs = HashMap<Int, Job>()
private val mutex = Mutex()
@@ -76,21 +74,21 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) {
mutex.lock()
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20))
withContext(Dispatchers.Main) {
notification.fillFrom(manga)
notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
}
val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
val destination = getExternalFilesDir("manga") ?: filesDir.sub("manga").takeIf {
it.exists() || it.mkdir()
}
checkNotNull(destination) { "Cannot find place to store file" }
var output: MangaZip? = null
try {
val repo = MangaProviderFactory.create(manga.source)
val cover = safe {
Coil.execute(GetRequestBuilder(this@DownloadService)
.data(manga.coverUrl)
.build()).drawable
Coil.loader().get(manga.coverUrl)
}
withContext(Dispatchers.Main) {
notification.setLargeIcon(cover)
@@ -151,6 +149,7 @@ class DownloadService : BaseService() {
notification.update()
}
} catch (e: Throwable) {
e.printStackTrace()
withContext(Dispatchers.Main) {
notification.setError(e)
notification.setCancelId(0)
@@ -167,9 +166,7 @@ class DownloadService : BaseService() {
notification.dismiss()
stopSelf(startId)
}
if (wakeLock.isHeld) {
wakeLock.release()
}
wakeLock.release()
mutex.unlock()
}
}
@@ -186,7 +183,7 @@ class DownloadService : BaseService() {
okHttp.newCall(request).await().use { response ->
val file = destination.sub("page.tmp")
file.outputStream().use { out ->
response.body!!.byteStream().copyTo(out)
response.body()!!.byteStream().copyTo(out)
}
file
}

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.ui.list.favourites.categories
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_category.*
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class CategoriesAdapter(private val onItemClickListener: OnRecyclerItemClickListener<FavouriteCategory>) :
BaseRecyclerAdapter<FavouriteCategory, Unit>() {
override fun onCreateViewHolder(parent: ViewGroup) = CategoryHolder(parent)
override fun onGetItemId(item: FavouriteCategory) = item.id
override fun getExtra(item: FavouriteCategory, position: Int) = Unit
@SuppressLint("ClickableViewAccessibility")
override fun onViewAttachedToWindow(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
}
}
}
override fun onViewDetachedFromWindow(holder: BaseViewHolder<FavouriteCategory, Unit>) {
holder.imageView_more.setOnClickListener(null)
holder.imageView_handle.setOnTouchListener(null)
}
fun moveItem(oldPos: Int, newPos: Int) {
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
}
}

View File

@@ -1,19 +0,0 @@
package org.koitharu.kotatsu.ui.list.feed
import android.view.ViewGroup
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
class FeedAdapter(onItemClickListener: OnRecyclerItemClickListener<TrackingLogItem>? = null) :
BaseRecyclerAdapter<TrackingLogItem, Unit>(onItemClickListener) {
override fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<TrackingLogItem, Unit> {
return FeedHolder(parent)
}
override fun onGetItemId(item: TrackingLogItem) = item.id
override fun getExtra(item: TrackingLogItem, position: Int) = Unit
}

View File

@@ -1,126 +0,0 @@
package org.koitharu.kotatsu.ui.list.feed
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_tracklogs.*
import moxy.MvpDelegate
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.ui.common.list.PaginationScrollListener
import org.koitharu.kotatsu.ui.common.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.callOnScrollListeners
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasItems
class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), FeedView,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<TrackingLogItem> {
private val presenter by moxyPresenter(factory = ::FeedPresenter)
private var adapter: FeedAdapter? = null
override fun getTitle() = context?.getString(R.string.updates)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = FeedAdapter(this)
recyclerView.adapter = adapter
recyclerView.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
)
recyclerView.setHasFixedSize(true)
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_feed, menu)
}
override fun onDestroyView() {
adapter = null
super.onDestroyView()
}
override fun onListChanged(list: List<TrackingLogItem>) {
adapter?.replaceData(list)
if (list.isEmpty()) {
setUpEmptyListHolder()
layout_holder.isVisible = true
} else {
layout_holder.isVisible = false
}
recyclerView.callOnScrollListeners()
}
override fun onListAppended(list: List<TrackingLogItem>) {
adapter?.appendData(list)
recyclerView.callOnScrollListeners()
}
override fun onListError(e: Throwable) {
if (recyclerView.hasItems) {
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT)
.show()
} else {
textView_holder.text = e.getDisplayMessage(resources)
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(
0,
R.drawable.ic_error_large,
0,
0
)
layout_holder.isVisible = true
}
}
override fun onError(e: Throwable) {
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems
if (isLoading) {
layout_holder.isVisible = false
}
}
override fun getItemsCount(): Int {
return adapter?.itemCount ?: 0
}
override fun onRequestMoreItems(offset: Int) {
presenter.loadList(offset)
}
override fun onItemClick(item: TrackingLogItem, position: Int, view: View) {
startActivity(MangaDetailsActivity.newIntent(context ?: return, item.manga))
}
private fun setUpEmptyListHolder() {
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
textView_holder.setText(R.string.text_feed_holder)
}
companion object {
fun newInstance() = FeedFragment()
}
}

View File

@@ -1,41 +0,0 @@
package org.koitharu.kotatsu.ui.list.feed
import android.text.format.DateUtils
import android.view.ViewGroup
import coil.api.clear
import coil.api.load
import kotlinx.android.synthetic.main.item_tracklog.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.utils.ext.formatRelative
class FeedHolder(parent: ViewGroup) :
BaseViewHolder<TrackingLogItem, Unit>(parent, R.layout.item_tracklog) {
override fun onBind(data: TrackingLogItem, extra: Unit) {
imageView_cover.load(data.manga.coverUrl) {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder)
}
textView_title.text = data.manga.title
textView_subtitle.text = buildString {
append(data.createdAt.formatRelative(DateUtils.DAY_IN_MILLIS))
append(" ")
append(
context.resources.getQuantityString(
R.plurals.new_chapters,
data.chapters.size,
data.chapters.size
)
)
}
textView_chapters.text = data.chapters.joinToString("\n")
}
override fun onRecycled() {
super.onRecycled()
imageView_cover.clear()
}
}

View File

@@ -1,48 +0,0 @@
package org.koitharu.kotatsu.ui.list.feed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.presenterScope
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
class FeedPresenter : BasePresenter<FeedView>() {
private lateinit var repository: TrackingRepository
override fun onFirstViewAttach() {
repository = TrackingRepository()
super.onFirstViewAttach()
}
fun loadList(offset: Int) {
presenterScope.launch {
viewState.onLoadingStateChanged(true)
try {
val list = withContext(Dispatchers.IO) {
repository.getTrackingLog(offset, 20)
}
if (offset == 0) {
viewState.onListChanged(list)
} else {
viewState.onListAppended(list)
}
} catch (e: CancellationException) {
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
if (offset == 0) {
viewState.onListError(e)
} else {
viewState.onError(e)
}
} finally {
viewState.onLoadingStateChanged(false)
}
}
}
}

View File

@@ -1,19 +0,0 @@
package org.koitharu.kotatsu.ui.list.feed
import moxy.viewstate.strategy.AddToEndSingleTagStrategy
import moxy.viewstate.strategy.AddToEndStrategy
import moxy.viewstate.strategy.StateStrategyType
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.ui.common.BaseMvpView
interface FeedView : BaseMvpView {
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListChanged(list: List<TrackingLogItem>)
@StateStrategyType(AddToEndStrategy::class, tag = "content")
fun onListAppended(list: List<TrackingLogItem>)
@StateStrategyType(AddToEndSingleTagStrategy::class, tag = "content")
fun onListError(e: Throwable)
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.list
package org.koitharu.kotatsu.ui.main
import android.app.ActivityOptions
import android.content.SharedPreferences
@@ -21,18 +21,15 @@ import moxy.ktx.moxyPresenter
import org.koin.core.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.list.favourites.FavouritesContainerFragment
import org.koitharu.kotatsu.ui.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.list.feed.FeedFragment
import org.koitharu.kotatsu.ui.main.list.favourites.FavouritesListFragment
import org.koitharu.kotatsu.ui.main.list.history.HistoryListFragment
import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.search.SearchHelper
import org.koitharu.kotatsu.ui.settings.AppUpdateService
import org.koitharu.kotatsu.ui.settings.SettingsActivity
import org.koitharu.kotatsu.ui.tracker.TrackWorker
@@ -59,7 +56,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
navigationView.setNavigationItemSelectedListener(this)
settings.subscribe(this)
fab.imageTintList = ColorStateList.valueOf(Color.WHITE)
fab.supportImageTintList = ColorStateList.valueOf(Color.WHITE)
fab.isVisible = true
fab.setOnClickListener {
presenter.openLastReader()
@@ -68,7 +65,8 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
supportFragmentManager.findFragmentById(R.id.container)?.let {
fab.isVisible = it is HistoryListFragment
} ?: run {
openDefaultSection()
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
@@ -92,11 +90,8 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
drawerToggle.onConfigurationChanged(newConfig)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
SearchHelper.setupSearchView(menuItem)
}
return super.onCreateOptionsMenu(menu)
}
@@ -111,22 +106,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) {
R.id.nav_history -> {
settings.defaultSection = AppSection.HISTORY
setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
settings.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
settings.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_feed -> {
settings.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_history -> setPrimaryFragment(HistoryListFragment.newInstance())
R.id.nav_favourites -> setPrimaryFragment(FavouritesListFragment.newInstance())
R.id.nav_local_storage -> setPrimaryFragment(LocalListFragment.newInstance())
R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
@@ -185,27 +167,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
private fun openDefaultSection() {
when(settings.defaultSection) {
AppSection.LOCAL -> {
navigationView.setCheckedItem(R.id.nav_local_storage)
setPrimaryFragment(LocalListFragment.newInstance())
}
AppSection.FAVOURITES -> {
navigationView.setCheckedItem(R.id.nav_favourites)
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
AppSection.HISTORY -> {
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
AppSection.FEED -> {
navigationView.setCheckedItem(R.id.nav_feed)
setPrimaryFragment(FeedFragment.newInstance())
}
}
}
private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)

View File

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

View File

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

View File

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

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