Compare commits

...

65 Commits

Author SHA1 Message Date
01607ec1e2 Fix MangaLib provider 2020-05-13 20:17:37 +03:00
50f8cb9193 Action mode chapters selection 2020-05-11 17:30:59 +03:00
0100974508 Optimize webtoon scroll 2020-05-11 14:04:51 +03:00
b438898456 Add mangalib source 2020-05-10 18:52:00 +03:00
c3c43dce3d Update dependencies and fix deprecations 2020-05-10 13:38:14 +03:00
e33dfd63e4 Downgrade kotlin 2020-05-10 12:36:14 +03:00
1927500f5a Configure shelf appwidget 2020-05-09 14:15:13 +03:00
f9ccd0851d Small refactor 2020-05-09 12:33:09 +03:00
23412e5c17 Optimize webtoon scroll 2020-05-09 10:16:46 +03:00
1b7c8355ec Refactor: fix deprecations 2020-05-03 18:04:26 +03:00
8378b3dd90 Disable save action for local chapters 2020-05-03 17:31:33 +03:00
9ff5bb6352 Update readme 2020-04-26 20:43:26 +03:00
b2bb1d22df Fix default storage 2020-04-26 20:28:15 +03:00
34acf5bb55 Fix json null string 2020-04-26 20:25:46 +03:00
5af32898f8 Add MangaDex source 2020-04-26 20:22:49 +03:00
ef7108f6c9 Fix typo 2020-04-26 16:48:57 +03:00
941d992793 Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-26 16:38:32 +03:00
de9a07a680 Select storage where save manga 2020-04-26 12:22:36 +03:00
0dc74f9188 Update readme 2020-04-25 18:06:11 +03:00
f95cf9b231 Reset new chapters on reading 2020-04-25 17:22:20 +03:00
0d0982b244 Check if in-app update allowed 2020-04-19 08:43:45 +03:00
ef4dd82e92 Fix TextInputDialog keyboard behavior 2020-04-19 08:06:24 +03:00
bc825681a8 Small ui fixes 2020-04-18 20:24:19 +03:00
da6204f44f Remove unused resources 2020-04-18 19:56:12 +03:00
10c68bdd72 Update readme 2020-04-18 17:33:27 +03:00
b1e90dde8f Optimize page loading 2020-04-18 12:20:18 +03:00
e0d45961f8 Add some animations 2020-04-18 11:19:20 +03:00
b732a220f6 Merge remote-tracking branch 'origin/feature/appwidget' into devel 2020-04-12 12:44:54 +03:00
582adae11f Manage favourites categories 2020-04-12 12:40:40 +03:00
3014ebdfd4 Notification settings for pre-Oreo android 2020-04-12 09:58:20 +03:00
12b13f98f8 Migrate to WorkManager 2020-04-11 11:39:40 +03:00
c13c43c616 Recent manga app widget 2020-04-10 20:13:17 +03:00
ab1eacea3f Shelf app widget 2020-04-10 19:56:51 +03:00
ac4b97928a Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2020-04-10 17:14:40 +03:00
aa8281678b Update settings 2020-04-10 17:14:33 +03:00
0be4f56538 Refactor shortcut helper 2020-04-08 20:30:44 +03:00
679c06557e Change notification icon 2020-04-08 20:15:29 +03:00
1d387709f2 Cleanup code 2020-04-05 20:40:58 +03:00
a78774d10e Optimize pages thumbnails 2020-04-05 13:04:22 +03:00
390639e9e3 Fix recycled bitmap crash 2020-04-05 12:58:06 +03:00
b98ec2199d Crash info dialog 2020-04-05 12:46:05 +03:00
8b28f1cd74 Small UI fixes 2020-04-03 21:20:41 +03:00
904b78a01e Optimize images in lists 2020-04-03 20:59:34 +03:00
a774d2d915 Read from start quick action 2020-04-03 20:45:18 +03:00
9d19b5fec0 Fix grouple genres list 2020-04-03 20:34:14 +03:00
b6c0f3ca8c Small refactor 2020-04-03 20:24:46 +03:00
e06cb1230f Update dependencies 2020-04-03 19:54:18 +03:00
1720fde4c4 Small fix webtoon reader 2020-04-02 21:59:41 +03:00
4c3dbe1643 Update notification enhancement 2020-04-02 20:39:15 +03:00
3f31bd5ad1 Tracker enhancements 2020-04-02 20:24:46 +03:00
3a79b4667b Merge branch 'master' into devel 2020-04-02 20:10:52 +03:00
de49877178 Fix crash and foreign key issue 2020-03-30 20:55:53 +03:00
65e92fa206 Fix sources preference summary 2020-03-29 21:18:32 +03:00
9cb181d53e Show new chapters on details 2020-03-29 21:08:45 +03:00
a2d4a63eb1 Tracker notifications option in settings 2020-03-29 20:48:53 +03:00
c4f712be3a Improve download notification 2020-03-29 20:41:37 +03:00
9e8367e45e Increase database version 2020-03-29 20:24:06 +03:00
fa2d1de2f2 Merge branch 'San4ito-patch-1' into devel 2020-03-29 18:05:48 +03:00
f8f4573486 Merge branch 'patch-1' of https://github.com/San4ito/Kotatsu into San4ito-patch-1 2020-03-29 18:05:30 +03:00
f15f0ce769 Merge branch 'master' into devel 2020-03-29 18:03:58 +03:00
aad26d24ec Tracking manga updates 2020-03-29 17:33:44 +03:00
80c8344f8d Manga tracking job service 2020-03-29 13:32:25 +03:00
44b23d0b69 Merge branch 'master' into devel 2020-03-29 11:16:45 +03:00
f230f2d198 Update database scheme: tracks table 2020-03-29 10:40:49 +03:00
San4ito
cf50b608a7 Убрал небольшие опечатки
Думаю, так должно быть лучше.
2020-03-28 22:14:21 +03:00
190 changed files with 3590 additions and 774 deletions

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

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

1
.idea/gradle.xml generated
View File

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

View File

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

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

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

9
.idea/vcs.xml generated
View File

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

View File

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

View File

@@ -1,7 +1,9 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
}
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
@@ -15,17 +17,16 @@ android {
minSdkVersion 21
targetSdkVersion 29
versionCode gitCommits
versionName '0.1.3'
versionName '0.3.2'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
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
@@ -38,6 +39,7 @@ android {
applicationIdSuffix = '.debug'
}
release {
multiDexEnabled false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
@@ -58,17 +60,21 @@ androidExtensions {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.5'
implementation 'androidx.core:core-ktx:1.3.0-alpha02'
implementation 'androidx.fragment:fragment-ktx:1.2.3'
implementation 'androidx.appcompat:appcompat:1.2.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
implementation 'androidx.preference:preference:1.1.0'
implementation 'com.google.android.material:material:1.2.0-alpha05'
implementation 'androidx.core:core-ktx:1.3.0-rc01'
implementation 'androidx.appcompat:appcompat:1.2.0-beta01'
implementation 'androidx.activity:activity-ktx:1.2.0-alpha04'
implementation 'androidx.fragment:fragment-ktx:1.3.0-alpha04'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha02'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta5'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha03'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.3.4'
implementation 'com.google.android.material:material:1.2.0-alpha06'
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
@@ -80,18 +86,18 @@ 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.4.1'
implementation 'com.squareup.okio:okio:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.6.0'
implementation 'com.squareup.okio:okio:2.6.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.4'
implementation 'io.coil-kt:coil:0.9.5'
implementation 'org.koin:koin-android:2.1.5'
implementation 'io.coil-kt:coil:0.10.1'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.1.2'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.1.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.2.0'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.2.0'
testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20190722'

View File

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

View File

@@ -8,7 +8,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -44,21 +46,45 @@
android:label="@string/settings" />
<activity
android:name=".ui.reader.SimpleSettingsActivity"
android:label="@string/settings" />
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".ui.browser.BrowserActivity" />
<activity
android:name=".ui.browser.BrowserActivity"
android:launchMode="singleInstance" />
android:name=".ui.utils.CrashActivity"
android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".ui.main.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>
<service
android:name=".ui.download.DownloadService"
android:foregroundServiceType="dataSync" />
<service android:name=".ui.settings.AppUpdateService" />
<service
android:name=".ui.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".ui.widget.recent.RecentWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name=".ui.search.MangaSuggestionsProvider"
android:authorities="${applicationId}.MangaSuggestionsProvider"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
@@ -69,6 +95,27 @@
android:resource="@xml/filepaths" />
</provider>
<receiver
android:name=".ui.widget.shelf.ShelfWidgetProvider"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_shelf" />
</receiver>
<receiver
android:name=".ui.widget.recent.RecentWidgetProvider"
android:label="@string/recent_manga">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" />
</receiver>
</application>
</manifest>

View File

@@ -4,7 +4,8 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import coil.Coil
import coil.ImageLoader
import coil.ComponentRegistry
import coil.ImageLoaderBuilder
import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
@@ -16,6 +17,7 @@ import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.local.CbzFetcher
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.local.cookies.PersistentCookieJar
@@ -24,6 +26,10 @@ import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePers
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.utils.AppCrashHandler
import org.koitharu.kotatsu.ui.widget.WidgetUpdater
import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit
@@ -41,10 +47,14 @@ class KotatsuApp : Application() {
super.onCreate()
initKoin()
initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
if (BuildConfig.DEBUG) {
initErrorHandler()
}
AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme)
val widgetUpdater = WidgetUpdater(applicationContext)
FavouritesRepository.subscribe(widgetUpdater)
HistoryRepository.subscribe(widgetUpdater)
}
private fun initKoin() {
@@ -76,16 +86,19 @@ class KotatsuApp : Application() {
}
private fun initCoil() {
Coil.setDefaultImageLoader(ImageLoader(applicationContext) {
okHttpClient {
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
}
componentRegistry {
add(CbzFetcher())
}
})
Coil.setImageLoader(
ImageLoaderBuilder(applicationContext)
.okHttpClient(
okHttp()
.cache(CoilUtils.createDefaultCache(applicationContext))
.build()
).componentRegistry(
ComponentRegistry.Builder()
.add(CbzFetcher())
.build()
)
.build()
)
}
private fun initErrorHandler() {
@@ -111,5 +124,5 @@ class KotatsuApp : Application() {
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(Migration1To2, Migration2To3)
).addMigrations(Migration1To2, Migration2To3, Migration3To4)
}

View File

@@ -17,4 +17,7 @@ abstract class FavouriteCategoriesDao {
@Query("DELETE FROM favourite_categories WHERE category_id = :id")
abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -29,8 +30,8 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val files = context.getExternalFilesDirs("manga")
.flatMap { x -> x?.listFiles(CbzFilter())?.toList().orEmpty() }
val files = getAvailableStorageDirs(context)
.flatMap { x -> x.listFiles(CbzFilter())?.toList().orEmpty() }
return files.mapNotNull { x -> safe { getFromFile(x) } }
}
@@ -133,9 +134,24 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5)
result += context.filesDir.sub(DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() }
}
fun getFallbackStorageDir(context: Context): File? {
return context.getExternalFilesDir(DIR_NAME) ?: context.filesDir.sub(DIR_NAME).takeIf {
(it.exists() || it.mkdir()) && it.canWrite()
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository : RemoteMangaRepository() {
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
protected abstract val defaultDomain: String
@@ -28,7 +30,11 @@ abstract class GroupleRepository : RemoteMangaRepository() {
"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
@@ -85,12 +91,22 @@ abstract class GroupleRepository : RemoteMangaRepository() {
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(defaultDomain)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root = doc.body().getElementById("mangaBox") ?: throw ParseException("Cannot find root")
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full"
),
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
},
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexedNotNull { i, a ->
val href =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,15 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import android.os.StatFs
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 {
@@ -52,6 +57,31 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
0L
)
val trackerNotifications by BoolPreferenceDelegate(
resources.getString(R.string.key_tracker_notifications),
true
)
var notificationSound by StringPreferenceDelegate(
resources.getString(R.string.key_notifications_sound),
Settings.System.DEFAULT_NOTIFICATION_URI.toString()
)
val notificationVibrate by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_vibrate),
false
)
val notificationLight by BoolPreferenceDelegate(
resources.getString(R.string.key_notifications_light),
true
)
val readerAnimation by BoolPreferenceDelegate(
resources.getString(R.string.key_reader_animation),
false
)
private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order))
var sourcesOrder: List<Int>
@@ -62,6 +92,24 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun getStorageDir(context: Context): File? {
val value = prefs.getString(context.getString(R.string.key_local_storage), null)?.let {
File(it)
}?.takeIf { it.exists() && it.canWrite() }
return value ?: LocalMangaRepository.getFallbackStorageDir(context)
}
fun setStorageDir(context: Context, file: File?) {
val key = context.getString(R.string.key_local_storage)
prefs.edit {
if (file == null) {
remove(key)
} else {
putString(key, file.path)
}
}
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,17 +19,22 @@ class FavouritesRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao().findAll(offset, 20, "created_at")
val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao().findAll("created_at")
val entities = db.favouriteCategoriesDao.findAll("created_at")
return entities.map { it.toFavouriteCategory() }
}
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao().find(mangaId)?.categories
val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
@@ -39,27 +44,30 @@ class FavouritesRepository : KoinComponent {
createdAt = System.currentTimeMillis(),
categoryId = 0
)
val id = db.favouriteCategoriesDao().insert(entity)
val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao().delete(id)
db.favouriteCategoriesDao.delete(id)
}
suspend fun addToCategory(manga: Manga, categoryId: Long) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao().add(entity)
db.favouritesDao.add(entity)
}
notifyFavouritesChanged(manga.id)
}
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao().delete(categoryId, manga.id)
db.favouritesDao.delete(categoryId, manga.id)
notifyFavouritesChanged(manga.id)
}

View File

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

View File

@@ -11,23 +11,25 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import java.util.*
class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject()
private val trackingRepository by lazy(::TrackingRepository)
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao().findAll(offset, limit)
val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.map(TagEntity::toMangaTag).toSet()) }
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
db.historyDao().upsert(
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
@@ -37,12 +39,13 @@ class HistoryRepository : KoinComponent {
scroll = scroll
)
)
trackingRepository.upsert(manga)
}
notifyHistoryChanged()
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao().find(manga.id)?.let {
return db.historyDao.find(manga.id)?.let {
MangaHistory(
createdAt = Date(it.createdAt),
updatedAt = Date(it.updatedAt),
@@ -54,12 +57,12 @@ class HistoryRepository : KoinComponent {
}
suspend fun clear() {
db.historyDao().clear()
db.historyDao.clear()
notifyHistoryChanged()
}
suspend fun delete(manga: Manga) {
db.historyDao().delete(manga.id)
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
@@ -68,8 +71,8 @@ class HistoryRepository : KoinComponent {
* Useful for replacing saved manga on deleting it with remove source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao().update(MangaEntity.from(alternative)) <= 0) {
db.historyDao().delete(manga.id)
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
db.historyDao.delete(manga.id)
notifyHistoryChanged()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.domain.tracking
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.TrackEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaTracking
import java.util.*
class TrackingRepository : KoinComponent {
private val db: MangaDatabase by inject()
suspend fun getNewChaptersCount(mangaId: Long): Int {
val entity = db.tracksDao.find(mangaId) ?: return 0
return entity.newChapters
}
suspend fun getAllTracks(): List<MangaTracking> {
val favourites = db.favouritesDao.findAllManga()
val history = db.historyDao.findAllManga()
val manga = (favourites + history).distinctBy { it.id }
val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
return manga.map { m ->
val track = tracks[m.id]?.singleOrNull()
MangaTracking(
manga = m.toManga(),
knownChaptersCount = track?.totalChapters ?: -1,
lastChapterId = track?.lastChapterId ?: 0L,
lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
)
}
}
suspend fun storeTrackResult(
mangaId: Long,
knownChaptersCount: Int,
lastChapterId: Long,
newChapters: Int,
lastNotifiedChapterId: Long
) {
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) {
val chapters = manga.chapters ?: return
val entity = TrackEntity(
mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = 0,
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = 0L
)
db.tracksDao.upsert(entity)
}
}

View File

@@ -1,22 +1,16 @@
package org.koitharu.kotatsu.ui.common
import android.content.pm.PackageManager
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import moxy.MvpAppCompatActivity
import org.koin.core.KoinComponent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
private var permissionCallback: ((Boolean) -> Unit)? = null
override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID)
setupToolbar()
@@ -36,48 +30,12 @@ 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 onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//TODO remove. Just for testing
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
recreate()
return true
}
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
StorageSelectDialog.Builder(this).create().show()
return true
}
return super.onKeyDown(keyCode, event)
}
private companion object {
const val REQUEST_PERMISSION = 30
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,10 @@ abstract class BaseRecyclerAdapter<T, E>(private val onItemClickListener: OnRecy
onDataSetChanged()
}
override fun onViewRecycled(holder: BaseViewHolder<T, E>) {
holder.onRecycled()
}
final override fun getItemCount() = dataSet.size
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T, E> {

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
@@ -31,14 +32,21 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
fun setOnItemClickListener(listener: OnRecyclerItemClickListener<T>?): BaseViewHolder<T, E> {
if (listener != null) {
itemView.setOnClickListener {
listener.onItemClick(boundData ?: return@setOnClickListener, adapterPosition, it)
listener.onItemClick(boundData ?: return@setOnClickListener, bindingAdapterPosition, it)
}
itemView.setOnLongClickListener {
listener.onItemLongClick(boundData ?: return@setOnLongClickListener false, adapterPosition, it)
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)
}

View File

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

View File

@@ -10,23 +10,68 @@ 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
updateCurrentPosition()
}
var newChaptersCount: Int = 0
set(value) {
val updated = maxOf(field, value)
field = value
notifyItemRangeChanged(itemCount - updated, updated)
}
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 {
currentChapterPosition == RecyclerView.NO_POSITION -> ChapterExtra.UNREAD
item.id in checkedIds -> ChapterExtra.CHECKED
currentChapterPosition == RecyclerView.NO_POSITION
|| currentChapterPosition < position -> if (position >= itemCount - newChaptersCount) {
ChapterExtra.NEW
} else {
ChapterExtra.UNREAD
}
currentChapterPosition == position -> ChapterExtra.CURRENT
currentChapterPosition < position -> ChapterExtra.UNREAD
currentChapterPosition > position -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
}

View File

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

View File

@@ -8,10 +8,13 @@ import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope
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
@@ -25,12 +28,12 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.browser.BrowserActivity
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.download.DownloadService
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ShortcutUtils
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showDialog
class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
class MangaDetailsActivity : BaseActivity(), MangaDetailsView,
TabLayoutMediator.TabConfigurationStrategy {
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance)
@@ -40,8 +43,8 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
pager.adapter = MangaDetailsAdapter(resources, supportFragmentManager)
tabs.setupWithViewPager(pager)
pager.adapter = MangaDetailsAdapter(this)
TabLayoutMediator(tabs, pager, this).attach()
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
intent?.getParcelableExtra<Manga>(EXTRA_MANGA)?.let {
presenter.loadDetails(it, true)
@@ -80,6 +83,17 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
}
}
override fun onNewChaptersChanged(newChapters: Int) {
val tab = tabs.getTabAt(1) ?: return
if (newChapters == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.number = newChapters
badge.isVisible = true
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu)
@@ -108,14 +122,14 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
}
R.id.action_delete -> {
manga?.let { m ->
showDialog {
setTitle(R.string.delete_manga)
setMessage(getString(R.string.text_delete_local_manga, m.title))
setPositiveButton(R.string.delete) { _, _ ->
AlertDialog.Builder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
presenter.deleteLocal(m)
}
setNegativeButton(android.R.string.cancel, null)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
true
}
@@ -145,7 +159,7 @@ class MangaDetailsActivity : BaseActivity(), MangaDetailsView {
R.id.action_shortcut -> {
manga?.let {
lifecycleScope.launch {
if (!ShortcutUtils.requestPinShortcut(this@MangaDetailsActivity, manga)) {
if (!MangaShortcut(it).requestPinShortcut(this@MangaDetailsActivity)) {
Snackbar.make(
pager,
R.string.operation_not_supported,
@@ -159,10 +173,28 @@ 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)
else -> null
}
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
pager.isUserInputEnabled = false
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
pager.isUserInputEnabled = true
}
companion object {
private const val EXTRA_MANGA = "manga"
private const val EXTRA_MANGA_ID = "manga_id"
const val EXTRA_MANGA_ID = "manga_id"
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"

View File

@@ -1,24 +1,16 @@
package org.koitharu.kotatsu.ui.details
import android.content.res.Resources
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import org.koitharu.kotatsu.R
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class MangaDetailsAdapter(private val resources: Resources, fm: FragmentManager) : FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getCount() = 2
override fun getItemCount() = 2
override fun getItem(position: Int): Fragment = when(position) {
override fun createFragment(position: Int): Fragment = when(position) {
0 -> MangaDetailsFragment()
1 -> ChaptersFragment()
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

@@ -13,14 +13,17 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.ui.common.BaseFragment
import org.koitharu.kotatsu.ui.main.list.favourites.categories.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.ext.addChips
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.textAndVisible
import kotlin.math.roundToInt
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView, View.OnClickListener {
class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetailsView,
View.OnClickListener,
View.OnLongClickListener {
@Suppress("unused")
private val presenter by moxyPresenter(factory = MangaDetailsPresenter.Companion::getInstance)
@@ -64,9 +67,9 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
onClickListener = this@MangaDetailsFragment
)
}
imageView_favourite.setOnClickListener {
FavouriteCategoriesDialog.show(childFragmentManager, manga)
}
imageView_favourite.setOnClickListener(this)
button_read.setOnClickListener(this)
button_read.setOnLongClickListener(this)
updateReadButton()
}
@@ -93,12 +96,55 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
override fun onMangaRemoved(manga: Manga) = Unit //handled in activity
override fun onNewChaptersChanged(newChapters: Int) = Unit
override fun onClick(v: View) {
if (v is Chip) {
when(val tag = v.tag) {
is String -> MangaSearchSheet.show(activity?.supportFragmentManager ?: childFragmentManager,
manga?.source ?: return, tag)
when {
v.id == R.id.imageView_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
}
v.id == R.id.button_read -> {
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
history
)
)
}
v is Chip -> {
when (val tag = v.tag) {
is String -> MangaSearchSheet.show(activity?.supportFragmentManager
?: childFragmentManager,
manga?.source ?: return, tag)
}
}
}
}
override fun onLongClick(v: View): Boolean {
when {
v.id == R.id.button_read -> {
if (history == null) {
return false
}
v.showPopupMenu(R.menu.popup_read) {
when (it.itemId) {
R.id.action_read -> {
startActivity(
ReaderActivity.newIntent(
context ?: return@showPopupMenu false,
manga ?: return@showPopupMenu false
)
)
true
}
else -> false
}
}
return true
}
else -> return false
}
}
@@ -114,15 +160,6 @@ class MangaDetailsFragment : BaseFragment(R.layout.fragment_details), MangaDetai
button_read.setText(R.string._continue)
button_read.setIconResource(R.drawable.ic_play)
}
button_read.setOnClickListener {
startActivity(
ReaderActivity.newIntent(
context ?: return@setOnClickListener,
manga ?: return@setOnClickListener,
history
)
)
}
}
}
}

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.domain.favourites.FavouritesRepository
import org.koitharu.kotatsu.domain.favourites.OnFavouritesChangeListener
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.domain.history.OnHistoryChangeListener
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.utils.ext.safe
import java.io.IOException
@@ -28,12 +29,14 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
private lateinit var historyRepository: HistoryRepository
private lateinit var favouritesRepository: FavouritesRepository
private lateinit var trackingRepository: TrackingRepository
private var manga: Manga? = null
override fun onFirstViewAttach() {
historyRepository = HistoryRepository()
favouritesRepository = FavouritesRepository()
trackingRepository = TrackingRepository()
super.onFirstViewAttach()
HistoryRepository.subscribe(this)
FavouritesRepository.subscribe(this)
@@ -75,6 +78,7 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
}
viewState.onMangaUpdated(data)
this@MangaDetailsPresenter.manga = data
viewState.onNewChaptersChanged(trackingRepository.getNewChaptersCount(manga.id))
} catch (_: CancellationException){
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.ui.details
import moxy.MvpView
import moxy.viewstate.strategy.alias.AddToEndSingle
import moxy.viewstate.strategy.alias.OneExecution
import moxy.viewstate.strategy.alias.SingleState
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
@@ -22,4 +20,7 @@ interface MangaDetailsView : BaseMvpView {
@SingleState
fun onMangaRemoved(manga: Manga)
@AddToEndSingle
fun onNewChaptersChanged(newChapters: Int)
}

View File

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

View File

@@ -4,17 +4,16 @@ import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.os.PowerManager
import android.os.WorkSource
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.ContextCompat
import coil.Coil
import coil.api.get
import coil.request.GetRequestBuilder
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.inject
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.local.PagesCache
@@ -31,6 +30,7 @@ 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,6 +40,7 @@ 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()
@@ -81,12 +82,15 @@ class DownloadService : BaseService() {
notification.setCancelId(startId)
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
}
val destination = getExternalFilesDir("manga")!!
val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null
try {
val repo = MangaProviderFactory.create(manga.source)
val cover = safe {
Coil.loader().get(manga.coverUrl)
Coil.execute(GetRequestBuilder(this@DownloadService)
.data(manga.coverUrl)
.build()).drawable
}
withContext(Dispatchers.Main) {
notification.setLargeIcon(cover)

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.ui.main
import android.app.ActivityOptions
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -30,6 +32,7 @@ import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.settings.AppUpdateService
import org.koitharu.kotatsu.ui.settings.SettingsActivity
import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp
@@ -44,7 +47,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
drawerToggle =
ActionBarDrawerToggle(this, drawer, toolbar, R.string.open_menu, R.string.close_menu)
drawer.addDrawerListener(drawerToggle)
@@ -69,6 +71,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
}
TrackWorker.setup(applicationContext)
}
override fun onDestroy() {
@@ -117,7 +120,16 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
override fun onOpenReader(state: ReaderState) {
startActivity(ReaderActivity.newIntent(this, state))
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
fab, 0, 0, fab.measuredWidth, fab.measuredHeight
)
} else {
ActivityOptions.makeScaleUpAnimation(
fab, 0, 0, fab.measuredWidth, fab.measuredHeight
)
}
startActivity(ReaderActivity.newIntent(this, state), options?.toBundle())
}
override fun onError(e: Throwable) {

View File

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

View File

@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.ui.main.list
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.core.view.isVisible
import coil.api.clear
import coil.api.load
import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_list_details.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
@@ -15,14 +15,12 @@ import kotlin.math.roundToInt
class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHistory?>(parent, R.layout.item_manga_list_details) {
private var coverRequest: RequestDisposable? = null
@SuppressLint("SetTextI18n")
override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose()
imageView_cover.clear()
textView_title.text = data.title
textView_subtitle.textAndVisible = data.altTitle
coverRequest = imageView_cover.load(data.coverUrl) {
imageView_cover.load(data.coverUrl) {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder)
@@ -37,4 +35,8 @@ class MangaListDetailsHolder(parent: ViewGroup) : BaseViewHolder<Manga, MangaHis
it.title
}
}
override fun onRecycled() {
imageView_cover.clear()
}
}

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.MvpDelegate
@@ -39,7 +40,7 @@ import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), MangaListView<E>,
PaginationScrollListener.Callback, OnRecyclerItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, OnFilterChangedListener,
SectionItemDecoration.Callback {
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
private val settings by inject<AppSettings>()
@@ -58,13 +59,14 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
initListMode(settings.listMode)
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
swipeRefreshLayout.setOnRefreshListener {
onRequestMoreItems(0)
}
swipeRefreshLayout.setOnRefreshListener(this)
recyclerView_filter.setHasFixedSize(true)
recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context))
recyclerView_filter.addItemDecoration(SectionItemDecoration(false, this))
settings.subscribe(this)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onDestroyView() {
@@ -73,13 +75,6 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
super.onDestroyView()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (savedInstanceState?.containsKey(MvpDelegate.MOXY_DELEGATE_TAGS_KEY) != true) {
onRequestMoreItems(0)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_list, menu)
super.onCreateOptionsMenu(menu, inflater)
@@ -122,6 +117,10 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
}
}
final override fun onRefresh() {
onRequestMoreItems(0)
}
override fun onListChanged(list: List<Manga>) {
adapter?.replaceData(list)
if (list.isEmpty()) {
@@ -171,6 +170,7 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
}
@CallSuper
override fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems
@@ -181,7 +181,11 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
}
}
@CallSuper
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (context == null) {
return
}
when (key) {
getString(R.string.key_list_mode) -> initListMode(settings.listMode)
getString(R.string.key_grid_size) -> UiUtils.SpanCountResolver.update(recyclerView)
@@ -229,6 +233,7 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
ListMode.GRID -> GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx))
else -> LinearLayoutManager(ctx)
}
recyclerView.recycledViewPool.clear()
recyclerView.adapter = adapter
recyclerView.addItemDecoration(
when (mode) {
@@ -246,13 +251,13 @@ abstract class MangaListFragment<E> : BaseFragment(R.layout.fragment_list), Mang
recyclerView.firstItem = position
}
override fun isSection(position: Int): Boolean {
final override fun isSection(position: Int): Boolean {
return position == 0 || recyclerView_filter.adapter?.run {
getItemViewType(position) != getItemViewType(position - 1)
} ?: false
}
override fun getSectionTitle(position: Int): CharSequence? {
final override fun getSectionTitle(position: Int): CharSequence? {
return when (recyclerView_filter.adapter?.getItemViewType(position)) {
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre)

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.ui.main.list
import android.view.ViewGroup
import coil.api.clear
import coil.api.load
import coil.request.RequestDisposable
import kotlinx.android.synthetic.main.item_manga_list.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
@@ -13,16 +13,18 @@ import org.koitharu.kotatsu.utils.ext.textAndVisible
class MangaListHolder(parent: ViewGroup) :
BaseViewHolder<Manga, MangaHistory?>(parent, R.layout.item_manga_list) {
private var coverRequest: RequestDisposable? = null
override fun onBind(data: Manga, extra: MangaHistory?) {
coverRequest?.dispose()
imageView_cover.clear()
textView_title.text = data.title
textView_subtitle.textAndVisible = data.tags.joinToString(", ") { it.title }
coverRequest = imageView_cover.load(data.coverUrl) {
imageView_cover.load(data.coverUrl) {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder)
}
}
override fun onRecycled() {
imageView_cover.clear()
}
}

View File

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

View File

@@ -6,11 +6,11 @@ import android.view.MenuItem
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import org.koitharu.kotatsu.ui.main.list.MangaListView
import org.koitharu.kotatsu.ui.main.list.favourites.categories.CategoriesActivity
class FavouritesListFragment : MangaListFragment<Unit>(), MangaListView<Unit>{
class FavouritesListFragment : MangaListFragment<Unit>(), MangaListView<Unit> {
private val presenter by moxyPresenter(factory = ::FavouritesListPresenter)
@@ -19,12 +19,17 @@ class FavouritesListFragment : MangaListFragment<Unit>(), MangaListView<Unit>{
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// inflater.inflate(R.menu.opt_history, menu)
inflater.inflate(R.menu.opt_favourites, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem) = when(item.itemId) {
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_categories -> {
context?.let {
startActivity(CategoriesActivity.newIntent(it))
}
true
}
else -> super.onOptionsItemSelected(item)
}

View File

@@ -0,0 +1,113 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_categories.*
import moxy.ktx.moxyPresenter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.common.dialog.TextInputDialog
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class CategoriesActivity : BaseActivity(), OnRecyclerItemClickListener<FavouriteCategory>,
FavouriteCategoriesView, View.OnClickListener {
private val presenter by moxyPresenter(factory = ::FavouriteCategoriesPresenter)
private lateinit var adapter: CategoriesAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_categories)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
fab_add.imageTintList = ColorStateList.valueOf(Color.WHITE)
adapter = CategoriesAdapter(this)
recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
recyclerView.adapter = adapter
fab_add.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.fab_add -> createCategory()
}
}
override fun onItemClick(item: FavouriteCategory, position: Int, view: View) {
view.showPopupMenu(R.menu.popup_category) {
when (it.itemId) {
R.id.action_remove -> deleteCategory(item)
R.id.action_rename -> renameCategory(item)
}
true
}
}
override fun onCategoriesChanged(categories: List<FavouriteCategory>) {
adapter.replaceData(categories)
textView_holder.isVisible = categories.isEmpty()
}
override fun onCheckedCategoriesChanged(checkedIds: Set<Int>) = Unit
override fun onError(e: Throwable) {
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
.show()
}
private fun deleteCategory(category: FavouriteCategory) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
presenter.deleteCategory(category.id)
}.create()
.show()
}
private fun renameCategory(category: FavouriteCategory) {
TextInputDialog.Builder(this)
.setTitle(R.string.rename)
.setText(category.title)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(12, false)
.setPositiveButton(R.string.rename) { _, name ->
presenter.renameCategory(category.id, name)
}.create()
.show()
}
private fun createCategory() {
TextInputDialog.Builder(this)
.setTitle(R.string.add_new_category)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(12, false)
.setPositiveButton(R.string.add) { _, name ->
presenter.createCategory(name)
}.create()
.show()
}
companion object {
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
}
}

View File

@@ -1,46 +1,17 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories
import android.util.SparseBooleanArray
import android.view.ViewGroup
import android.widget.Checkable
import androidx.core.util.set
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
import org.koitharu.kotatsu.ui.main.list.favourites.categories.select.CategoryCheckableHolder
class CategoriesAdapter(private val listener: OnCategoryCheckListener) :
BaseRecyclerAdapter<FavouriteCategory, Boolean>() {
class CategoriesAdapter(onItemClickListener: OnRecyclerItemClickListener<FavouriteCategory>? = null) :
BaseRecyclerAdapter<FavouriteCategory, Unit>(onItemClickListener) {
private val checkedIds = SparseBooleanArray()
fun setCheckedIds(ids: Iterable<Int>) {
checkedIds.clear()
ids.forEach {
checkedIds[it] = true
}
notifyDataSetChanged()
}
override fun getExtra(item: FavouriteCategory, position: Int) =
checkedIds.get(item.id.toInt(), false)
override fun onCreateViewHolder(parent: ViewGroup) =
CategoryHolder(
parent
)
override fun onCreateViewHolder(parent: ViewGroup) = CategoryHolder(parent)
override fun onGetItemId(item: FavouriteCategory) = item.id
override fun onViewHolderCreated(holder: BaseViewHolder<FavouriteCategory, Boolean>) {
super.onViewHolderCreated(holder)
holder.itemView.setOnClickListener {
if (it !is Checkable) return@setOnClickListener
it.toggle()
if (it.isChecked) {
listener.onCategoryChecked(holder.requireData())
} else {
listener.onCategoryUnchecked(holder.requireData())
}
}
}
override fun getExtra(item: FavouriteCategory, position: Int) = Unit
}

View File

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

View File

@@ -70,10 +70,44 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
}
}
fun addToCategory(manga: Manga, categoryId: Long) {
fun renameCategory(id: Long, name: String) {
presenterScope.launch {
try {
val categories = withContext(Dispatchers.IO) {
repository.renameCategory(id, name)
repository.getAllCategories()
}
viewState.onCategoriesChanged(categories)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
}
}
}
fun deleteCategory(id: Long) {
presenterScope.launch {
try {
val categories = withContext(Dispatchers.IO) {
repository.removeCategory(id)
repository.getAllCategories()
}
viewState.onCategoriesChanged(categories)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
}
}
}
fun addToCategory(manga: Manga, categoryId: Long) {
presenterScope.launch {
try {
withContext(Dispatchers.IO) {
repository.addToCategory(manga,categoryId)
}
} catch (e: Exception) {
@@ -88,7 +122,7 @@ class FavouriteCategoriesPresenter : BasePresenter<FavouriteCategoriesView>() {
fun removeFromCategory(manga: Manga, categoryId: Long) {
presenterScope.launch {
try {
val categories = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
repository.removeFromCategory(manga, categoryId)
}
} catch (e: Exception) {

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories.select
import android.util.SparseBooleanArray
import android.view.ViewGroup
import android.widget.Checkable
import androidx.core.util.set
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class CategoriesSelectAdapter(private val listener: OnCategoryCheckListener) :
BaseRecyclerAdapter<FavouriteCategory, Boolean>() {
private val checkedIds = SparseBooleanArray()
fun setCheckedIds(ids: Iterable<Int>) {
checkedIds.clear()
ids.forEach {
checkedIds[it] = true
}
notifyDataSetChanged()
}
override fun getExtra(item: FavouriteCategory, position: Int) =
checkedIds.get(item.id.toInt(), false)
override fun onCreateViewHolder(parent: ViewGroup) =
CategoryCheckableHolder(
parent
)
override fun onGetItemId(item: FavouriteCategory) = item.id
override fun onViewHolderCreated(holder: BaseViewHolder<FavouriteCategory, Boolean>) {
super.onViewHolderCreated(holder)
holder.itemView.setOnClickListener {
if (it !is Checkable) return@setOnClickListener
it.toggle()
if (it.isChecked) {
listener.onCategoryChecked(holder.requireData())
} else {
listener.onCategoryUnchecked(holder.requireData())
}
}
}
}

View File

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

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories
package org.koitharu.kotatsu.ui.main.list.favourites.categories.select
import android.os.Bundle
import android.text.InputType
@@ -12,6 +12,8 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.common.BaseBottomSheet
import org.koitharu.kotatsu.ui.common.dialog.TextInputDialog
import org.koitharu.kotatsu.ui.main.list.favourites.categories.FavouriteCategoriesPresenter
import org.koitharu.kotatsu.ui.main.list.favourites.categories.FavouriteCategoriesView
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -23,11 +25,13 @@ class FavouriteCategoriesDialog : BaseBottomSheet(R.layout.dialog_favorite_categ
private val manga get() = arguments?.getParcelable<Manga>(ARG_MANGA)
private var adapter: CategoriesAdapter? = null
private var adapter: CategoriesSelectAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = CategoriesAdapter(this)
adapter =
CategoriesSelectAdapter(
this)
recyclerView_categories.adapter = adapter
textView_add.setOnClickListener {
createCategory()
@@ -66,6 +70,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet(R.layout.dialog_favorite_categ
TextInputDialog.Builder(context ?: return)
.setTitle(R.string.add_new_category)
.setHint(R.string.enter_category_name)
.setMaxLength(12, false)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setPositiveButton(R.string.add) { _, name ->
@@ -79,8 +84,10 @@ class FavouriteCategoriesDialog : BaseBottomSheet(R.layout.dialog_favorite_categ
private const val ARG_MANGA = "manga"
private const val TAG = "FavouriteCategoriesDialog"
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog().withArgs(1) {
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog()
.withArgs(1) {
putParcelable(ARG_MANGA, manga)
}.show(fm, TAG)
}.show(fm,
TAG)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.ui.main.list.favourites.categories
package org.koitharu.kotatsu.ui.main.list.favourites.categories.select
import org.koitharu.kotatsu.core.model.FavouriteCategory

View File

@@ -1,14 +1,9 @@
package org.koitharu.kotatsu.ui.main.list.history
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
@@ -53,7 +48,7 @@ class HistoryListFragment : MangaListFragment<MangaHistory>(), MangaListView<Man
}
override fun setUpEmptyListHolder() {
textView_holder.setText(R.string.history_is_empty)
textView_holder.setText(R.string.text_history_holder)
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
import org.koitharu.kotatsu.utils.ShortcutUtils
import org.koitharu.kotatsu.utils.MangaShortcut
@InjectViewState
class HistoryListPresenter : BasePresenter<MangaListView<MangaHistory>>() {
@@ -62,7 +62,7 @@ class HistoryListPresenter : BasePresenter<MangaListView<MangaHistory>>() {
repository.clear()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
ShortcutUtils.clearAppShortcuts(get())
MangaShortcut.clearAppShortcuts(get())
}
viewState.onListChanged(emptyList())
} catch (_: CancellationException) {
@@ -84,7 +84,7 @@ class HistoryListPresenter : BasePresenter<MangaListView<MangaHistory>>() {
repository.delete(manga)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
ShortcutUtils.removeAppShortcut(get(), manga)
MangaShortcut(manga).removeAppShortcut(get())
}
viewState.onItemRemoved(manga)
} catch (_: CancellationException) {

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.ui.main.list.local
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
@@ -14,10 +16,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.ui.main.list.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize
import org.koitharu.kotatsu.utils.ext.showDialog
import java.io.File
class LocalListFragment : MangaListFragment<File>() {
class LocalListFragment : MangaListFragment<File>(), ActivityResultCallback<Uri> {
private val presenter by moxyPresenter(factory = ::LocalListPresenter)
@@ -35,11 +36,9 @@ class LocalListFragment : MangaListFragment<File>() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_import -> {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
try {
startActivityForResult(intent, REQUEST_IMPORT)
registerForActivityResult(ActivityResultContracts.OpenDocument(), this)
.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
@@ -59,17 +58,13 @@ class LocalListFragment : MangaListFragment<File>() {
}
override fun setUpEmptyListHolder() {
textView_holder.setText(R.string.no_saved_manga)
textView_holder.setText(R.string.text_local_holder)
textView_holder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
REQUEST_IMPORT -> if (resultCode == Activity.RESULT_OK) {
val uri = data?.data ?: return
presenter.importFile(context?.applicationContext ?: return, uri)
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
presenter.importFile(context?.applicationContext ?: return, result)
}
}
@@ -81,14 +76,14 @@ class LocalListFragment : MangaListFragment<File>() {
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
return when (item.itemId) {
R.id.action_delete -> {
context?.showDialog {
setTitle(R.string.delete_manga)
setMessage(getString(R.string.text_delete_local_manga, data.title))
setPositiveButton(R.string.delete) { _, _ ->
AlertDialog.Builder(context ?: return false)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, data.title))
.setPositiveButton(R.string.delete) { _, _ ->
presenter.delete(data)
}
setNegativeButton(android.R.string.cancel, null)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
true
}
else -> super.onPopupMenuItemSelected(item, data)

View File

@@ -15,12 +15,13 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.LocalMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.history.HistoryRepository
import org.koitharu.kotatsu.ui.common.BasePresenter
import org.koitharu.kotatsu.ui.main.list.MangaListView
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.ShortcutUtils
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
@@ -64,7 +65,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = context.getExternalFilesDir("manga")?.sub(name)
val dest = get<AppSettings>().getStorageDir(context)?.sub(name)
?: throw IOException("External files dir unavailable")
context.contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
@@ -98,7 +99,7 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
ShortcutUtils.removeAppShortcut(get(), manga)
MangaShortcut(manga).removeAppShortcut(get())
}
viewState.onItemRemoved(manga)
} catch (e: CancellationException) {

View File

@@ -16,7 +16,7 @@ import kotlin.coroutines.CoroutineContext
class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
private val job = SupervisorJob()
private val tasks = HashMap<String, Job>()
private val tasks = HashMap<String, Deferred<File>>()
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
@@ -30,8 +30,13 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
return it
}
}
val task = tasks[url]?.takeUnless { it.isCancelled }
return (task ?: loadAsync(url).also { tasks[url] = it }).await()
}
private fun loadAsync(url: String) = async(Dispatchers.IO) {
val uri = Uri.parse(url)
return if (uri.scheme == "cbz") {
if (uri.scheme == "cbz") {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
@@ -46,13 +51,12 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
.cacheControl(CacheUtils.CONTROL_DISABLED)
.build()
okHttp.newCall(request).await().use { response ->
val body = response.body!!
val type = body.contentType()
check(type?.type == "image") {
"Unexpected content type ${type?.type}/${type?.subtype}"
val body = response.body
checkNotNull(body) {
"Null response"
}
cache.put(url) { out ->
response.body!!.byteStream().copyTo(out)
body.byteStream().copyTo(out)
}
}
}
@@ -60,5 +64,6 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
override fun dispose() {
coroutineContext.cancel()
tasks.clear()
}
}

View File

@@ -4,11 +4,16 @@ import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.core.view.updatePadding
@@ -34,14 +39,15 @@ import org.koitharu.kotatsu.ui.reader.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.ui.reader.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.ui.reader.wetoon.WebtoonReaderFragment
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ShortcutUtils
import org.koitharu.kotatsu.utils.anim.Motion
import org.koitharu.kotatsu.utils.ext.*
class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnChapterChangeListener,
GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback,
ReaderListener, SharedPreferences.OnSharedPreferenceChangeListener {
ReaderListener, SharedPreferences.OnSharedPreferenceChangeListener,
View.OnApplyWindowInsetsListener, ActivityResultCallback<Boolean> {
private val presenter by moxyPresenter(factory = ::ReaderPresenter)
private val settings by inject<AppSettings>()
@@ -64,7 +70,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
toolbar_bottom.inflateMenu(R.menu.opt_reader_bottom)
toolbar_bottom.setOnMenuItemClickListener(::onOptionsItemSelected)
state = savedInstanceState?.getParcelable(EXTRA_STATE)
@Suppress("RemoveExplicitTypeArguments")
state = savedInstanceState?.getParcelable<ReaderState>(EXTRA_STATE)
?: intent.getParcelableExtra<ReaderState>(EXTRA_STATE)
?: let {
Toast.makeText(this, R.string.error_occurred, Toast.LENGTH_SHORT).show()
@@ -78,10 +85,7 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
getString(R.string.chapter_d_of_d, state.chapter?.number ?: 0, size)
}
appbar_bottom.setOnApplyWindowInsetsListener { view, insets ->
view.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
rootLayout.setOnApplyWindowInsetsListener(this)
settings.subscribe(this)
loadSettings()
@@ -91,7 +95,7 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
GlobalScope.launch {
safe {
ShortcutUtils.addAppShortcut(applicationContext, state.manga)
MangaShortcut(state.manga).addAppShortcut(applicationContext)
}
}
}
@@ -179,13 +183,17 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
}
R.id.action_save_page -> {
if (reader?.hasItems == true) {
requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
if (it) {
presenter.savePage(
resolver = contentResolver,
page = reader?.currentPage ?: return@requestPermission
)
}
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
onActivityResult(true)
} else {
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
this
).launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
} else {
showWaitWhileLoading()
@@ -195,6 +203,15 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
else -> super.onOptionsItemSelected(item)
}
override fun onActivityResult(result: Boolean) {
if (result) {
presenter.savePage(
resolver = contentResolver,
page = reader?.currentPage ?: return
)
}
}
override fun saveState(chapterId: Long, page: Int, scroll: Float) {
state = state.copy(chapterId = chapterId, page = page, scroll = scroll)
ReaderPresenter.saveState(state)
@@ -207,16 +224,16 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
}
override fun onError(e: Throwable) {
showDialog {
setTitle(R.string.error_occurred)
setMessage(e.message)
setPositiveButton(R.string.close, null)
if (reader?.hasItems != true) {
setOnDismissListener {
finish()
}
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.error_occurred)
.setMessage(e.message)
.setPositiveButton(R.string.close, null)
if (reader?.hasItems != true) {
dialog.setOnDismissListener {
finish()
}
}
dialog.show()
}
override fun onGridTouch(area: Int) {
@@ -351,6 +368,11 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets {
appbar_top.updatePadding(top = insets.systemWindowInsetTop)
appbar_bottom.updatePadding(bottom = insets.systemWindowInsetBottom)
return insets.consumeSystemWindowInsets()
}
private fun loadSettings() {
settings.readerPageSwitch.let {

View File

@@ -8,6 +8,7 @@ import moxy.presenterScope
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.prefs.ReaderMode
@@ -39,7 +40,7 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
mode = MangaUtils.determineReaderMode(pages)
if (mode != null) {
prefs.savePreferences(
mangaId = manga.id,
manga = manga,
mode = mode
)
}
@@ -49,6 +50,9 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
viewState.onInitReader(manga, mode)
} catch (_: CancellationException) {
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
viewState.onError(e)
} finally {
viewState.onLoadingStateChanged(isLoading = false)
@@ -59,7 +63,7 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
fun setMode(manga: Manga, mode: ReaderMode) {
presenterScope.launch(Dispatchers.IO) {
MangaDataRepository().savePreferences(
mangaId = manga.id,
manga = manga,
mode = mode
)
}

View File

@@ -4,9 +4,11 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BaseActivity
import org.koitharu.kotatsu.ui.settings.MainSettingsFragment
import org.koitharu.kotatsu.ui.settings.NetworkSettingsFragment
import org.koitharu.kotatsu.ui.settings.ReaderSettingsFragment
class SimpleSettingsActivity : BaseActivity() {
@@ -15,10 +17,10 @@ class SimpleSettingsActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings_simple)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val section = intent?.getIntExtra(EXTRA_SECTION, 0)
supportFragmentManager.commit {
replace(R.id.container, when(section) {
SECTION_READER -> ReaderSettingsFragment()
replace(R.id.container, when(intent?.action) {
Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment()
ACTION_READER -> ReaderSettingsFragment()
else -> MainSettingsFragment()
})
}
@@ -26,10 +28,9 @@ class SimpleSettingsActivity : BaseActivity() {
companion object {
private const val EXTRA_SECTION = "section"
private const val SECTION_READER = 1
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
fun newReaderSettingsIntent(context: Context) = Intent(context, SimpleSettingsActivity::class.java)
.putExtra(EXTRA_SECTION, SECTION_READER)
.setAction(ACTION_READER)
}
}

View File

@@ -46,10 +46,6 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = onCreateAdapter(pages)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
@Suppress("RemoveExplicitTypeArguments")
val state = savedInstanceState?.getParcelable<ReaderState>(ARG_STATE)
?: requireArguments().getParcelable<ReaderState>(ARG_STATE)!!

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.ui.reader.standard
import android.view.View
import androidx.viewpager2.widget.ViewPager2
class PageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) {
page.apply {
val pageWidth = width
when {
position < -1 -> alpha = 0f
position <= 0 -> { // [-1,0]
alpha = 1f
translationX = 0f
translationZ = 0f
scaleX = 1 + FACTOR * position
scaleY = 1f
}
position <= 1 -> { // (0,1]
alpha = 1f
translationX = pageWidth * -position
translationZ = -1f
scaleX = 1f
scaleY = 1f
}
else -> alpha = 0f
}
}
}
private companion object {
const val FACTOR = 0.1f
}
}

View File

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

View File

@@ -1,10 +1,14 @@
package org.koitharu.kotatsu.ui.reader.standard
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import kotlinx.android.synthetic.main.fragment_reader_standard.*
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.reader.base.AbstractReader
import org.koitharu.kotatsu.ui.reader.base.BaseReaderAdapter
@@ -12,19 +16,34 @@ import org.koitharu.kotatsu.ui.reader.base.GroupedList
import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.withArgs
class PagerReaderFragment : AbstractReader(R.layout.fragment_reader_standard) {
class PagerReaderFragment : AbstractReader(R.layout.fragment_reader_standard),
SharedPreferences.OnSharedPreferenceChangeListener {
private var paginationListener: PagerPaginationListener? = null
private val settings by inject<AppSettings>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
paginationListener = PagerPaginationListener(adapter!!, 2, this)
pager.adapter = adapter
if (settings.readerAnimation) {
pager.setPageTransformer(PageAnimTransformer())
}
pager.offscreenPageLimit = 2
pager.registerOnPageChangeCallback(paginationListener!!)
pager.doOnPageChanged(::notifyPageChanged)
}
override fun onAttach(context: Context) {
super.onAttach(context)
settings.subscribe(this)
}
override fun onDetach() {
settings.unsubscribe(this)
super.onDetach()
}
override fun onDestroyView() {
paginationListener = null
super.onDestroyView()
@@ -47,6 +66,18 @@ class PagerReaderFragment : AbstractReader(R.layout.fragment_reader_standard) {
override fun restorePageScroll(position: Int, scroll: Float) = Unit
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
getString(R.string.key_reader_animation) -> {
if (settings.readerAnimation) {
pager.setPageTransformer(PageAnimTransformer())
} else {
pager.setPageTransformer(null)
}
}
}
}
companion object {
fun newInstance(state: ReaderState) = PagerReaderFragment().withArgs(1) {

View File

@@ -1,27 +1,26 @@
package org.koitharu.kotatsu.ui.reader.thumbnails
import android.view.ViewGroup
import androidx.core.net.toUri
import coil.Coil
import coil.api.get
import coil.request.GetRequestBuilder
import coil.size.PixelSize
import coil.size.Size
import kotlinx.android.synthetic.main.item_page_thumb.*
import kotlinx.coroutines.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class PageThumbnailHolder(parent: ViewGroup, private val scope: CoroutineScope) :
BaseViewHolder<MangaPage, Unit>(parent, R.layout.item_page_thumb) {
BaseViewHolder<MangaPage, PagesCache>(parent, R.layout.item_page_thumb) {
private var job: Job? = null
private val thumbSize: Size
init {
// FIXME
// val color = DrawUtils.invertColor(textView_number.currentTextColor)
// textView_number.setShadowLayer(parent.resources.resolveDp(26f), 0f, 0f, color)
val width = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
thumbSize = PixelSize(
width = width,
@@ -29,18 +28,20 @@ class PageThumbnailHolder(parent: ViewGroup, private val scope: CoroutineScope)
)
}
override fun onBind(data: MangaPage, extra: Unit) {
override fun onBind(data: MangaPage, extra: PagesCache) {
imageView_thumb.setImageDrawable(null)
textView_number.text = (adapterPosition + 1).toString()
textView_number.text = (bindingAdapterPosition + 1).toString()
job?.cancel()
job = scope.launch(Dispatchers.IO) {
try {
val url = data.preview ?: data.url.let {
MangaProviderFactory.create(data.source).getPageFullUrl(data)
}
val drawable = Coil.get(url) {
size(thumbSize)
val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
extra[pageUrl]?.toUri()?.toString() ?: pageUrl
}
val drawable = Coil.execute(GetRequestBuilder(context)
.data(url)
.size(thumbSize)
.build()).drawable
withContext(Dispatchers.Main) {
imageView_thumb.setImageDrawable(drawable)
}
@@ -50,4 +51,9 @@ class PageThumbnailHolder(parent: ViewGroup, private val scope: CoroutineScope)
}
}
}
override fun onRecycled() {
job?.cancel()
imageView_thumb.setImageDrawable(null)
}
}

View File

@@ -5,15 +5,18 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.SupervisorJob
import org.koin.core.inject
import org.koitharu.kotatsu.core.local.PagesCache
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.common.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import kotlin.coroutines.CoroutineContext
class PagesThumbnailsAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaPage>?) :
BaseRecyclerAdapter<MangaPage, Unit>(onItemClickListener), CoroutineScope, DisposableHandle {
BaseRecyclerAdapter<MangaPage, PagesCache>(onItemClickListener), CoroutineScope, DisposableHandle {
private val job = SupervisorJob()
private val cache by inject<PagesCache>()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
@@ -22,7 +25,7 @@ class PagesThumbnailsAdapter(onItemClickListener: OnRecyclerItemClickListener<Ma
job.cancel()
}
override fun getExtra(item: MangaPage, position: Int) = Unit
override fun getExtra(item: MangaPage, position: Int) = cache
override fun onCreateViewHolder(parent: ViewGroup) = PageThumbnailHolder(parent, this)

View File

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

View File

@@ -6,10 +6,11 @@ import androidx.core.net.toUri
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.android.synthetic.main.item_page.*
import kotlinx.android.synthetic.main.item_page_webtoon.*
import kotlinx.coroutines.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.reader.PageLoader
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -42,7 +43,8 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
ssiv.recycle()
try {
val uri = withContext(Dispatchers.IO) {
loader.loadFile(data.url, force)
val pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
loader.loadFile(pageUrl, force)
}.toUri()
ssiv.setImage(ImageSource.uri(uri))
} catch (e: CancellationException) {
@@ -53,6 +55,11 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
}
}
override fun onRecycled() {
job?.cancel()
ssiv.recycle()
}
fun getScrollY() = ssiv.center?.y ?: 0f
fun restoreScroll(scroll: Float) {

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.ui.reader.wetoon
import android.content.Context
import android.graphics.PointF
import android.graphics.RectF
import android.util.AttributeSet
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.utils.ext.toIntUp
class WebtoonImageView : SubsamplingScaleImageView {
constructor(context: Context?) : super(context)
constructor(context: Context?, attr: AttributeSet?) : super(context, attr)
private val pan = RectF()
private val ct = PointF()
fun dispatchVerticalScroll(dy: Int): Int {
if (!isReady) {
return 0
}
getPanRemaining(pan)
// pan.offset(0f, -nonConsumedScroll.toFloat())
ct.set(width / 2f, height / 2f)
viewToSourceCoord(ct.x, ct.y, ct) ?: return 0
val s = scale
return when {
dy > 0 -> {
val delta = minOf(pan.bottom.toIntUp(), dy)
ct.offset(0f, delta.toFloat() / s)
setScaleAndCenter(s, ct)
delta
}
dy < 0 -> {
val delta = minOf(pan.top.toInt(), -dy)
ct.offset(0f, -delta.toFloat() / s)
setScaleAndCenter(s, ct)
-delta
}
else -> 0
}
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.ui.reader.wetoon
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.fragment_reader_webtoon.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
@@ -12,7 +11,7 @@ import org.koitharu.kotatsu.ui.reader.base.AbstractReader
import org.koitharu.kotatsu.ui.reader.base.BaseReaderAdapter
import org.koitharu.kotatsu.ui.reader.base.GroupedList
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findMiddleVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstItem
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -43,7 +42,7 @@ class WebtoonReaderFragment : AbstractReader(R.layout.fragment_reader_webtoon) {
get() = adapter?.itemCount ?: 0
override fun getCurrentItem(): Int {
return (recyclerView.layoutManager as LinearLayoutManager).findMiddleVisibleItemPosition()
return recyclerView.findCenterViewPosition()
}
override fun setCurrentItem(position: Int, isSmooth: Boolean) {

View File

@@ -2,16 +2,27 @@ package org.koitharu.kotatsu.ui.reader.wetoon
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign
class WebtoonRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, ViewCompat.TYPE_TOUCH)
override fun startNestedScroll(axes: Int, type: Int): Boolean {
return true
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH)
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
@@ -20,21 +31,11 @@ class WebtoonRecyclerView @JvmOverloads constructor(
type: Int
): Boolean {
val consumedY = consumeVerticalScroll(dy)
val superRes = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
consumed?.set(1, consumed[1] + consumedY)
return superRes || consumedY != 0
}
override fun dispatchNestedPreScroll(
dx: Int,
dy: Int,
consumed: IntArray?,
offsetInWindow: IntArray?
): Boolean {
val consumedY = consumeVerticalScroll(dy)
val superRes = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow)
consumed?.set(1, consumed[1] + consumedY)
return superRes || consumedY != 0
if (consumed != null) {
consumed[0] = 0
consumed[1] = consumedY
}
return consumedY != 0
}
private fun consumeVerticalScroll(dy: Int): Int {

View File

@@ -1,14 +1,17 @@
package org.koitharu.kotatsu.ui.settings
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -21,8 +24,18 @@ import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.ui.common.BaseService
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
class AppUpdateService : BaseService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -80,22 +93,37 @@ class AppUpdateService : BaseService() {
PendingIntent.FLAG_CANCEL_CURRENT
)
)
builder.addAction(
R.drawable.ic_download, getString(R.string.download),
PendingIntent.getActivity(
this,
NOTIFICATION_ID + 1,
Intent(Intent.ACTION_VIEW, Uri.parse(newVersion.apkUrl)),
PendingIntent.FLAG_CANCEL_CURRENT
)
)
builder.setSmallIcon(R.drawable.ic_stat_update)
builder.setAutoCancel(true)
builder.color = ContextCompat.getColor(this, R.color.blue_primary_dark)
builder.setLargeIcon(BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher))
manager.notify(NOTIFICATION_ID, builder.build())
}
companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private const val NOTIFICATION_ID = 202
private const val CHANNEL_ID = "update"
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun start(context: Context) =
context.startService(Intent(context, AppUpdateService::class.java))
fun isUpdateSupported(context: Context): Boolean {
return getCertificateSHA1Fingerprint(context) == CERT_SHA1
}
fun startIfRequired(context: Context) {
if (!isUpdateSupported(context)) {
return
}
val settings = AppSettings(context)
if (settings.appUpdateAuto) {
val lastUpdate = settings.appUpdate
@@ -104,5 +132,47 @@ class AppUpdateService : BaseService() {
}
}
}
private fun start(context: Context) {
try {
context.startService(Intent(context, AppUpdateService::class.java))
} catch (_: IllegalStateException) {
}
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(context: Context): String? {
val packageInfo = try {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNATURES
)
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
return null
}
val signatures = packageInfo?.signatures
val cert: ByteArray = signatures?.firstOrNull()?.toByteArray() ?: return null
val input: InputStream = ByteArrayInputStream(cert)
val c = try {
val cf = CertificateFactory.getInstance("X509")
cf.generateCertificate(input) as X509Certificate
} catch (e: CertificateException) {
e.printStackTrace()
return null
}
return try {
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.getEncoded())
publicKey.byte2HexFormatted()
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
null
} catch (e: CertificateEncodingException) {
e.printStackTrace()
null
}
}
}
}

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.ui.settings
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arrayMapOf
@@ -13,11 +16,17 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
import org.koitharu.kotatsu.ui.common.dialog.StorageSelectDialog
import org.koitharu.kotatsu.ui.main.list.ListModeSelectDialog
import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener {
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main)
@@ -32,21 +41,28 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
}
findPreference<MultiSelectListPreference>(R.string.key_reader_switchers)?.summaryProvider =
MultiSummaryProvider(R.string.gestures_only)
findPreference<PreferenceScreen>(R.string.key_remote_sources)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_from_d, total - settings.hiddenSources.size, total
)
findPreference<Preference>(R.string.key_app_update_auto)?.run {
isVisible = AppUpdateService.isUpdateSupported(context)
}
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary =
LIST_MODES[settings.listMode]?.let(::getString)
getString(R.string.key_theme) -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
getString(R.string.key_local_storage) -> {
findPreference<Preference>(R.string.key_local_storage)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
}
}
}
@@ -60,16 +76,50 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
super.onDestroyView()
}
override fun onResume() {
super.onResume()
findPreference<PreferenceScreen>(R.string.key_remote_sources)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_from_d, total - settings.hiddenSources.size, total
)
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
getString(R.string.key_list_mode) -> {
ListModeSelectDialog.show(childFragmentManager)
true
}
getString(R.string.key_notifications_settings) -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
startActivity(intent)
} else {
(activity as? SettingsActivity)?.openNotificationSettingsLegacy()
}
true
}
getString(R.string.key_local_storage) -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx),this)
.setTitle(preference.title)
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.setStorageDir(context ?: return, file)
}
private companion object {
val LIST_MODES = arrayMapOf(

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.ui.settings
import android.os.Bundle
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
class NetworkSettingsFragment : BasePreferenceFragment(R.string.settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
//TODO https://developer.android.com/training/basics/network-ops/managing
}
}

View File

@@ -0,0 +1,66 @@
package org.koitharu.kotatsu.ui.settings
import android.app.Activity
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.preference.Preference
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
import org.koitharu.kotatsu.utils.ext.toUriOrNull
class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_notifications)
findPreference<Preference>(R.string.key_notifications_sound)?.run {
val uri = settings.notificationSound.toUriOrNull()
summary = RingtoneManager.getRingtone(context, uri).getTitle(context)
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
getString(R.string.key_notifications_sound) -> {
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE,
RingtoneManager.TYPE_NOTIFICATION)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, preference.title)
val existingValue = settings.notificationSound.toUriOrNull()
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existingValue)
startActivityForResult(intent, REQUEST_RINGTONE)
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_RINGTONE -> {
if (resultCode == Activity.RESULT_OK) {
val uri =
data?.getParcelableExtra<Uri>(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
settings.notificationSound = uri?.toString().orEmpty()
findPreference<Preference>(R.string.key_notifications_sound)?.run {
summary = RingtoneManager.getRingtone(context, uri).getTitle(context)
}
}
}
else -> {
super.onActivityResult(requestCode, resultCode, data)
}
}
}
private companion object {
const val REQUEST_RINGTONE = 340
}
}

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