Compare commits

..

50 Commits

Author SHA1 Message Date
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
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
450daf17fd Fix foreign key error 2020-03-29 17:57:04 +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
7ee486e4f2 Fix version code 2020-03-29 10:58:24 +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
1314c601b2 Simplify settings 2020-03-28 19:40:43 +02:00
c5970c5606 Confirm large manga downloading 2020-03-28 19:01:50 +02:00
85b18d118b Store page scroll position in history 2020-03-28 18:49:01 +02:00
e7a150bd9a Replace history with remote manga when delete local 2020-03-28 12:46:57 +02:00
2c66edda68 Update database schema: foreign keys and indices 2020-03-28 12:28:03 +02:00
1a93cc228d Update dependencies 2020-03-23 18:14:27 +02:00
798ae6aeb7 Fix pages max scale 2020-03-22 17:35:42 +02:00
418d0247f5 Option to hide manga source 2020-03-19 16:52:16 +02:00
db0ee268f9 Browser activity 2020-03-19 14:31:25 +02:00
032d671c38 Add Chucker for debug 2020-03-19 13:39:19 +02:00
127978d3d7 Decrease app update checking interval 2020-03-19 12:53:14 +02:00
fddc3e41cf Remove shortcut if local manga removed 2020-03-19 12:47:36 +02:00
e0e6f0dab4 DesuMe parser 2020-03-18 20:33:57 +02:00
beaa825a9f Add UserAgent 2020-03-18 19:22:10 +02:00
cae27dda05 Fix all parsers issues 2020-03-18 19:12:27 +02:00
0d041e9a0a Fix appending saved chapters 2020-03-17 18:15:15 +02:00
151 changed files with 2461 additions and 737 deletions

120
.idea/codeStyles generated
View File

@@ -1,120 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectCodeStyleConfiguration">
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
<code_scheme name="Project" version="173">
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
</code_scheme>
</component>
</project>

187
.idea/codeStyles/Project.xml generated Executable file
View File

@@ -0,0 +1,187 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="OTHER_INDENT_OPTIONS">
<value>
<option name="USE_TAB_CHARACTER" value="true" />
</value>
</option>
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
<option name="MANIFEST_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
<option name="OTHER_SETTINGS">
<value>
<option name="INSERT_LINE_BREAK_BEFORE_NAMESPACE_DECLARATION" value="true" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="CMake">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Groovy">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JSON">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Executable file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

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>

View File

@@ -1,6 +1,8 @@
<component name="ProjectDictionaryState">
<dictionary name="admin">
<words>
<w>chucker</w>
<w>desu</w>
<w>koin</w>
<w>kotatsu</w>
<w>manga</w>

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

@@ -21,5 +21,15 @@
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://dl.bintray.com/kotlin/kotlin-eap" />
</remote-repository>
</component>
</project>

View File

@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
def gitCommits = 'git rev-list --all --count'.execute([], rootDir).text.trim().toInteger()
def gitCommits = 'git rev-list --count HEAD'.execute([], rootDir).text.trim().toInteger()
def gitBranch = 'git branch --show-current'.execute([], rootDir).text.trim()
android {
@@ -15,7 +15,7 @@ android {
minSdkVersion 21
targetSdkVersion 29
versionCode gitCommits
versionName '0.1'
versionName '0.2-b1'
buildConfigField 'String', 'GIT_BRANCH', "\"${gitBranch}\""
@@ -61,35 +61,40 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'androidx.core:core-ktx:1.3.0-alpha02'
implementation 'androidx.fragment:fragment-ktx:1.2.2'
implementation 'androidx.appcompat:appcompat:1.2.0-alpha03'
implementation 'androidx.core:core-ktx:1.3.0-beta01'
implementation 'androidx.fragment:fragment-ktx:1.2.4'
implementation 'androidx.appcompat:appcompat:1.2.0-beta01'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.work:work-runtime-ktx:2.3.4'
implementation 'com.google.android.material:material:1.2.0-alpha05'
implementation 'androidx.room:room-runtime:2.2.4'
implementation 'androidx.room:room-ktx:2.2.4'
kapt 'androidx.room:room-compiler:2.2.4'
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'
implementation 'com.github.moxy-community:moxy:2.1.1'
implementation 'com.github.moxy-community:moxy-androidx:2.1.1'
implementation 'com.github.moxy-community:moxy-material:2.1.1'
implementation 'com.github.moxy-community:moxy-ktx:2.1.1'
kapt 'com.github.moxy-community:moxy-compiler:2.1.1'
implementation 'com.github.moxy-community:moxy:2.1.2'
implementation 'com.github.moxy-community:moxy-androidx:2.1.2'
implementation 'com.github.moxy-community:moxy-material:2.1.2'
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.0'
implementation 'com.squareup.okio:okio:2.4.3'
implementation 'org.jsoup:jsoup:1.12.2'
implementation 'com.squareup.okhttp3:okhttp:4.5.0'
implementation 'com.squareup.okio:okio:2.5.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.koin:koin-android:2.1.3'
implementation 'org.koin:koin-android:2.1.4'
implementation 'io.coil-kt:coil:0.9.5'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0'
implementation 'com.tomclaw.cache:cache:1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.2'
debugImplementation 'com.github.ChuckerTeam.Chucker:library:3.1.2'
releaseImplementation 'com.github.ChuckerTeam.Chucker:library-no-op:3.1.2'
testImplementation 'junit:junit:4.13'
testImplementation 'org.json:json:20190722'
}

1
app/libs/.gitkeep Normal file
View File

@@ -0,0 +1 @@

Binary file not shown.

View File

@@ -1,13 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" />
<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"
@@ -18,7 +21,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute">
<activity android:name=".ui.main.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -43,6 +47,12 @@
<activity
android:name=".ui.reader.SimpleSettingsActivity"
android:label="@string/settings" />
<activity android:name=".ui.browser.BrowserActivity" />
<activity
android:name=".ui.utils.CrashActivity"
android:label="@string/error_occurred"
android:theme="@android:style/Theme.DeviceDefault.Dialog"
android:windowSoftInputMode="stateAlwaysHidden" />
<service
android:name=".ui.download.DownloadService"
@@ -51,7 +61,8 @@
<provider
android:name=".ui.search.MangaSuggestionsProvider"
android:authorities="${applicationId}.MangaSuggestionsProvider" />
android:authorities="${applicationId}.MangaSuggestionsProvider"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"

View File

@@ -6,20 +6,26 @@ import androidx.room.Room
import coil.Coil
import coil.ImageLoader
import coil.util.CoilUtils
import com.itkacher.okhttpprofiler.OkHttpProfilerInterceptor
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.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
import org.koitharu.kotatsu.core.local.cookies.cache.SetCookieCache
import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePersistor
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaLoaderContext
import org.koitharu.kotatsu.ui.utils.AppCrashHandler
import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit
@@ -29,10 +35,18 @@ class KotatsuApp : Application() {
PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(applicationContext))
}
private val chuckerCollector by lazy(LazyThreadSafetyMode.NONE) {
ChuckerCollector(applicationContext)
}
override fun onCreate() {
super.onCreate()
initKoin()
initCoil()
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
if (BuildConfig.DEBUG) {
initErrorHandler()
}
AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme)
}
@@ -77,13 +91,22 @@ class KotatsuApp : Application() {
})
}
private fun initErrorHandler() {
val exceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
chuckerCollector.onError("CRASH", e)
exceptionHandler?.uncaughtException(t, e)
}
}
private fun okHttp() = OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
addInterceptor(UserAgentInterceptor)
if (BuildConfig.DEBUG) {
addInterceptor(OkHttpProfilerInterceptor())
addInterceptor(ChuckerInterceptor(applicationContext, collector = chuckerCollector))
}
}
@@ -91,5 +114,5 @@ class KotatsuApp : Application() {
applicationContext,
MangaDatabase::class.java,
"kotatsu-db"
)
).addMigrations(Migration1To2, Migration2To3, Migration3To4)
}

View File

@@ -3,6 +3,7 @@ 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 {
@@ -11,6 +12,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?

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?
@@ -24,19 +28,20 @@ abstract class HistoryDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(mangaId: Long, page: Int, chapterId: Long, updatedAt: Long): Int
@Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(mangaId: Long, page: Int, chapterId: Long, scroll: Float, updatedAt: Long): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.updatedAt)
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 = 1
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

@@ -2,10 +2,26 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(tableName = "favourites", primaryKeys = ["manga_id", "category_id"])
@Entity(
tableName = "favourites", primaryKeys = ["manga_id", "category_id"], foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = FavouriteCategoryEntity::class,
parentColumns = ["category_id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class FavouriteEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "category_id") val categoryId: Long,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long
)

View File

@@ -2,24 +2,36 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity(tableName = "history")
@Entity(
tableName = "history", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float
) {
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page
)
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll
)
}

View File

@@ -2,9 +2,18 @@ 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 = "preferences")
@Entity(
tableName = "preferences", foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)]
)
data class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,

View File

@@ -2,9 +2,25 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"])
@Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE
)
]
)
data class MangaTagsEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "tag_id") val tagId: Long
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
)

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,54 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration1To2 : Migration(1, 2) {
/**
* Adding foreign keys
*/
override fun migrate(database: SupportSQLiteDatabase) {
/* manga_tags */
database.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */
database.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
database.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */
database.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
database.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */
database.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE preferences")
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
object Migration2To3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
}
}

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

@@ -15,6 +15,7 @@ import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun fetch(
pool: BitmapPool,
data: Uri,

View File

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

View File

@@ -41,27 +41,24 @@ class SharedPrefsCookiePersistor(private val sharedPreferences: SharedPreference
return cookies
}
@SuppressLint("ApplySharedPref")
override fun saveAll(cookies: Collection<Cookie>) {
val editor = sharedPreferences.edit()
for (cookie in cookies) {
editor.putString(createCookieKey(cookie), SerializableCookie().encode(cookie))
}
editor.commit()
editor.apply()
}
@SuppressLint("ApplySharedPref")
override fun removeAll(cookies: Collection<Cookie>) {
val editor = sharedPreferences.edit()
for (cookie in cookies) {
editor.remove(createCookieKey(cookie))
}
editor.commit()
editor.apply()
}
@SuppressLint("ApplySharedPref")
override fun clear() {
sharedPreferences.edit().clear().commit()
sharedPreferences.edit().clear().apply()
}
private companion object {

View File

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

View File

@@ -18,6 +18,7 @@ enum class MangaSource(
MINTMANGA("MintManga", "ru", MintMangaRepository::class.java),
SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java),
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java),
DESUME("Desu.me", "ru", DesuMeRepository::class.java),
HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java),
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::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

@@ -38,6 +38,7 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
getFromFile(Uri.parse(manga.url).toFile())
} else manga
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val file = Uri.parse(chapter.url).toFile()
val zip = ZipFile(file)
@@ -104,6 +105,16 @@ class LocalMangaRepository : MangaRepository, KoinComponent {
}
}
fun getRemoteManga(localManga: Manga): Manga? {
val file = safe {
Uri.parse(localManga.url).toFile()
} ?: return null
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return null
return index.getMangaInfo()
}
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint
import android.os.Build
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import java.util.*
@SuppressLint("ConstantLocale")
object UserAgentInterceptor : Interceptor {
private val userAgent = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language
)
override fun intercept(chain: Interceptor.Chain) = chain.proceed(
chain.request().newBuilder()
.header("User-Agent", userAgent)
.build()
)
}

View File

@@ -93,7 +93,7 @@ abstract class ChanRepository : RemoteMangaRepository() {
val json = data.substring(pos).substringAfter('[').substringBefore(';')
.substringBeforeLast(']')
return json.split(",").mapNotNull {
it.trim().removeSurrounding('"').takeUnless(String::isBlank)
it.trim().removeSurrounding('"','\'').takeUnless(String::isBlank)
}.map { url ->
MangaPage(
id = url.longHashCode(),

View File

@@ -0,0 +1,137 @@
package org.koitharu.kotatsu.core.parser.site
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.*
class DesuMeRepository : RemoteMangaRepository() {
override val source = MangaSource.DESUME
override val sortOrders = setOf(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL
)
override suspend fun getList(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
): List<Manga> {
val domain = conf.getDomain(DOMAIN)
val url = buildString {
append("https://")
append(domain)
append("/manga/api/?limit=20&order=")
append(getSortKey(sortOrder))
append("&page=")
append((offset / 20) + 1)
if (tag != null) {
append("&genres=")
append(tag.key)
}
if (query != null) {
append("&search=")
append(query)
}
}
val json = loaderContext.httpGet(url).parseJson().getJSONArray("response")
?: throw ParseException("Invalid response")
val total = json.length()
val list = ArrayList<Manga>(total)
for (i in 0 until total) {
val jo = json.getJSONObject(i)
val cover = jo.getJSONObject("image")
list += Manga(
url = jo.getString("url"),
source = MangaSource.DESUME,
title = jo.getString("russian"),
altTitle = jo.getString("name"),
coverUrl = cover.getString("preview"),
largeCoverUrl = cover.getString("original"),
state = when {
jo.getInt("ongoing") == 1 -> MangaState.ONGOING
else -> null
},
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
id = ID_MASK + jo.getLong("id"),
description = jo.getString("description")
)
}
return list
}
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(DOMAIN)
val url = "https://$domain/manga/api/${manga.id - ID_MASK}"
val json = loaderContext.httpGet(url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response")
return manga.copy(
tags = json.getJSONArray("genres").map {
MangaTag(
key = it.getString("text"),
title = it.getString("russian"),
source = manga.source
)
}.toSet(),
description = json.getString("description"),
chapters = json.getJSONObject("chapters").getJSONArray("list").mapIndexed { i, it ->
val chid = it.getLong("id")
MangaChapter(
id = ID_MASK + chid,
source = manga.source,
url = "$url/chapter/$chid",
name = it.optString("title", "${manga.title} #${it.getDouble("ch")}"),
number = i + 1
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val json = loaderContext.httpGet(chapter.url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response")
return json.getJSONObject("pages").getJSONArray("list").map {
MangaPage(
id = it.getLong("id"),
source = chapter.source,
url = it.getString("img")
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val domain = conf.getDomain(DOMAIN)
val doc = loaderContext.httpGet("https://$domain/manga/").parseHtml()
val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres")
return root.select("li").map {
MangaTag(
source = source,
key = it.selectFirst("input").attr("data-genre"),
title = it.selectFirst("label").text()
)
}.toSet()
}
override fun onCreatePreferences() = setOf(R.string.key_parser_domain)
private fun getSortKey(sortOrder: SortOrder?) =
when (sortOrder) {
SortOrder.ALPHABETICAL -> "name"
SortOrder.POPULARITY -> "popular"
SortOrder.UPDATED -> "updated"
SortOrder.NEWEST -> "id"
else -> "updated"
}
private companion object {
private const val ID_MASK = 1000
private const val DOMAIN = "desu.me"
}
}

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
abstract class GroupleRepository : RemoteMangaRepository() {
abstract class GroupleRepository : RemoteMangaRepository() {
protected abstract val defaultDomain: String
@@ -20,7 +20,7 @@ abstract class GroupleRepository : RemoteMangaRepository() {
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tag: MangaTag?,
): List<Manga> {
val domain = conf.getDomain(defaultDomain)
val doc = when {
@@ -28,7 +28,8 @@ 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 +86,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

@@ -4,6 +4,7 @@ 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.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain
@@ -18,22 +19,27 @@ class HenChanRepository : ChanRepository() {
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val readLink = manga.url.replace("manga", "online")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.attr("src")?.withDomain(domain),
chapters = root.getElementById("right").select("table.table_cha").flatMap { table ->
table.select("div.manga2")
}.mapNotNull { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.attr("href")
?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.text().trim(),
number = i + 1,
url = href,
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.map {
val a = it.children().last()
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
}
}?.toSet() ?: manga.tags,
chapters = listOf(
MangaChapter(
id = readLink.longHashCode(),
url = readLink,
source = source,
number = 1,
name = manga.title
)
)
)
}
}

View File

@@ -1,9 +1,39 @@
package org.koitharu.kotatsu.core.parser.site
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.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.parseHtml
import org.koitharu.kotatsu.utils.ext.withDomain
class YaoiChanRepository : ChanRepository() {
override val source = MangaSource.YAOICHAN
override val defaultDomain = "yaoi-chan.me"
override suspend fun getDetails(manga: Manga): Manga {
val domain = conf.getDomain(defaultDomain)
val doc = loaderContext.httpGet(manga.url).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.attr("src")?.withDomain(domain),
chapters = root.select("table.table_cha").flatMap { table ->
table.select("div.manga")
}.mapNotNull { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.attr("href")
?.withDomain(domain) ?: return@mapIndexedNotNull null
MangaChapter(
id = href.longHashCode(),
name = a.text().trim(),
number = i + 1,
url = href,
source = source
)
}
)
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import org.koitharu.kotatsu.R
@@ -52,6 +53,26 @@ 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
)
private var sourcesOrderStr by NullableStringPreferenceDelegate(resources.getString(R.string.key_sources_order))
var sourcesOrder: List<Int>
@@ -60,6 +81,8 @@ class AppSettings private constructor(resources: Resources, private val prefs: S
sourcesOrderStr = value.joinToString("|")
}
var hiddenSources by StringSetPreferenceDelegate(resources.getString(R.string.key_sources_hidden))
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain
import androidx.room.withTransaction
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -13,24 +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) {
db.mangaDao().upsert(MangaEntity.from(manga), manga.tags.map(TagEntity.Companion::fromMangaTag))
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
}
}
}

View File

@@ -9,15 +9,23 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
object MangaProviderFactory : KoinComponent {
val sources: List<MangaSource>
get() {
val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = get<AppSettings>().sourcesOrder
return list.sortedBy { x ->
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
fun getSources(includeHidden: Boolean): List<MangaSource> {
val settings = get<AppSettings>()
val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = settings.sourcesOrder
val hidden = settings.hiddenSources
val sorted = list.sortedBy { x ->
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
}
return if(includeHidden) {
sorted
} else {
sorted.filterNot { x ->
x.name in hidden
}
}
}
fun createLocal() = LocalMangaRepository()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.favourites
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
@@ -18,17 +19,17 @@ 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, "created_at")
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()
}
@@ -38,25 +39,27 @@ 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 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.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao().add(entity)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
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

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.domain.history
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
@@ -10,6 +11,7 @@ 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 {
@@ -17,47 +19,65 @@ class HistoryRepository : KoinComponent {
private val db: MangaDatabase by inject()
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) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Float) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao().upsert(tags)
db.mangaDao().upsert(MangaEntity.from(manga), tags)
db.historyDao().upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page
)
)
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
if (db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll
)
)
) {
TrackingRepository().insertOrNothing(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),
chapterId = it.chapterId,
page = it.page
page = it.page,
scroll = it.scroll
)
}
}
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()
}
/**
* Try to replace one manga with another one
* 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)
notifyHistoryChanged()
}
}
companion object {
private val listeners = HashSet<OnHistoryChangeListener>()

View File

@@ -14,7 +14,7 @@ class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga) {
fun setMangaInfo(manga: Manga, append: Boolean) {
json.put("id", manga.id)
json.put("title", manga.title)
json.put("title_alt", manga.altTitle)
@@ -32,7 +32,9 @@ class MangaIndex(source: String?) {
a.put(jo)
}
})
json.put("chapters", JSONObject())
if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject())
}
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
}

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
@@ -17,11 +17,12 @@ class MangaZip(val file: File) {
private val dir = file.parentFile?.sub(file.name + ".tmp")?.takeIf { it.mkdir() }
?: throw RuntimeException("Cannot create temporary directory")
private val index = MangaIndex(dir.sub(INDEX_ENTRY).takeIfReadable()?.readText())
private var index = MangaIndex(null)
fun prepare(manga: Manga) {
extract()
index.setMangaInfo(manga)
index = MangaIndex(dir.sub(INDEX_ENTRY).takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
fun cleanup() {
@@ -90,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 insertOrNothing(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.insert(entity)
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.ui.browser
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.activity_browser.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BaseActivity
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity(), BrowserCallback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_browser)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_cross)
}
with(webView.settings) {
javaScriptEnabled = true
}
webView.webViewClient = BrowserClient(this)
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finish()
} else {
webView.loadUrl(url)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
webView.stopLoading()
finish()
true
}
R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(webView.url)
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
override fun onPause() {
webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
webView.onResume()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
companion object {
@JvmStatic
fun newIntent(context: Context, url: String) = Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.ui.browser
interface BrowserCallback {
fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.ui.browser
import android.graphics.Bitmap
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.utils.ext.safe
class BrowserClient(private val callback: BrowserCallback) : WebViewClient(), KoinComponent {
private val okHttp by inject<OkHttpClient>()
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
callback.onLoadingStateChanged(isLoading = true)
}
override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title, url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?) = false
override fun shouldOverrideUrlLoading(view: WebView, url: String) = false
override fun shouldInterceptRequest(view: WebView?, url: String?): WebResourceResponse? {
return url?.let(::doRequest)
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
return request?.url?.toString()?.let(::doRequest)
}
private fun doRequest(url: String): WebResourceResponse? = safe {
val request = Request.Builder()
.url(url)
.build()
val response = okHttp.newCall(request).execute()
val ct = response.body?.contentType()
WebResourceResponse(
"${ct?.type}/${ct?.subtype}",
ct?.charset()?.name() ?: "utf-8",
response.body?.byteStream()
)
}
}

View File

@@ -11,7 +11,6 @@ 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 {
@@ -70,7 +69,7 @@ abstract class BaseActivity : MvpAppCompatActivity(), KoinComponent {
return true
}
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
StorageSelectDialog.Builder(this).create().show()
throw StackOverflowError("test")
return true
}
return super.onKeyDown(keyCode, event)

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

@@ -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

@@ -40,5 +40,7 @@ abstract class BaseViewHolder<T, E> protected constructor(view: View) :
return this
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E)
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.ui.common.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.Checkable
import androidx.appcompat.widget.AppCompatImageView
class CheckableImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr), Checkable {
private var isCheckedInternal = false
private var isBroadcasting = false
var onCheckedChangeListener: OnCheckedChangeListener? = null
init {
setOnClickListener {
toggle()
}
}
fun setOnCheckedChangeListener(listener: (Boolean) -> Unit) {
onCheckedChangeListener = object : OnCheckedChangeListener {
override fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) {
listener(isChecked)
}
}
}
override fun isChecked() = isCheckedInternal
override fun toggle() {
isChecked = !isCheckedInternal
}
override fun setChecked(checked: Boolean) {
if (checked != isCheckedInternal) {
isCheckedInternal = checked
refreshDrawableState()
if (!isBroadcasting) {
isBroadcasting = true
onCheckedChangeListener?.onCheckedChanged(this, checked)
isBroadcasting = false
}
}
}
override fun onCreateDrawableState(extraSpace: Int): IntArray {
val state = super.onCreateDrawableState(extraSpace + 1)
if (isCheckedInternal) {
mergeDrawableStates(state, CHECKED_STATE_SET)
}
return state
}
interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
}
private companion object {
@JvmStatic
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
}
}

View File

@@ -16,6 +16,13 @@ class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChap
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
@@ -24,9 +31,13 @@ class ChaptersAdapter(onItemClickListener: OnRecyclerItemClickListener<MangaChap
override fun onGetItemId(item: MangaChapter) = item.id
override fun getExtra(item: MangaChapter, position: Int): ChapterExtra = when {
currentChapterPosition == RecyclerView.NO_POSITION -> ChapterExtra.UNREAD
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

@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.View
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
@@ -44,6 +45,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,6 +58,11 @@ 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
@@ -86,4 +93,13 @@ class ChaptersFragment : BaseFragment(R.layout.fragment_chapters), MangaDetailsV
}
return true
}
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, 100)
}
}
}

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.lifecycle.lifecycleScope
@@ -21,12 +22,12 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.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 {
@@ -78,6 +79,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)
@@ -106,27 +118,44 @@ 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
}
R.id.action_save -> {
manga?.let {
DownloadService.start(this, it)
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
AlertDialog.Builder(this)
.setTitle(R.string.save_manga)
.setMessage(getString(R.string.large_manga_save_confirm, chaptersCount))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
}.show()
} else {
DownloadService.start(this, it)
}
}
true
}
R.id.action_browser -> {
manga?.let {
startActivity(BrowserActivity.newIntent(this, it.url))
}
true
}
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,

View File

@@ -17,10 +17,13 @@ import org.koitharu.kotatsu.ui.main.list.favourites.categories.FavouriteCategori
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) {
@@ -94,9 +98,10 @@ class MangaDetailsPresenter private constructor() : BasePresenter<MangaDetailsVi
withContext(Dispatchers.IO) {
val repository =
MangaProviderFactory.create(MangaSource.LOCAL) as LocalMangaRepository
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
safe {
HistoryRepository().delete(manga)
HistoryRepository().deleteOrSwap(manga, original)
}
}
viewState.onMangaRemoved(manga)

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

@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.ui.download
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.os.PowerManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.ContextCompat
import coil.Coil
import coil.api.get
@@ -11,7 +13,7 @@ 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
@@ -27,11 +29,13 @@ import org.koitharu.kotatsu.utils.ext.retryUntilSuccess
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
@@ -41,6 +45,8 @@ class DownloadService : BaseService() {
override fun onCreate() {
super.onCreate()
notification = DownloadNotification(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -50,6 +56,7 @@ class DownloadService : BaseService() {
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet()
if (manga != null) {
jobs[startId] = downloadManga(manga, chapters, startId)
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
} else {
stopSelf(startId)
}
@@ -67,6 +74,7 @@ class DownloadService : BaseService() {
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return launch(Dispatchers.IO) {
mutex.lock()
wakeLock.acquire(TimeUnit.MINUTES.toMillis(20))
withContext(Dispatchers.Main) {
notification.fillFrom(manga)
notification.setCancelId(startId)
@@ -154,6 +162,7 @@ class DownloadService : BaseService() {
notification.dismiss()
stopSelf(startId)
}
wakeLock.release()
mutex.unlock()
}
}

View File

@@ -28,8 +28,9 @@ import org.koitharu.kotatsu.ui.main.list.local.LocalListFragment
import org.koitharu.kotatsu.ui.main.list.remote.RemoteListFragment
import org.koitharu.kotatsu.ui.reader.ReaderActivity
import org.koitharu.kotatsu.ui.reader.ReaderState
import org.koitharu.kotatsu.ui.settings.SettingsActivity
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
@@ -66,9 +67,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
navigationView.setCheckedItem(R.id.nav_history)
setPrimaryFragment(HistoryListFragment.newInstance())
}
drawer.postDelayed(4000) {
drawer.postDelayed(2000) {
AppUpdateService.startIfRequired(applicationContext)
}
TrackWorker.setup(applicationContext)
}
override fun onDestroy() {
@@ -79,7 +81,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
initSideMenu(MangaProviderFactory.sources)
initSideMenu(MangaProviderFactory.getSources(includeHidden = false))
}
override fun onConfigurationChanged(newConfig: Configuration) {
@@ -148,7 +150,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
getString(R.string.key_sources_order) -> initSideMenu(MangaProviderFactory.sources)
getString(R.string.key_sources_hidden),
getString(R.string.key_sources_order) -> {
initSideMenu(MangaProviderFactory.getSources(includeHidden = false))
}
}
}

View File

@@ -26,7 +26,7 @@ class MainPresenter : BasePresenter<MainView>() {
val history = repo.getOne(manga) ?: throw EmptyHistoryException()
ReaderState(
MangaProviderFactory.create(manga.source).getDetails(manga),
history.chapterId, history.page
history.chapterId, history.page, history.scroll
)
}
viewState.onOpenReader(state)

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,9 +59,7 @@ 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))
@@ -122,6 +121,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 +174,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 +185,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)
@@ -246,13 +254,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

@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.ui.common.dialog.TextInputDialog
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouriteCategoriesDialog() : BaseBottomSheet(R.layout.dialog_favorite_categories),
class FavouriteCategoriesDialog : BaseBottomSheet(R.layout.dialog_favorite_categories),
FavouriteCategoriesView,
OnCategoryCheckListener {

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

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

@@ -6,6 +6,7 @@ import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_list.*
import moxy.ktx.moxyPresenter
@@ -14,7 +15,6 @@ 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>() {
@@ -81,14 +81,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

@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.ui.main.list.local
import android.content.Context
import android.net.Uri
import android.os.Build
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moxy.InjectViewState
import moxy.presenterScope
import org.koin.core.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga
@@ -17,6 +19,7 @@ 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.ext.safe
import org.koitharu.kotatsu.utils.ext.sub
@@ -88,11 +91,15 @@ class LocalListPresenter : BasePresenter<MangaListView<File>>() {
presenterScope.launch {
try {
withContext(Dispatchers.IO) {
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
safe {
HistoryRepository().delete(manga)
HistoryRepository().deleteOrSwap(manga, original)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
MangaShortcut(manga).removeAppShortcut(get())
}
viewState.onItemRemoved(manga)
} catch (e: CancellationException) {
} catch (e: Throwable) {

View File

@@ -23,6 +23,7 @@ class PageLoader : KoinComponent, CoroutineScope, DisposableHandle {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun loadFile(url: String, force: Boolean): File {
if (!force) {
cache[url]?.let {

View File

@@ -9,6 +9,7 @@ import android.os.Build
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.core.view.updatePadding
@@ -34,8 +35,8 @@ 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.*
@@ -91,7 +92,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)
}
}
}
@@ -195,8 +196,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
else -> super.onOptionsItemSelected(item)
}
override fun saveState(chapterId: Long, page: Int) {
state = state.copy(chapterId = chapterId, page = page)
override fun saveState(chapterId: Long, page: Int, scroll: Float) {
state = state.copy(chapterId = chapterId, page = page, scroll = scroll)
ReaderPresenter.saveState(state)
}
@@ -207,16 +208,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) {
@@ -225,11 +226,13 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
setUiIsVisible(!appbar_top.isVisible)
}
GridTouchHelper.AREA_TOP,
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
GridTouchHelper.AREA_LEFT,
-> if (isTapSwitchEnabled) {
reader?.switchPageBy(-1)
}
GridTouchHelper.AREA_BOTTOM,
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
GridTouchHelper.AREA_RIGHT,
-> if (isTapSwitchEnabled) {
reader?.switchPageBy(1)
}
}
@@ -267,13 +270,15 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT -> {
KeyEvent.KEYCODE_DPAD_RIGHT,
-> {
reader?.switchPageBy(1)
true
}
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT -> {
KeyEvent.KEYCODE_DPAD_LEFT,
-> {
reader?.switchPageBy(-1)
true
}
@@ -293,7 +298,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
override fun onChapterChanged(chapter: MangaChapter) {
state = state.copy(
chapterId = chapter.id,
page = 0
page = 0,
scroll = 0f
)
reader?.updateState(chapterId = chapter.id)
}
@@ -371,7 +377,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
manga = manga,
chapterId = if (chapterId == -1L) manga.chapters?.firstOrNull()?.id
?: -1 else chapterId,
page = 0
page = 0,
scroll = 0f
)
)
@@ -383,7 +390,8 @@ class ReaderActivity : BaseFullscreenActivity(), ReaderView, ChaptersDialog.OnCh
context, ReaderState(
manga = manga,
chapterId = history.chapterId,
page = history.page
page = history.page,
scroll = history.scroll
)
)
}

View File

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

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.ui.reader
import android.content.ContentResolver
import android.util.Log
import android.webkit.URLUtil
import kotlinx.coroutines.*
import moxy.InjectViewState
@@ -41,7 +40,7 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
mode = MangaUtils.determineReaderMode(pages)
if (mode != null) {
prefs.savePreferences(
mangaId = manga.id,
manga = manga,
mode = mode
)
}
@@ -51,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)
@@ -61,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
)
}
@@ -103,7 +105,8 @@ class ReaderPresenter : BasePresenter<ReaderView>() {
HistoryRepository().addOrUpdate(
manga = state.manga,
chapterId = state.chapterId,
page = state.page
page = state.page,
scroll = state.scroll
)
}
}

View File

@@ -10,7 +10,8 @@ import org.koitharu.kotatsu.core.model.MangaChapter
data class ReaderState(
val manga: Manga,
val chapterId: Long,
val page: Int
val page: Int,
val scroll: Float
) : Parcelable {
@IgnoredOnParcel

View File

@@ -6,8 +6,8 @@ import android.os.Bundle
import androidx.fragment.app.commit
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.ReaderSettingsFragment
import org.koitharu.kotatsu.ui.settings.SettingsHeadersFragment
class SimpleSettingsActivity : BaseActivity() {
@@ -19,7 +19,7 @@ class SimpleSettingsActivity : BaseActivity() {
supportFragmentManager.commit {
replace(R.id.container, when(section) {
SECTION_READER -> ReaderSettingsFragment()
else -> SettingsHeadersFragment()
else -> MainSettingsFragment()
})
}
}

View File

@@ -58,6 +58,9 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
pages.addLast(state.chapterId, it)
adapter?.notifyDataSetChanged()
setCurrentItem(state.page, false)
if (state.scroll != 0f) {
restorePageScroll(state.page, state.scroll)
}
}
}
@@ -67,7 +70,8 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
ARG_STATE, ReaderState(
manga = manga,
chapterId = pages.findGroupByIndex(getCurrentItem()) ?: return,
page = pages.getRelativeIndex(getCurrentItem())
page = pages.getRelativeIndex(getCurrentItem()),
scroll = getCurrentPageScroll()
)
)
}
@@ -174,7 +178,7 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
val chapterId = pages.findGroupByIndex(getCurrentItem()) ?: return
val page = pages.getRelativeIndex(getCurrentItem())
if (page != -1) {
readerListener?.saveState(chapterId, page)
readerListener?.saveState(chapterId, page, getCurrentPageScroll())
}
Log.i(TAG, "saveState(chapterId=$chapterId, page=$page)")
}
@@ -217,6 +221,10 @@ abstract class AbstractReader(contentLayoutId: Int) : BaseFragment(contentLayout
protected abstract fun getCurrentItem(): Int
protected abstract fun getCurrentPageScroll(): Float
protected abstract fun restorePageScroll(position: Int, scroll: Float)
protected abstract fun setCurrentItem(position: Int, isSmooth: Boolean)
protected abstract fun onCreateAdapter(dataSet: GroupedList<Long, MangaPage>): BaseReaderAdapter

View File

@@ -55,7 +55,7 @@ class GroupedList<K, T> {
lruGroup = entry.second
lruGroupKey = entry.first
lruGroupFirstIndex = lastIndex - entry.second.size
return entry.second.get(index - lruGroupFirstIndex)
return entry.second[index - lruGroupFirstIndex]
}
lastIndex -= entry.second.size
}

View File

@@ -30,6 +30,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 {
@@ -49,7 +54,10 @@ class PageHolder(parent: ViewGroup, private val loader: PageLoader) :
}
}
override fun onReady() = Unit
override fun onReady() {
ssiv.maxScale = 2f * maxOf(ssiv.width / ssiv.sWidth.toFloat(), ssiv.height / ssiv.sHeight.toFloat())
ssiv.resetScaleAndCenter()
}
override fun onImageLoadError(e: Exception) = onError(e)

View File

@@ -5,14 +5,14 @@ import android.view.View
import kotlinx.android.synthetic.main.fragment_reader_standard.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.ui.reader.ReaderState
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.ui.reader.ReaderState
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) {
private var paginationListener: PagerPaginationListener? = null
@@ -43,6 +43,10 @@ class PagerReaderFragment() : AbstractReader(R.layout.fragment_reader_standard)
pager.setCurrentItem(position, isSmooth)
}
override fun getCurrentPageScroll() = 0f
override fun restorePageScroll(position: Int, scroll: Float) = Unit
companion object {
fun newInstance(state: ReaderState) = PagerReaderFragment().withArgs(1) {

View File

@@ -1,6 +1,7 @@
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.size.PixelSize
@@ -8,20 +9,18 @@ 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,14 +28,15 @@ 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 pageUrl = MangaProviderFactory.create(data.source).getPageFullUrl(data)
extra[pageUrl]?.toUri()?.toString() ?: pageUrl
}
val drawable = Coil.get(url) {
size(thumbSize)
@@ -50,4 +50,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

@@ -6,7 +6,7 @@ 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
@@ -20,6 +20,7 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
SubsamplingScaleImageView.OnImageEventListener, CoroutineScope by loader {
private var job: Job? = null
private var scrollToRestore = 0f
init {
ssiv.setOnImageEventListener(this)
@@ -34,6 +35,7 @@ class WebtoonHolder(parent: ViewGroup, private val loader: PageLoader) :
private fun doLoad(data: MangaPage, force: Boolean) {
job?.cancel()
scrollToRestore = 0f
job = launch {
layout_error.isVisible = false
progressBar.isVisible = true
@@ -51,17 +53,39 @@ 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) {
if (ssiv.isReady) {
ssiv.setScaleAndCenter(
ssiv.scale,
PointF(
ssiv.sWidth / 2f,
scroll
)
)
} else {
scrollToRestore = scroll
}
}
override fun onReady() {
ssiv.maxScale = 2f * ssiv.width / ssiv.sWidth.toFloat()
ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
ssiv.minScale = ssiv.width / ssiv.sWidth.toFloat()
ssiv.setScaleAndCenter(
ssiv.minScale,
PointF(
ssiv.sWidth / 2f,
if (itemView.top < 0) {
ssiv.sHeight.toFloat()
} else {
0f
when {
scrollToRestore != 0f -> scrollToRestore
itemView.top < 0 -> ssiv.sHeight.toFloat()
else -> 0f
}
)
)

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
@@ -11,16 +10,15 @@ import org.koitharu.kotatsu.ui.reader.ReaderState
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.ui.reader.standard.PagerReaderFragment
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
class WebtoonReaderFragment : AbstractReader(R.layout.fragment_reader_webtoon) {
private val scrollInterpolator = AccelerateDecelerateInterpolator()
protected var paginationListener: ListPaginationListener? = null
private var paginationListener: ListPaginationListener? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -44,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) {
@@ -56,7 +54,23 @@ class WebtoonReaderFragment : AbstractReader(R.layout.fragment_reader_webtoon) {
}
override fun switchPageBy(delta: Int) {
recyclerView.smoothScrollBy(0, (recyclerView.height * 0.9).toInt() * delta, scrollInterpolator)
recyclerView.smoothScrollBy(
0,
(recyclerView.height * 0.9).toInt() * delta,
scrollInterpolator
)
}
override fun getCurrentPageScroll(): Float {
return (recyclerView.findViewHolderForAdapterPosition(getCurrentItem()) as? WebtoonHolder)
?.getScrollY() ?: 0f
}
override fun restorePageScroll(position: Int, scroll: Float) {
recyclerView.post {
val holder = recyclerView.findViewHolderForAdapterPosition(position) ?: return@post
(holder as WebtoonHolder).restoreScroll(scroll)
}
}
companion object {

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.ui.settings
import android.os.Bundle
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
class AboutSettingsFragment : BasePreferenceFragment(R.string.about_app) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about)
}
}

View File

@@ -9,6 +9,7 @@ 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
@@ -80,8 +81,18 @@ 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.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
builder.setLargeIcon(BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher))
manager.notify(NOTIFICATION_ID, builder.build())
}
@@ -90,10 +101,14 @@ class AppUpdateService : BaseService() {
private const val NOTIFICATION_ID = 202
private const val CHANNEL_ID = "update"
private val PERIOD = TimeUnit.HOURS.toMillis(10)
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun start(context: Context) =
context.startService(Intent(context, AppUpdateService::class.java))
fun start(context: Context) {
try {
context.startService(Intent(context, AppUpdateService::class.java))
} catch (_: IllegalStateException) {
}
}
fun startIfRequired(context: Context) {
val settings = AppSettings(context)

View File

@@ -1,25 +1,33 @@
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
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceScreen
import androidx.preference.SeekBarPreference
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.main.list.ListModeSelectDialog
import org.koitharu.kotatsu.ui.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.ui.tracker.TrackWorker
class AppearanceSettingsFragment : BasePreferenceFragment(R.string.appearance),
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
addPreferencesFromResource(R.xml.pref_main)
findPreference<Preference>(R.string.key_list_mode)?.summary =
listModes[settings.listMode]?.let(::getString)
LIST_MODES[settings.listMode]?.let(::getString)
findPreference<SeekBarPreference>(R.string.key_grid_size)?.run {
summary = "%d%%".format(value)
setOnPreferenceChangeListener { preference, newValue ->
@@ -27,6 +35,18 @@ class AppearanceSettingsFragment : BasePreferenceFragment(R.string.appearance),
true
}
}
findPreference<MultiSelectListPreference>(R.string.key_reader_switchers)?.summaryProvider =
MultiSummaryProvider(R.string.gestures_only)
}
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)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -39,29 +59,40 @@ class AppearanceSettingsFragment : BasePreferenceFragment(R.string.appearance),
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
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
getString(R.string.key_list_mode) -> findPreference<Preference>(R.string.key_list_mode)?.summary =
listModes[settings.listMode]?.let(::getString)
getString(R.string.key_theme) -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
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
}
else -> super.onPreferenceTreeClick(preference)
}
}
private companion object {
val listModes = arrayMapOf(
val LIST_MODES = arrayMapOf(
ListMode.DETAILED_LIST to R.string.detailed_list,
ListMode.GRID to R.string.grid,
ListMode.LIST to R.string.list

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
}
}

View File

@@ -3,11 +3,12 @@ package org.koitharu.kotatsu.ui.settings
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import android.transition.Slide
import android.view.Gravity
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import kotlinx.android.synthetic.main.activity_settings.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.BaseActivity
@@ -20,51 +21,37 @@ class SettingsActivity : BaseActivity(),
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val isTablet = container_side != null
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
supportFragmentManager.commit {
if (isTablet) {
replace(R.id.container_side, SettingsHeadersFragment())
replace(R.id.container, AppearanceSettingsFragment())
} else {
replace(R.id.container, SettingsHeadersFragment())
}
replace(R.id.container, MainSettingsFragment().also {
it.exitTransition = Slide(Gravity.START)
})
}
}
}
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fm = supportFragmentManager
if (container_side != null && caller is SettingsHeadersFragment) {
fm.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment)
fragment.arguments = pref.extras
fragment.setTargetFragment(caller, 0)
fm.commit {
replace(R.id.container, fragment)
if (container_side == null || caller !is SettingsHeadersFragment) {
addToBackStack(null)
}
}
openFragment(fragment)
return true
}
override fun onTitleChanged(title: CharSequence?, color: Int) {
if (container_side == null) {
super.onTitleChanged(title, color)
} else {
if (supportFragmentManager.backStackEntryCount == 0) {
supportActionBar?.subtitle = null
} else {
supportActionBar?.subtitle = title
}
}
fun openMangaSourceSettings(mangaSource: MangaSource) {
openFragment(SourceSettingsFragment.newInstance(mangaSource))
}
fun openMangaSourceSettings(mangaSource: MangaSource) {
fun openNotificationSettingsLegacy() {
openFragment(NotificationSettingsLegacyFragment())
}
private fun openFragment(fragment: Fragment) {
fragment.enterTransition = Slide(Gravity.END)
fragment.exitTransition = Slide(Gravity.START)
supportFragmentManager.commit {
replace(R.id.container, SourceSettingsFragment.newInstance(mangaSource))
replace(R.id.container, fragment)
addToBackStack(null)
}
}

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.ui.settings
import android.os.Bundle
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.BasePreferenceFragment
class SettingsHeadersFragment : BasePreferenceFragment(R.string.settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_headers)
}
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.ui.settings.sources
import android.view.ViewGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class SourceDividerHolder(parent: ViewGroup) :
BaseViewHolder<Unit, Unit>(parent, R.layout.item_sources_pref_divider) {
override fun onBind(data: Unit, extra: Unit) = Unit
}

View File

@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
class SourceViewHolder(parent: ViewGroup) :
BaseViewHolder<MangaSource, Unit>(parent, R.layout.item_source_config) {
BaseViewHolder<MangaSource, Boolean>(parent, R.layout.item_source_config) {
override fun onBind(data: MangaSource, extra: Unit) {
override fun onBind(data: MangaSource, extra: Boolean) {
textView_title.text = data.title
imageView_hidden.isChecked = extra
}
}

View File

@@ -6,32 +6,46 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.item_source_config.*
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.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.ui.common.list.BaseViewHolder
import org.koitharu.kotatsu.ui.common.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.utils.ext.safe
class SourcesAdapter(private val onItemClickListener: OnRecyclerItemClickListener<MangaSource>) :
RecyclerView.Adapter<BaseViewHolder<*, Unit>>(), KoinComponent {
RecyclerView.Adapter<SourceViewHolder>(), KoinComponent {
private val dataSet = MangaProviderFactory.sources.toMutableList()
private val dataSet = MangaProviderFactory.getSources(includeHidden = true).toMutableList()
private val settings by inject<AppSettings>()
private val hiddenItems = settings.hiddenSources.mapNotNull {
safe {
MangaSource.valueOf(it)
}
}.toMutableSet()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
ITEM_SOURCE -> SourceViewHolder(parent).also(::onViewHolderCreated)
ITEM_DIVIDER -> SourceDividerHolder(parent)
else -> throw IllegalArgumentException("Unsupported viewType $viewType")
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = SourceViewHolder(parent).also(::onViewHolderCreated)
override fun getItemCount() = dataSet.size
override fun onBindViewHolder(holder: BaseViewHolder<*, Unit>, position: Int) {
(holder as? SourceViewHolder)?.bind(dataSet[position], Unit)
override fun onBindViewHolder(holder: SourceViewHolder, position: Int) {
val item = dataSet[position]
holder.bind(item, !hiddenItems.contains(item))
}
@SuppressLint("ClickableViewAccessibility")
private fun onViewHolderCreated(holder: SourceViewHolder) {
holder.imageView_hidden.setOnCheckedChangeListener {
if (it) {
hiddenItems.remove(holder.requireData())
} else {
hiddenItems.add(holder.requireData())
}
settings.hiddenSources = hiddenItems.map { x -> x.name }.toSet()
}
holder.imageView_config.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), holder.adapterPosition, v)
}
@@ -48,12 +62,6 @@ class SourcesAdapter(private val onItemClickListener: OnRecyclerItemClickListene
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
get<AppSettings>().sourcesOrder = dataSet.map { it.ordinal }
}
companion object {
const val ITEM_SOURCE = 0
const val ITEM_DIVIDER = 1
settings.sourcesOrder = dataSet.map { it.ordinal }
}
}

View File

@@ -0,0 +1,210 @@
package org.koitharu.kotatsu.ui.tracker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.work.*
import coil.Coil
import coil.api.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.KoinComponent
import org.koin.core.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.domain.MangaProviderFactory
import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import org.koitharu.kotatsu.ui.details.MangaDetailsActivity
import org.koitharu.kotatsu.utils.ext.safe
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.util.concurrent.TimeUnit
class TrackWorker(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams), KoinComponent {
private val notificationManager by lazy(LazyThreadSafetyMode.NONE) {
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private val settings by inject<AppSettings>()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val repo = TrackingRepository()
val tracks = repo.getAllTracks()
if (tracks.isEmpty()) {
return@withContext Result.success()
}
var success = 0
for (track in tracks) {
val details = safe {
MangaProviderFactory.create(track.manga.source)
.getDetails(track.manga)
}
val chapters = details?.chapters ?: continue
when {
track.knownChaptersCount == -1 -> { //first check
repo.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: 0L,
lastNotifiedChapterId = 0L,
newChapters = 0
)
}
track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { //manga was empty on last check
repo.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = track.knownChaptersCount,
lastChapterId = 0L,
lastNotifiedChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = chapters.size
)
showNotification(track.manga, chapters)
}
chapters.size == track.knownChaptersCount -> {
if (chapters.lastOrNull()?.id == track.lastChapterId) {
// manga was not updated. skip
} else {
// number of chapters still the same, bu last chapter changed.
// maybe some chapters are removed. we need to find last known chapter
val knownChapter = chapters.indexOfLast { it.id == track.lastChapterId }
if (knownChapter == -1) {
// confuse. reset anything
repo.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: 0L,
lastNotifiedChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = 0
)
} else {
val newChapters = chapters.size - knownChapter + 1
repo.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = knownChapter + 1,
lastChapterId = track.lastChapterId,
lastNotifiedChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = newChapters
)
if (chapters.lastOrNull()?.id != track.lastNotifiedChapterId) {
showNotification(track.manga, chapters.takeLast(newChapters))
}
}
}
}
else -> {
val newChapters = chapters.size - track.knownChaptersCount
repo.storeTrackResult(
mangaId = track.manga.id,
knownChaptersCount = track.knownChaptersCount,
lastChapterId = track.lastChapterId,
lastNotifiedChapterId = chapters.lastOrNull()?.id ?: 0L,
newChapters = newChapters
)
if (chapters.lastOrNull()?.id != track.lastNotifiedChapterId) {
showNotification(track.manga, chapters.takeLast(newChapters))
}
}
}
success++
}
if (success == 0) {
Result.retry()
} else {
Result.success()
}
}
private suspend fun showNotification(manga: Manga, newChapters: List<MangaChapter>) {
if (newChapters.isEmpty() || !settings.trackerNotifications) {
return
}
val id = manga.url.hashCode()
val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary)
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
val summary = applicationContext.resources.getQuantityString(R.plurals.new_chapters,
newChapters.size, newChapters.size)
with(builder) {
setContentText(summary)
setContentText(manga.title)
setNumber(newChapters.size)
setLargeIcon(safe {
Coil.loader().get(manga.coverUrl).toBitmap()
})
setSmallIcon(R.drawable.ic_stat_book_plus)
val style = NotificationCompat.InboxStyle(this)
for (chapter in newChapters) {
style.addLine(chapter.name)
}
style.setSummaryText(manga.title)
style.setBigContentTitle(summary)
setStyle(style)
val intent = MangaDetailsActivity.newIntent(applicationContext, manga)
setContentIntent(PendingIntent.getActivity(applicationContext, id,
intent, PendingIntent.FLAG_UPDATE_CURRENT))
setAutoCancel(true)
color = colorPrimary
priority = NotificationCompat.PRIORITY_DEFAULT
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
builder.setSound(settings.notificationSound.toUriOrNull())
var defaults = if (settings.notificationLight) {
setLights(colorPrimary, 1000, 5000)
NotificationCompat.DEFAULT_LIGHTS
} else 0
if (settings.notificationVibrate) {
builder.setVibrate(longArrayOf(500, 500, 500, 500))
defaults = defaults or NotificationCompat.DEFAULT_VIBRATE
}
builder.setDefaults(defaults)
}
}
withContext(Dispatchers.Main) {
notificationManager.notify(TAG, id, builder.build())
}
}
companion object {
const val CHANNEL_ID = "tracking"
private const val TAG = "tracking"
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(context: Context) {
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID,
context.getString(R.string.new_chapters),
NotificationManager.IMPORTANCE_DEFAULT)
channel.setShowBadge(true)
channel.lightColor = ContextCompat.getColor(context, R.color.blue_primary)
channel.enableLights(true)
manager.createNotificationChannel(channel)
}
}
fun setup(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(context)
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.ui.utils
import android.content.Context
import android.content.Intent
import java.io.PrintWriter
import java.io.StringWriter
import kotlin.system.exitProcess
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
val crashInfo = buildString {
val writer = StringWriter()
e.printStackTrace(PrintWriter(writer))
append(writer.toString().trimIndent())
}
val intent = Intent(applicationContext, CrashActivity::class.java)
intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)
} catch (e: Throwable) {
e.printStackTrace()
}
exitProcess(1)
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.ui.utils
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import kotlinx.android.synthetic.main.activity_crash.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.ui.main.MainActivity
class CrashActivity : Activity(), View.OnClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_crash)
textView.text = intent.getStringExtra(Intent.EXTRA_TEXT)
button_close.setOnClickListener(this)
button_restart.setOnClickListener(this)
}
override fun onClick(v: View) {
when(v.id) {
R.id.button_close -> {
finish()
}
R.id.button_restart -> {
val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}
}
}

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