Compare commits

..

40 Commits
v3.4.8 ... v3.5

Author SHA1 Message Date
Koitharu
45dbd5aa44 Fix crash on page loading 2022-10-16 10:41:53 +03:00
Koitharu
ee65251bf5 Update parsers 2022-10-16 10:38:23 +03:00
Koitharu
4d838d290d Update dependencies 2022-10-03 08:41:32 +03:00
Koitharu
048efdf59f Fix crash on slider 2022-10-03 08:07:25 +03:00
Koitharu
af2adeba13 Fix opening fingerprint dialog 2022-10-01 12:19:36 +03:00
Koitharu
93c6bec452 Fix widgets colors 2022-10-01 10:34:25 +03:00
Koitharu
c944044465 Update version 2022-09-22 17:38:08 +03:00
Koitharu
8a63ca2310 Fix coroutines cancellation 2022-09-22 17:33:40 +03:00
Koitharu
12e5e3b35e Update gitignore 2022-09-22 16:53:27 +03:00
Zakhar Timoshenko
553a85ef86 Widget theme fix #225 2022-09-22 16:49:38 +03:00
Koitharu
de7012cabf Change acra sender to http 2022-09-15 08:36:20 +03:00
Koitharu
46f0d3ef74 Fix onboarding dialog 2022-09-14 12:34:33 +03:00
Koitharu
c27c785ac2 Use Coil for empty states images 2022-09-14 12:30:07 +03:00
Koitharu
4186c36f30 Prevent GoneOnInvisibleListener leak 2022-09-12 15:45:34 +03:00
Koitharu
757e33dfb4 Fix unwanted errors reporting 2022-09-12 14:32:42 +03:00
Koitharu
ab9bdf9f07 Fixes 2022-09-11 09:11:35 +03:00
Koitharu
2e561697ac Update parsers 2022-09-07 14:07:48 +03:00
Koitharu
d242acd502 Sort local list by manga name 2022-09-03 15:57:48 +03:00
Koitharu
d37b44d3f6 Update parsers 2022-09-03 15:56:16 +03:00
Koitharu
e4c4d2bbf0 Fix crashes 2022-08-27 09:38:54 +03:00
Koitharu
040d3e4433 Reader control direction depends on mode #214 2022-08-26 12:13:18 +03:00
Koitharu
b4f93fc0a5 Fix showing reader control by long press 2022-08-26 10:09:48 +03:00
Koitharu
c4e7807d18 Update version 2022-08-23 09:23:34 +03:00
Koitharu
8e55a4d824 Fix Shikimori authToken refreshing 2022-08-19 11:46:05 +03:00
Koitharu
c1e9fde6e8 Update parsers and version 2022-08-17 16:51:03 +03:00
Koitharu
32e80c7e95 Fix downloads cancellation #210 2022-08-11 15:49:44 +03:00
Koitharu
c07a3b9d0d Download control buttons in list 2022-08-11 12:55:02 +03:00
Koitharu
893d1a881d Fix download service stopping #210 2022-08-11 12:15:24 +03:00
Koitharu
43ef130052 Group download notification 2022-08-11 11:40:27 +03:00
Koitharu
d5bea0ca53 Fix DownloadService leak 2022-08-11 10:18:44 +03:00
Koitharu
9c740c5cc1 Fix settings title 2022-08-10 15:30:57 +03:00
lowak
cf7535e2ba Translated using Weblate (Swedish)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2022-08-10 15:14:17 +03:00
Koitharu
87afad29ce Update parsers 2022-08-08 16:11:24 +03:00
Koitharu
436233e735 Fix description text color 2022-08-08 11:49:48 +03:00
Koitharu
6e367ddd74 Failsafe implementation of MangaSource.valueOf 2022-08-08 11:04:35 +03:00
Koitharu
fcdfaf5564 Fix covers size resolving 2022-08-08 10:50:53 +03:00
Koitharu
dff17fd11f Change BaseSavedState to AbsSavedState 2022-08-06 18:37:25 +03:00
Koitharu
85af73df99 Update dependencies 2022-08-04 13:43:06 +03:00
Koitharu
c7a97711c0 Optimize chapters mapping 2022-08-04 11:59:09 +03:00
Koitharu
ffbe05b2ae Fix tracker for multiple branches 2022-08-04 11:32:50 +03:00
109 changed files with 1821 additions and 1186 deletions

View File

@@ -15,5 +15,6 @@ disabled_rules=no-wildcard-imports,no-unused-imports
ij_continuation_indent_size = 4 ij_continuation_indent_size = 4
[{*.kt,*.kts}] [{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>

3
.idea/kotlinc.xml generated
View File

@@ -3,4 +3,7 @@
<component name="Kotlin2JvmCompilerArguments"> <component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" /> <option name="jvmTarget" value="1.8" />
</component> </component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.6.21" />
</component>
</project> </project>

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 420 versionCode 430
versionName '3.4.8' versionName '3.5'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -28,6 +28,8 @@ android {
// define this values in your local.properties file // define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
} }
buildTypes { buildTypes {
debug { debug {
@@ -79,19 +81,19 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:85bfe42ddf') { implementation('com.github.KotatsuApp:kotatsu-parsers:5cb953eb86') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0' implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.0' implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0' implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0' implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
@@ -99,13 +101,13 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha03' implementation 'com.google.android.material:material:1.7.0-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-runtime:2.4.3'
implementation 'androidx.room:room-ktx:2.4.2' implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.2' kapt 'androidx.room:room-compiler:2.4.3'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -115,12 +117,12 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.2.0' implementation 'io.insert-koin:koin-android:3.2.0'
implementation 'io.coil-kt:coil-base:2.1.0' implementation 'io.coil-kt:coil-base:2.2.1'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.5' implementation 'ch.acra:acra-http:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.5' implementation 'ch.acra:acra-dialog:5.9.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
@@ -137,6 +139,6 @@ dependencies {
androidTestImplementation 'io.insert-koin:koin-test:3.2.0' androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0' androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
androidTestImplementation 'androidx.room:room-testing:2.4.2' androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
} }

View File

@@ -10,4 +10,6 @@
} }
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.ConscryptPlatform -dontwarn okhttp3.internal.platform.ConscryptPlatform
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

View File

@@ -111,6 +111,7 @@
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" /> <service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service <service

View File

@@ -8,9 +8,10 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.config.mailSender import org.acra.config.httpSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.android.getKoin import org.koin.android.ext.android.getKoin
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@@ -73,7 +74,7 @@ class KotatsuApp : Application() {
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
shikimoriModule, shikimoriModule,
bookmarksModule bookmarksModule,
) )
} }
} }
@@ -82,16 +83,25 @@ class KotatsuApp : Application() {
super.attachBaseContext(base) super.attachBaseContext(base)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
)
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
basicAuthPassword = getString(R.string.acra_password)
httpMethod = HttpSender.Method.POST
}
reportContent = listOf( reportContent = listOf(
ReportField.PACKAGE_NAME, ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.SHARED_PREFERENCES ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
text = getString(R.string.crash_text) text = getString(R.string.crash_text)
@@ -100,11 +110,6 @@ class KotatsuApp : Application() {
resIcon = R.drawable.ic_alert_outline resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
} }
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
} }
} }
@@ -129,7 +134,7 @@ class KotatsuApp : Application() {
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()
.detectAll() .detectAll()
.penaltyLog() .penaltyLog()
.build() .build(),
) )
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder() StrictMode.VmPolicy.Builder()
@@ -138,7 +143,7 @@ class KotatsuApp : Application() {
.setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog() .penaltyLog()
.build() .build(),
) )
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath() .penaltyDeath()

View File

@@ -1,8 +1,12 @@
package org.koitharu.kotatsu.base.domain package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
fun interface ReversibleHandle { fun interface ReversibleHandle {
@@ -10,7 +14,13 @@ fun interface ReversibleHandle {
} }
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) { fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
reverse() runCatchingCancellable {
withContext(NonCancellable) {
reverse()
}
}.onFailure {
it.printStackTraceDebug()
}
} }
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle { operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {

View File

@@ -10,6 +10,7 @@ import android.widget.Checkable
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import androidx.customview.view.AbsSavedState
class CheckableImageView @JvmOverloads constructor( class CheckableImageView @JvmOverloads constructor(
context: Context, context: Context,
@@ -73,7 +74,7 @@ class CheckableImageView @JvmOverloads constructor(
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
} }
private class SavedState : BaseSavedState { private class SavedState : AbsSavedState {
val isChecked: Boolean val isChecked: Boolean
@@ -81,7 +82,7 @@ class CheckableImageView @JvmOverloads constructor(
isChecked = checked isChecked = checked
} }
constructor(source: Parcel) : super(source) { constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
isChecked = ParcelCompat.readBoolean(source) isChecked = ParcelCompat.readBoolean(source)
} }
@@ -91,9 +92,10 @@ class CheckableImageView @JvmOverloads constructor(
} }
companion object { companion object {
@Suppress("unused")
@JvmField @JvmField
val CREATOR: Creator<SavedState> = object : Creator<SavedState> { val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`) override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size) override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
} }

View File

@@ -25,7 +25,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false) ) = FragmentCloudflareBinding.inflate(inflater, container, false)
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@@ -49,6 +49,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() { override fun onDestroyView() {
binding.webView.stopLoading() binding.webView.stopLoading()
binding.webView.destroy()
super.onDestroyView() super.onDestroyView()
} }
@@ -77,7 +78,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true) pendingResult.putBoolean(EXTRA_RESULT, true)
dismiss() dismissAllowingStateLoss()
} }
companion object { companion object {

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -84,7 +85,7 @@ class BackupRepository(private val db: MangaDatabase) {
JsonDeserializer(it).toTagEntity() JsonDeserializer(it).toTagEntity()
} }
val history = JsonDeserializer(item).toHistoryEntity() val history = JsonDeserializer(item).toHistoryEntity()
result += runCatching { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags) db.mangaDao.upsert(manga, tags)
@@ -99,7 +100,7 @@ class BackupRepository(private val db: MangaDatabase) {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatching { result += runCatchingCancellable {
db.favouriteCategoriesDao.upsert(category) db.favouriteCategoriesDao.upsert(category)
} }
} }
@@ -115,7 +116,7 @@ class BackupRepository(private val db: MangaDatabase) {
JsonDeserializer(it).toTagEntity() JsonDeserializer(it).toTagEntity()
} }
val favourite = JsonDeserializer(item).toFavouriteEntity() val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatching { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags) db.mangaDao.upsert(manga, tags)

View File

@@ -1,6 +1,34 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
return null
}
if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId }
if (currentChapter != null) {
return currentChapter.branch
}
}
val groups = ch.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}

View File

@@ -1,10 +1,18 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.*
fun MangaSource.getLocaleTitle(): String? { fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null) val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc) return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
@Suppress("FunctionName")
fun MangaSource(name: String): MangaSource? {
MangaSource.values().forEach {
if (it.name == name) return it
}
return null
} }

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException // Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe private const val MAX_SAFE_CHAPTERS_COUNT = 32 // this is 100% safe
class ParcelableManga( class ParcelableManga(
val manga: Manga, val manga: Manga,

View File

@@ -6,6 +6,7 @@ import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireBitmap import org.koitharu.kotatsu.utils.ext.requireBitmap
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class ShortcutsUpdater( class ShortcutsUpdater(
private val context: Context, private val context: Context,
@@ -37,10 +39,12 @@ class ShortcutsUpdater(
private var shortcutsUpdateJob: Job? = null private var shortcutsUpdateJob: Job? = null
override fun onInvalidated(tables: MutableSet<String>) { override fun onInvalidated(tables: MutableSet<String>) {
val prevJob = shortcutsUpdateJob if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) { val prevJob = shortcutsUpdateJob
prevJob?.join() shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) {
updateShortcutsImpl() prevJob?.join()
updateShortcutsImpl()
}
} }
} }
@@ -48,7 +52,7 @@ class ShortcutsUpdater(
return ShortcutManagerCompat.requestPinShortcut( return ShortcutManagerCompat.requestPinShortcut(
context, context,
buildShortcutInfo(manga).build(), buildShortcutInfo(manga).build(),
null null,
) )
} }
@@ -57,7 +61,8 @@ class ShortcutsUpdater(
return shortcutsUpdateJob?.join() != null return shortcutsUpdateJob?.join() != null
} }
private suspend fun updateShortcutsImpl() = runCatching { @RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
.filter { x -> x.title.isNotEmpty() } .filter { x -> x.title.isNotEmpty() }
@@ -68,17 +73,17 @@ class ShortcutsUpdater(
} }
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching { val icon = runCatchingCancellable {
val bmp = coil.execute( val bmp = coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize.width, iconSize.height) .size(iconSize.width, iconSize.height)
.build() .build(),
).requireBitmap() ).requireBitmap()
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0) ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
}.fold( }.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) } onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
mangaRepository.storeManga(manga) mangaRepository.storeManga(manga)
return ShortcutInfoCompat.Builder(context, manga.id.toString()) return ShortcutInfoCompat.Builder(context, manga.id.toString())
@@ -87,7 +92,7 @@ class ShortcutsUpdater(
.setIcon(icon) .setIcon(icon)
.setIntent( .setIntent(
ReaderActivity.newIntent(context, manga.id) ReaderActivity.newIntent(context, manga.id)
.setAction(ReaderActivity.ACTION_MANGA_READ) .setAction(ReaderActivity.ACTION_MANGA_READ),
) )
} }

View File

@@ -5,7 +5,7 @@ import coil.map.Mapper
import coil.request.Options import coil.request.Options
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
class FaviconMapper : Mapper<Uri, HttpUrl> { class FaviconMapper : Mapper<Uri, HttpUrl> {
@@ -13,7 +13,7 @@ class FaviconMapper : Mapper<Uri, HttpUrl> {
if (data.scheme != "favicon") { if (data.scheme != "favicon") {
return null return null
} }
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null
val repo = MangaRepository(mangaSource) as RemoteMangaRepository val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl() return repo.getFaviconUrl().toHttpUrl()
} }

View File

@@ -14,9 +14,6 @@ import java.io.File
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
@@ -64,6 +61,9 @@ class AppSettings(context: Context) {
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@@ -314,6 +314,7 @@ class AppSettings(context: Context) {
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -12,6 +12,7 @@ import androidx.core.view.MenuProvider
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlin.math.roundToInt
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import kotlin.math.roundToInt
class ChaptersFragment : class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(), BaseFragment<FragmentChaptersBinding>(),
@@ -46,7 +46,7 @@ class ChaptersFragment :
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentChaptersBinding.inflate(inflater, container, false) ) = FragmentChaptersBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -98,7 +98,7 @@ class ChaptersFragment :
manga = viewModel.manga.value ?: return, manga = viewModel.manga.value ?: return,
state = ReaderState(item.chapter.id, 0, 0), state = ReaderState(item.chapter.id, 0, 0),
), ),
options.toBundle() options.toBundle(),
) )
} }
@@ -128,7 +128,7 @@ class ChaptersFragment :
Snackbar.make( Snackbar.make(
binding.recyclerViewChapters, binding.recyclerViewChapters,
R.string.chapters_will_removed_background, R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG,
).show() ).show()
} }
} }
@@ -271,8 +271,8 @@ class ChaptersFragment :
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {

View File

@@ -105,7 +105,7 @@ class DetailsActivity :
Toast.makeText( Toast.makeText(
this, this,
getString(R.string._s_deleted_from_local_storage, manga.title), getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT,
).show() ).show()
finishAfterTransition() finishAfterTransition()
} }
@@ -131,7 +131,7 @@ class DetailsActivity :
onActionClick = { onActionClick = {
e.report("DetailsActivity::onError") e.report("DetailsActivity::onError")
dismiss() dismiss()
} },
) )
} }
else -> { else -> {
@@ -142,14 +142,14 @@ class DetailsActivity :
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.snackbar.updatePadding( binding.snackbar.updatePadding(
bottom = insets.bottom bottom = insets.bottom,
) )
binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> { binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top topMargin = insets.top
} }
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right,
) )
} }
@@ -159,6 +159,7 @@ class DetailsActivity :
tab.removeBadge() tab.removeBadge()
} else { } else {
val badge = tab.orCreateBadge val badge = tab.orCreateBadge
badge.maxCharacterCount = 3
badge.number = newChapters badge.number = newChapters
badge.isVisible = true badge.isVisible = true
} }
@@ -275,8 +276,8 @@ class DetailsActivity :
ReaderActivity.newIntent( ReaderActivity.newIntent(
context = this@DetailsActivity, context = this@DetailsActivity,
manga = remoteManga, manga = remoteManga,
state = ReaderState(chapterId, 0, 0) state = ReaderState(chapterId, 0, 0),
) ),
) )
} }
setNeutralButton(R.string.download) { _, _ -> setNeutralButton(R.string.download) { _, _ ->
@@ -350,8 +351,8 @@ class DetailsActivity :
dialogBuilder.setMessage( dialogBuilder.setMessage(
getString( getString(
R.string.large_manga_save_confirm, R.string.large_manga_save_confirm,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
) ),
).setPositiveButton(R.string.save) { _, _ -> ).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, manga) DownloadService.start(this, manga)
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
import android.app.ActivityOptions import android.app.ActivityOptions
import android.os.Bundle import android.os.Bundle
import android.text.Spanned
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.view.* import android.view.*
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
@@ -10,18 +9,15 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -33,6 +29,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -49,6 +46,7 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
class DetailsFragment : class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
@@ -82,6 +80,7 @@ class DetailsFragment :
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
addMenuProvider(DetailsMenuProvider()) addMenuProvider(DetailsMenuProvider())
} }
@@ -126,18 +125,6 @@ class DetailsFragment :
else -> textViewState.isVisible = false else -> textViewState.isVisible = false
} }
// Info containers
val chapters = manga.chapters
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(
R.plurals.chapters,
chapters.size,
chapters.size,
)
}
if (manga.hasRating) { if (manga.hasRating) {
infoLayout.textViewRating.text = String.format("%.1f", manga.rating * 5) infoLayout.textViewRating.text = String.format("%.1f", manga.rating * 5)
infoLayout.ratingContainer.isVisible = true infoLayout.ratingContainer.isVisible = true
@@ -164,14 +151,27 @@ class DetailsFragment :
infoLayout.textViewNsfw.isVisible = manga.isNsfw infoLayout.textViewNsfw.isVisible = manga.isNsfw
// Buttons
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
// Chips // Chips
bindTags(manga) bindTags(manga)
} }
} }
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val infoLayout = binding.infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(
R.plurals.chapters,
chapters.size,
chapters.size,
)
}
// Buttons
binding.buttonRead.isEnabled = !chapters.isNullOrEmpty()
}
private fun onDescriptionChanged(description: CharSequence?) { private fun onDescriptionChanged(description: CharSequence?) {
if (description.isNullOrBlank()) { if (description.isNullOrBlank()) {
binding.textViewDescription.setText(R.string.no_description) binding.textViewDescription.setText(R.string.no_description)
@@ -266,7 +266,7 @@ class DetailsFragment :
context = context ?: return, context = context ?: return,
manga = manga, manga = manga,
branch = viewModel.selectedBranchValue, branch = viewModel.selectedBranchValue,
) ),
) )
} }
} }
@@ -276,14 +276,14 @@ class DetailsFragment :
context = v.context, context = v.context,
source = manga.source, source = manga.source,
query = manga.author ?: return, query = manga.author ?: return,
) ),
) )
} }
R.id.imageView_cover -> { R.id.imageView_cover -> {
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height) val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity( startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }), ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
options.toBundle() options.toBundle(),
) )
} }
} }
@@ -309,8 +309,8 @@ class DetailsFragment :
c.chapter.branch == branch c.chapter.branch == branch
}?.let { c -> }?.let { c ->
ReaderState(c.chapter.id, 0, 0) ReaderState(c.chapter.id, 0, 0)
} },
) ),
) )
true true
} }
@@ -343,7 +343,7 @@ class DetailsFragment :
icon = 0, icon = 0,
data = tag, data = tag,
) )
} },
) )
} }
@@ -355,13 +355,22 @@ class DetailsFragment :
} }
val request = ImageRequest.Builder(context ?: return) val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover) .target(binding.imageViewCover)
.size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl) .data(imageUrl)
.crossfade(true) .crossfade(true)
.referer(manga.publicUrl) .referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner) .lifecycle(viewLifecycleOwner)
lastResult?.drawable?.let { .placeholderMemoryCacheKey(manga.coverUrl)
request.fallback(it) val previousDrawable = lastResult?.drawable
} ?: request.fallback(R.drawable.ic_placeholder) if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
}
request.enqueueWith(coil) request.enqueueWith(coil)
} }

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.text.Html import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -33,7 +38,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class DetailsViewModel( class DetailsViewModel(
intent: MangaIntent, intent: MangaIntent,
@@ -91,8 +96,8 @@ class DetailsViewModel(
if (description.isNullOrEmpty()) { if (description.isNullOrEmpty()) {
emit(null) emit(null)
} else { } else {
emit(description.parseAsHtml()) emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter)) emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
@@ -110,7 +115,7 @@ class DetailsViewModel(
val selectedBranchIndex = combine( val selectedBranchIndex = combine(
branches.asFlow(), branches.asFlow(),
delegate.selectedBranch delegate.selectedBranch,
) { branches, selected -> ) { branches, selected ->
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
@@ -161,7 +166,7 @@ class DetailsViewModel(
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga) val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file") localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatching { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
onMangaRemoved.postCall(manga) onMangaRemoved.postCall(manga)
@@ -200,7 +205,7 @@ class DetailsViewModel(
reload() reload()
} else { } else {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
runCatching { runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga) localMangaRepository.getDetails(downloadedManga)
}.onSuccess { }.onSuccess {
delegate.relatedManga.value = it delegate.relatedManga.value = it
@@ -225,7 +230,7 @@ class DetailsViewModel(
fun unregisterScrobbling() { fun unregisterScrobbling() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling( scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId mangaId = delegate.mangaId,
) )
} }
} }
@@ -242,4 +247,13 @@ class DetailsViewModel(
it.chapter.name.contains(query, ignoreCase = true) it.chapter.name.contains(query, ignoreCase = true)
} }
} }
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
} }

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -16,10 +16,8 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaDetailsDelegate( class MangaDetailsDelegate(
private val intent: MangaIntent, private val intent: MangaIntent,
@@ -45,16 +43,11 @@ class MangaDetailsDelegate(
manga = MangaRepository(manga.source).getDetails(manga) manga = MangaRepository(manga.source).getDetails(manga)
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) { selectedBranch.value = manga.getPreferredBranch(hist)
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
mangaData.value = manga mangaData.value = manga
relatedManga.value = runCatching { relatedManga.value = runCatchingCancellable {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
MangaRepository(m.source).getDetails(m) MangaRepository(m.source).getDetails(m)
} else { } else {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)
@@ -91,7 +84,7 @@ class MangaDetailsDelegate(
val dateFormat = settings.getDateFormat() val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId } val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id } val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id }
for (i in chapters.indices) { for (i in chapters.indices) {
val chapter = chapters[i] val chapter = chapters[i]
if (chapter.branch != branch) { if (chapter.branch != branch) {
@@ -106,6 +99,9 @@ class MangaDetailsDelegate(
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} }
if (result.size < chapters.size / 2) {
result.trimToSize()
}
return result return result
} }
@@ -161,24 +157,9 @@ class MangaDetailsDelegate(
} }
result.sortBy { it.chapter.number } result.sortBy { it.chapter.number }
} }
if (result.size < sourceChapters.size / 2) {
result.trimToSize()
}
return result return result
} }
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}
} }

View File

@@ -1,13 +1,24 @@
package org.koitharu.kotatsu.details.ui.model package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem( class ChapterListItem(
val chapter: MangaChapter, val chapter: MangaChapter,
val flags: Int, val flags: Int,
val uploadDate: String?, private val uploadDateMs: Long,
private val dateFormat: DateFormat,
) { ) {
var uploadDate: String? = null
private set
get() {
if (field != null) return field
if (uploadDateMs == 0L) return null
field = dateFormat.format(uploadDateMs)
return field
}
val status: Int val status: Int
get() = flags and MASK_STATUS get() = flags and MASK_STATUS
@@ -32,7 +43,8 @@ class ChapterListItem(
if (chapter != other.chapter) return false if (chapter != other.chapter) return false
if (flags != other.flags) return false if (flags != other.flags) return false
if (uploadDate != other.uploadDate) return false if (uploadDateMs != other.uploadDateMs) return false
if (dateFormat != other.dateFormat) return false
return true return true
} }
@@ -40,7 +52,8 @@ class ChapterListItem(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = chapter.hashCode() var result = chapter.hashCode()
result = 31 * result + flags result = 31 * result + flags
result = 31 * result + (uploadDate?.hashCode() ?: 0) result = 31 * result + uploadDateMs.hashCode()
result = 31 * result + dateFormat.hashCode()
return result return result
} }
@@ -53,4 +66,4 @@ class ChapterListItem(
const val FLAG_DOWNLOADED = 32 const val FLAG_DOWNLOADED = 32
const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
} }
} }

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.details.ui.model package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import java.text.DateFormat
fun MangaChapter.toListItem( fun MangaChapter.toListItem(
isCurrent: Boolean, isCurrent: Boolean,
@@ -25,6 +25,7 @@ fun MangaChapter.toListItem(
return ChapterListItem( return ChapterListItem(
chapter = this, chapter = this,
flags = flags, flags = flags,
uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null uploadDateMs = uploadDate,
dateFormat = dateFormat,
) )
} }

View File

@@ -6,11 +6,21 @@ import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import java.io.File import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
@@ -26,6 +36,7 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
private const val MAX_FAILSAFE_ATTEMPTS = 2 private const val MAX_FAILSAFE_ATTEMPTS = 2
@@ -43,10 +54,10 @@ class DownloadManager(
) { ) {
private val coverWidth = context.resources.getDimensionPixelSize( private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width androidx.core.R.dimen.compat_notification_large_icon_max_width,
) )
private val coverHeight = context.resources.getDimensionPixelSize( private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height androidx.core.R.dimen.compat_notification_large_icon_max_height,
) )
private val semaphore = Semaphore(settings.downloadsParallelism) private val semaphore = Semaphore(settings.downloadsParallelism)
@@ -56,105 +67,115 @@ class DownloadManager(
startId: Int, startId: Int,
): PausingProgressJob<DownloadState> { ): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>( val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null) DownloadState.Queued(startId = startId, manga = manga, cover = null),
) )
val pausingHandle = PausingHandle() val pausingHandle = PausingHandle()
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId) val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle) return PausingProgressJob(job, stateFlow, pausingHandle)
} }
private fun downloadMangaImpl( private suspend fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle, pausingHandle: PausingHandle,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ) {
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
var manga = manga var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet() val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga) val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover) outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id) withMangaLock(manga) {
semaphore.acquire() semaphore.withPermit {
coroutineContext[WakeLockNode]?.acquire() outState.value = DownloadState.Preparing(startId, manga, null)
outState.value = DownloadState.Preparing(startId, manga, null) val destination = localMangaRepository.getOutputDir()
val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$startId.tmp"
val tempFileName = "${manga.id}_$startId.tmp" var output: CbzMangaOutput? = null
var output: CbzMangaOutput? = null try {
try { if (manga.source == MangaSource.LOCAL) {
if (manga.source == MangaSource.LOCAL) { manga = localMangaRepository.getRemoteManga(manga)
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url)
)
} }
outState.value = DownloadState.Progress( val repo = MangaRepository(manga.source)
startId = startId, outState.value = DownloadState.Preparing(startId, manga, cover)
manga = data, val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
cover = cover, output = CbzMangaOutput.get(destination, data)
totalChapters = chapters.size, val coverUrl = data.largeCoverUrl ?: data.coverUrl
currentChapter = chapterIndex, downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
totalPages = pages.size, output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
currentPage = pageIndex }
) val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) { if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY) delay(SLOWDOWN_DELAY)
}
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finish()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
} }
} }
} }
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finalize()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
} }
} }
@@ -203,27 +224,35 @@ class DownloadManager(
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value val prevValue = outState.value
outState.value = DownloadState.Error( outState.value = DownloadState.Error(
startId = prevValue.startId, startId = prevValue.startId,
manga = prevValue.manga, manga = prevValue.manga,
cover = prevValue.cover, cover = prevValue.cover,
error = throwable, error = throwable,
canRetry = false canRetry = false,
) )
} }
private suspend fun loadCover(manga: Manga) = runCatching { private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute( imageLoader.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.referer(manga.publicUrl) .referer(manga.publicUrl)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL) .scale(Scale.FILL)
.build() .build(),
).drawable ).drawable
}.getOrNull() }.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
class Factory( class Factory(
private val context: Context, private val context: Context,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
@@ -240,7 +269,7 @@ class DownloadManager(
okHttp = okHttp, okHttp = okHttp,
cache = cache, cache = cache,
localMangaRepository = localMangaRepository, localMangaRepository = localMangaRepository,
settings = settings settings = settings,
) )
} }
} }

View File

@@ -108,34 +108,6 @@ sealed interface DownloadState {
} }
} }
@Deprecated("TODO: remove")
class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WaitingForNetwork
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Done( class Done(
override val startId: Int, override val startId: Int,
override val manga: Manga, override val manga: Manga,

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.os.PowerManager
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class WakeLockNode(
private val wakeLock: PowerManager.WakeLock,
private val timeout: Long,
) : AbstractCoroutineContextElement(Key) {
init {
wakeLock.setReferenceCounted(true)
}
fun acquire() {
wakeLock.acquire(timeout)
}
fun release() {
wakeLock.release()
}
companion object Key : CoroutineContext.Key<WakeLockNode>
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -9,21 +10,33 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>( ) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) { ) {
var job: Job? = null var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> item.cancel()
R.id.button_resume -> item.resume()
else -> context.startActivity(
DetailsActivity.newIntent(context, item.progressValue.manga),
)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
bind { bind {
job?.cancel() job?.cancel()
job = item.progressAsFlow().onFirst { state -> job = item.progressAsFlow().onFirst { state ->
@@ -44,6 +57,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
} }
is DownloadState.Done -> { is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete) binding.textViewStatus.setText(R.string.download_complete)
@@ -51,6 +66,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
} }
is DownloadState.Error -> { is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred) binding.textViewStatus.setText(R.string.error_occurred)
@@ -59,6 +76,8 @@ fun downloadItemAD(
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_) binding.textViewStatus.setText(R.string.processing_)
@@ -66,6 +85,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
} }
is DownloadState.Preparing -> { is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_) binding.textViewStatus.setText(R.string.preparing_)
@@ -73,6 +94,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_) binding.textViewStatus.setText(R.string.manga_downloading_)
@@ -83,6 +106,8 @@ fun downloadItemAD(
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1)) binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
} }
is DownloadState.Queued -> { is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued) binding.textViewStatus.setText(R.string.queued)
@@ -90,13 +115,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false binding.textViewDetails.isVisible = false
} binding.buttonCancel.isVisible = true
is DownloadState.WaitingForNetwork -> { binding.buttonResume.isVisible = false
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
} }
} }
}.launchIn(scope) }.launchIn(scope)

View File

@@ -1,21 +1,23 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@@ -26,30 +28,63 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
val adapter = DownloadsAdapter(lifecycleScope, get()) val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
bindServiceWithLifecycle( val connection = DownloadServiceConnection(adapter)
owner = this, bindService(Intent(this, DownloadService::class.java), connection, 0)
service = Intent(this, DownloadService::class.java), lifecycle.addObserver(connection)
flags = 0,
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
adapter.items = it?.toList().orEmpty()
binding.textViewHolder.isVisible = it.isNullOrEmpty()
}.launchIn(lifecycleScope)
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
bottom = insets.bottom bottom = insets.bottom,
) )
binding.toolbar.updatePadding( binding.toolbar.updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right,
) )
} }
private inner class DownloadServiceConnection(
private val adapter: DownloadsAdapter,
) : ServiceConnection, DefaultLifecycleObserver {
private var collectJob: Job? = null
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleScope.launch {
binder.downloads.collect {
setItems(it)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
setItems(null)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
collectJob?.cancel()
collectJob = null
owner.lifecycle.removeObserver(this)
unbindService(this)
}
private fun setItems(items: Collection<DownloadItem>?) {
adapter.items = items?.toList().orEmpty()
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
}
companion object { companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)

View File

@@ -5,12 +5,14 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob<DownloadState>
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadState>>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope, coil)) delegatesManager.addDelegate(downloadItemAD(scope, coil))
@@ -21,18 +23,18 @@ class DownloadsAdapter(
return items[position].progressValue.startId.toLong() return items[position].progressValue.startId.toLong()
} }
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadState>>() { private class DiffCallback : DiffUtil.ItemCallback<DownloadItem>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: ProgressJob<DownloadState>, oldItem: DownloadItem,
newItem: ProgressJob<DownloadState>, newItem: DownloadItem,
): Boolean { ): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId return oldItem.progressValue.startId == newItem.progressValue.startId
} }
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: ProgressJob<DownloadState>, oldItem: DownloadItem,
newItem: ProgressJob<DownloadState>, newItem: DownloadItem,
): Boolean { ): Boolean {
return oldItem.progressValue == newItem.progressValue return oldItem.progressValue == newItem.progressValue
} }

View File

@@ -7,163 +7,307 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context, startId: Int) { class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast(
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
context.getString(R.string.try_again),
PendingIntent.getBroadcast(
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val listIntent = PendingIntent.getActivity( private val listIntent = PendingIntent.getActivity(
context, context,
REQUEST_LIST, REQUEST_LIST,
DownloadsActivity.newIntent(context), DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE PendingIntentCompat.FLAG_IMMUTABLE,
) )
init { init {
builder.setOnlyAlertOnce(true) groupBuilder.setOnlyAlertOnce(true)
builder.setDefaults(0) groupBuilder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary) groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true) groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setContentIntent(listIntent)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
} }
fun create(state: DownloadState, timeLeft: Long): Notification { fun buildGroupNotification(): Notification {
builder.setContentTitle(state.manga.title) val style = NotificationCompat.InboxStyle(groupBuilder)
builder.setContentText(context.getString(R.string.manga_downloading_)) var progress = 0f
builder.setProgress(1, 0, true) var isAllDone = true
builder.setSmallIcon(android.R.drawable.stat_sys_download) var isInProgress = false
builder.setContentIntent(listIntent) groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
builder.setStyle(null) states.forEach { _, state ->
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setVisibility(
if (state.manga.isNsfw) { if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
} else {
NotificationCompat.VISIBILITY_PUBLIC
} }
) val summary = when (state) {
when (state) { is DownloadState.Cancelled -> {
is DownloadState.Cancelled -> { progress++
builder.setProgress(1, 0, true) context.getString(R.string.cancelling_)
builder.setContentText(context.getString(R.string.cancelling_)) }
builder.setContentIntent(null) is DownloadState.Done -> {
builder.setStyle(null) progress++
builder.setOngoing(true) context.getString(R.string.download_complete)
} }
is DownloadState.Done -> { is DownloadState.Error -> {
builder.setProgress(0, 0, false) isAllDone = false
builder.setContentText(context.getString(R.string.download_complete)) context.getString(R.string.error)
builder.setContentIntent(createMangaIntent(context, state.localManga)) }
builder.setAutoCancel(true) is DownloadState.PostProcessing -> {
builder.setSmallIcon(android.R.drawable.stat_sys_download_done) progress++
builder.setCategory(null) isInProgress = true
builder.setStyle(null) isAllDone = false
builder.setOngoing(false) context.getString(R.string.processing_)
} }
is DownloadState.Error -> { is DownloadState.Preparing -> {
val message = state.error.getDisplayMessage(context.resources) isAllDone = false
builder.setProgress(0, 0, false) isInProgress = true
builder.setSmallIcon(android.R.drawable.stat_notify_error) context.getString(R.string.preparing_)
builder.setSubText(context.getString(R.string.error)) }
builder.setContentText(message) is DownloadState.Progress -> {
builder.setAutoCancel(!state.canRetry) isAllDone = false
builder.setOngoing(state.canRetry) isInProgress = true
builder.setCategory(NotificationCompat.CATEGORY_ERROR) progress += state.percent
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (state.canRetry) { }
builder.addAction(cancelAction) is DownloadState.Queued -> {
builder.addAction(retryAction) isAllDone = false
isInProgress = true
context.getString(R.string.queued)
} }
} }
is DownloadState.PostProcessing -> { style.addLine(
builder.setProgress(1, 0, true) context.getString(
builder.setContentText(context.getString(R.string.processing_)) R.string.download_summary_pattern,
builder.setStyle(null) state.manga.title.ellipsize(16).htmlEncode(),
builder.setOngoing(true) summary.htmlEncode(),
} ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
is DownloadState.Queued -> { )
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
} else {
val percent = (state.percent * 100).format()
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.WaitingForNetwork -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
} }
return builder.build() progress = if (isInProgress) {
progress / states.size.toFloat()
} else {
1f
}
style.setBigContentTitle(
context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga),
)
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done,
)
groupBuilder.setAutoCancel(isAllDone)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false)
}
return groupBuilder.build()
}
fun detach() {
if (states.isNotEmpty()) {
val notification = buildGroupNotification()
manager.notify(ID_GROUP_DETACHED, notification)
}
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast(
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
context.getString(R.string.try_again),
PendingIntent.getBroadcast(
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
fun notify(state: DownloadState, timeLeft: Long) {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(listIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when (state) {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(!state.canRetry)
builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
builder.setSubText(percent)
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
}
}
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
} }
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
context, context,
manga.hashCode(), manga.hashCode(),
DetailsActivity.newIntent(context, manga), DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
) )
companion object { companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download" private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
private const val REQUEST_LIST = 6 private const val REQUEST_LIST = 6
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -172,7 +316,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
context.getString(R.string.downloads), context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW,
) )
channel.enableVibration(false) channel.enableVibration(false)
channel.enableLights(false) channel.enableLights(false)

View File

@@ -8,13 +8,16 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@@ -25,7 +28,6 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
@@ -36,23 +38,23 @@ import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>() private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0) private val jobCount = MutableStateFlow(0)
private val controlReceiver = ControlReceiver() private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this) downloadNotification = DownloadNotification(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = get<DownloadManager.Factory>().create( wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)) downloadManager = get<DownloadManager.Factory>().create(lifecycleScope)
)
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter() val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME) intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
@@ -68,24 +70,19 @@ class DownloadService : BaseService() {
jobCount.value = jobs.size jobCount.value = jobs.size
START_REDELIVER_INTENT START_REDELIVER_INTENT
} else { } else {
stopSelf(startId) stopSelfIfIdle()
START_NOT_STICKY START_NOT_STICKY
} }
} }
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
super.onBind(intent) super.onBind(intent)
return binder ?: DownloadBinder(this).also { binder = it } return DownloadBinder(this)
}
override fun onUnbind(intent: Intent?): Boolean {
binder = null
return super.onUnbind(intent)
} }
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
binder = null wakeLock.release()
isRunning = false isRunning = false
super.onDestroy() super.onDestroy()
} }
@@ -103,10 +100,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId) val notificationItem = downloadNotification.newItem(startId)
try { try {
val timeLeftEstimator = TimeLeftEstimator() val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow() job.progressAsFlow()
.onEach { state -> .onEach { state ->
if (state is DownloadState.Progress) { if (state is DownloadState.Progress) {
@@ -119,26 +116,27 @@ class DownloadService : BaseService() {
.whileActive() .whileActive()
.collect { state -> .collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft)) notificationItem.notify(state, timeLeft)
} }
job.join() job.join()
} finally { } finally {
(job.progressValue as? DownloadState.Done)?.let { (job.progressValue as? DownloadState.Done)?.let {
sendBroadcast( sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE) Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)) .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
) )
} }
notificationSwitcher.detach( if (job.isCancelled) {
startId, notificationItem.dismiss()
if (job.isCancelled) { if (jobs.remove(startId) != null) {
null jobCount.value = jobs.size
} else {
notification.create(job.progressValue, -1L)
} }
) } else {
stopSelf(startId) notificationItem.notify(job.progressValue, -1L)
}
} }
}.invokeOnCompletion {
stopSelfIfIdle()
} }
} }
@@ -150,14 +148,25 @@ class DownloadService : BaseService() {
private val DownloadState.isTerminal: Boolean private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry) get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
return
}
downloadNotification.detach()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> { ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel() jobs[cancelId]?.cancel()
jobCount.value = jobs.size // jobs.remove(cancelId)?.cancel()
// jobCount.value = jobs.size
} }
ACTION_DOWNLOAD_RESUME -> { ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
@@ -167,10 +176,25 @@ class DownloadService : BaseService() {
} }
} }
class DownloadBinder(private val service: DownloadService) : Binder() { class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
val downloads: Flow<Collection<ProgressJob<DownloadState>>> private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
get() = service.jobCount.mapLatest { service.jobs.values }
init {
service.lifecycle.addObserver(this)
service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope)
}
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
downloadsStateFlow.value = emptyList()
super.onDestroy(owner)
}
val downloads
get() = downloadsStateFlow.asStateFlow()
} }
companion object { companion object {

View File

@@ -1,62 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.SparseArray
import androidx.core.app.ServiceCompat
import androidx.core.util.isEmpty
import androidx.core.util.size
private const val DEFAULT_DELAY = 500L
class ForegroundNotificationSwitcher(
private val service: Service,
) {
private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notifications = SparseArray<Notification>()
private val handler = Handler(Looper.getMainLooper())
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
service.startForeground(startId, notification)
} else {
notificationManager.notify(startId, notification)
}
notifications[startId] = notification
}
@Synchronized
fun detach(startId: Int, notification: Notification?) {
notifications.remove(startId)
if (notifications.isEmpty()) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH)
}
val nextIndex = notifications.size - 1
if (nextIndex >= 0) {
val nextStartId = notifications.keyAt(nextIndex)
val nextNotification = notifications.valueAt(nextIndex)
service.startForeground(nextStartId, nextNotification)
}
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
}
private inner class NotifyRunnable(
private val startId: Int,
private val notification: Notification?,
) : Runnable {
override fun run() {
if (notification != null) {
notificationManager.notify(startId, notification)
} else {
notificationManager.cancel(startId)
}
}
}
}

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class FavouritesListViewModel( class FavouritesListViewModel(
private val categoryId: Long, private val categoryId: Long,
@@ -45,7 +46,7 @@ class FavouritesListViewModel(
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
createListModeFlow() createListModeFlow(),
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@@ -58,8 +59,9 @@ class FavouritesListViewModel(
R.string.favourites_category_empty R.string.favourites_category_empty
}, },
actionStringRes = 0, actionStringRes = 0,
) ),
) )
else -> list.toUi(mode, this) else -> list.toUi(mode, this)
} }
}.catch { }.catch {

View File

@@ -5,9 +5,9 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import com.google.android.material.R as materialR
class HistoryListMenuProvider( class HistoryListMenuProvider(
private val context: Context, private val context: Context,
@@ -38,6 +38,6 @@ class HistoryListMenuProvider(
} }
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_history_grouping).isChecked = viewModel.isGroupingEnabled.value == true menu.findItem(R.id.action_history_grouping)?.isChecked = viewModel.isGroupingEnabled.value == true
} }
} }

View File

@@ -1,13 +1,16 @@
@file:SuppressLint("UnsafeOptInUsageError") @file:SuppressLint("UnsafeOptInUsageError")
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.View import android.view.View
import androidx.annotation.CheckResult
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.BadgeUtils
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@CheckResult
fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
return if (counter > 0) { return if (counter > 0) {
val badgeDrawable = badge ?: initBadge(this) val badgeDrawable = badge ?: initBadge(this)

View File

@@ -1,23 +1,31 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD( fun emptyStateListAD(
coil: ImageLoader,
listener: MangaListListener, listener: MangaListListener,
) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>( ) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
) { ) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
bind { bind {
binding.icon.setImageResource(item.icon) binding.icon.newImageRequest(item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary) binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes) binding.buttonRetry.setTextAndVisible(item.actionStringRes)
} }
onViewRecycled {
binding.icon.disposeImageRequest()
}
} }

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD( fun mangaGridItemAD(
coil: ImageLoader, coil: ImageLoader,
@@ -24,9 +25,8 @@ fun mangaGridItemAD(
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
sizeResolver: ItemSizeResolver?, sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>( ) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -46,6 +46,7 @@ fun mangaGridItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run { binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl) referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)

View File

@@ -4,9 +4,9 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import kotlin.jvm.internal.Intrinsics
class MangaListAdapter( class MangaListAdapter(
coil: ImageLoader, coil: ImageLoader,
@@ -24,7 +24,7 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD()) .addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener)) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) .addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(listener)) .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(listener))
.addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(listener)) .addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(listener))

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -12,15 +11,16 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaListDetailedItemAD( fun mangaListDetailedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>( ) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -36,6 +36,7 @@ fun mangaListDetailedItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run { binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl) referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
@@ -45,7 +46,7 @@ fun mangaListDetailedItemAD(
} }
binding.textViewRating.textAndVisible = item.rating binding.textViewRating.textAndVisible = item.rating
binding.textViewTags.text = item.tags binding.textViewTags.text = item.tags
itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
onViewRecycled { onViewRecycled {

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -11,15 +10,15 @@ import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
fun mangaListItemAD( fun mangaListItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>( ) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -41,7 +40,7 @@ fun mangaListItemAD(
lifecycle(lifecycleOwner) lifecycle(lifecycleOwner)
enqueueWith(coil) enqueueWith(coil)
} }
itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
onViewRecycled { onViewRecycled {

View File

@@ -6,15 +6,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.text.Collator import java.text.Collator
import java.util.* import java.util.Locale
import java.util.TreeSet
class FilterCoordinator( class FilterCoordinator(
private val repository: RemoteMangaRepository, private val repository: RemoteMangaRepository,
@@ -152,7 +159,7 @@ class FilterCoordinator(
} }
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatching { runCatchingCancellable {
repository.getTags() repository.getTags()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
@@ -203,4 +210,4 @@ class FilterCoordinator(
return collator?.compare(t1, t2) ?: compareValues(t1, t2) return collator?.compare(t1, t2) ?: compareValues(t1, t2)
} }
} }
} }

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.local.domain package org.koitharu.kotatsu.local.domain
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import java.io.File
import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
@@ -11,8 +13,6 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import java.io.File
import java.util.zip.ZipFile
class CbzMangaOutput( class CbzMangaOutput(
val file: File, val file: File,
@@ -62,7 +62,7 @@ class CbzMangaOutput(
index.addChapter(chapter) index.addChapter(chapter)
} }
suspend fun finalize() { suspend fun finish() {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString()) output.put(ENTRY_NAME_INDEX, index.toString())
output.finish() output.finish()
@@ -89,7 +89,7 @@ class CbzMangaOutput(
otherIndex = MangaIndex( otherIndex = MangaIndex(
zip.getInputStream(entry).use { zip.getInputStream(entry).use {
it.reader().readText() it.reader().readText()
} },
) )
} else { } else {
output.copyEntryFrom(zip, entry) output.copyEntryFrom(zip, entry)

View File

@@ -8,19 +8,31 @@ import androidx.collection.ArraySet
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import java.io.File import java.io.File
import java.io.IOException import java.util.Enumeration
import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex import org.koitharu.kotatsu.utils.CompositeMutex
@@ -28,6 +40,7 @@ import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@@ -48,6 +61,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
x.altTitle?.contains(query, ignoreCase = true) == true x.altTitle?.contains(query, ignoreCase = true) == true
} }
} }
list.sortWith(compareBy(AlphanumComparator()) { x -> x.title })
return list return list
} }
@@ -61,6 +75,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
x.tags.containsAll(tags) x.tags.containsAll(tags)
} }
} }
list.sortWith(compareBy(AlphanumComparator()) { x -> x.title })
return list return list
} }
@@ -68,6 +83,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved" "Manga is not local or saved"
} }
else -> getFromFile(Uri.parse(manga.url).toFile()) else -> getFromFile(Uri.parse(manga.url).toFile())
} }
@@ -86,7 +102,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
entries.filter { x -> entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast( !x.isDirectory && x.name.substringBeforeLast(
File.separatorChar, File.separatorChar,
"" "",
) == parent ) == parent
} }
} }
@@ -138,11 +154,11 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
url = fileUri, url = fileUri,
coverUrl = zipUri( coverUrl = zipUri(
file, file,
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty() entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
), ),
chapters = info.chapters?.map { c -> chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL) c.copy(url = fileUri, source = MangaSource.LOCAL)
} },
) )
} }
// fallback // fallback
@@ -211,7 +227,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return@runInterruptible info.copy2( return@runInterruptible info.copy2(
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
url = fileUri, url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) } chapters = info.chapters?.map { c -> c.copy(url = fileUri) },
) )
} }
} }
@@ -224,7 +240,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
context: CoroutineContext, context: CoroutineContext,
): Deferred<Manga?> = async(context) { ): Deferred<Manga?> = async(context) {
runInterruptible { runInterruptible {
runCatching { getFromFile(file) }.getOrNull() runCatchingCancellable { getFromFile(file) }.getOrNull()
} }
} }
@@ -288,7 +304,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
locks.lock(id) locks.lock(id)
} }
suspend fun unlockManga(id: Long) { fun unlockManga(id: Long) {
locks.unlock(id) locks.unlock(id)
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.ui
import android.net.Uri import android.net.Uri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.IOException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@@ -21,8 +23,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
private val repository: LocalMangaRepository, private val repository: LocalMangaRepository,
@@ -40,7 +42,7 @@ class LocalListViewModel(
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), createListModeFlow(),
listError listError,
) { list, mode, error -> ) { list, mode, error ->
when { when {
error != null -> listOf(error.toErrorState(canRetry = true)) error != null -> listOf(error.toErrorState(canRetry = true))
@@ -51,8 +53,9 @@ class LocalListViewModel(
textPrimary = R.string.text_local_holder_primary, textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary, textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import, actionStringRes = R.string._import,
) ),
) )
else -> ArrayList<ListModel>(list.size + 1).apply { else -> ArrayList<ListModel>(list.size + 1).apply {
add(headerModel) add(headerModel)
list.toUi(this, mode) list.toUi(this, mode)
@@ -60,7 +63,7 @@ class LocalListViewModel(
} }
}.asLiveDataDistinct( }.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default, viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState) listOf(LoadingState),
) )
init { init {
@@ -97,7 +100,7 @@ class LocalListViewModel(
for (manga in itemsToRemove) { for (manga in itemsToRemove) {
val original = repository.getRemoteManga(manga) val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file") repository.delete(manga) || throw IOException("Unable to delete file")
runCatching { runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original) historyRepository.deleteOrSwap(manga, original)
} }
mangaList.update { list -> mangaList.update { list ->
@@ -113,6 +116,8 @@ class LocalListViewModel(
try { try {
listError.value = null listError.value = null
mangaList.value = repository.getList(0, null, null) mangaList.value = repository.getList(0, null, null)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }
@@ -121,7 +126,7 @@ class LocalListViewModel(
private fun cleanup() { private fun cleanup() {
if (!DownloadService.isRunning) { if (!DownloadService.isRunning) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatchingCancellable {
repository.cleanup() repository.cleanup()
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()

View File

@@ -12,19 +12,24 @@ import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.* import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -59,7 +64,6 @@ import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary" private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -94,10 +98,10 @@ class MainActivity :
it, it,
binding.toolbar, binding.toolbar,
R.string.open_menu, R.string.open_menu,
R.string.close_menu R.string.close_menu,
).apply { ).apply {
setHomeAsUpIndicator( setHomeAsUpIndicator(
ContextCompat.getDrawable(this@MainActivity, materialR.drawable.abc_ic_ab_back_material) ContextCompat.getDrawable(this@MainActivity, materialR.drawable.abc_ic_ab_back_material),
) )
setToolbarNavigationClickListener { setToolbarNavigationClickListener {
binding.searchView.hideKeyboard() binding.searchView.hideKeyboard()
@@ -429,7 +433,12 @@ class MainActivity :
} }
private fun onFirstStart() { private fun onFirstStart() {
lifecycleScope.launchWhenResumed { lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
val settings = get<AppSettings>()
when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
val isUpdateSupported = withContext(Dispatchers.Default) { val isUpdateSupported = withContext(Dispatchers.Default) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
@@ -438,11 +447,6 @@ class MainActivity :
if (isUpdateSupported) { if (isUpdateSupported) {
AppUpdateChecker(this@MainActivity).checkIfNeeded() AppUpdateChecker(this@MainActivity).checkIfNeeded()
} }
val settings = get<AppSettings>()
when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
} }
} }
@@ -469,7 +473,7 @@ class MainActivity :
val drawer = drawer ?: return val drawer = drawer ?: return
val isLocked = actionModeDelegate.isActionModeStarted || isSearchOpened val isLocked = actionModeDelegate.isActionModeStarted || isSearchOpened
drawer.setDrawerLockMode( drawer.setDrawerLockMode(
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED,
) )
drawerToggle?.isDrawerIndicatorEnabled = !isLocked drawerToggle?.isDrawerIndicatorEnabled = !isLocked
} }

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.app.Application import android.app.Application
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
class AppProtectHelper(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks { class AppProtectHelper(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks {
@@ -11,7 +12,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity
private var isUnlocked = settings.appPassword.isNullOrEmpty() private var isUnlocked = settings.appPassword.isNullOrEmpty()
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is ProtectActivity && !isUnlocked) { if (!isUnlocked && activity !is ProtectActivity && activity !is CrashReportDialog) {
val sourceIntent = Intent(activity, activity.javaClass) val sourceIntent = Intent(activity, activity.javaClass)
activity.intent?.let { activity.intent?.let {
sourceIntent.putExtras(it) sourceIntent.putExtras(it)

View File

@@ -46,7 +46,10 @@ class ProtectActivity :
startActivity(intent) startActivity(intent)
finishAfterTransition() finishAfterTransition()
} }
}
override fun onStart() {
super.onStart()
if (!useFingerprint()) { if (!useFingerprint()) {
binding.editPassword.requestFocus() binding.editPassword.requestFocus()
} }

View File

@@ -6,9 +6,22 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import kotlinx.coroutines.* import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -26,18 +39,15 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.ProgressDeferred import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
private const val PROGRESS_UNDEFINED = -1f private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10 private const val PREFETCH_LIMIT_DEFAULT = 10
class PageLoader : KoinComponent, Closeable { class PageLoader : KoinComponent, Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val okHttp = get<OkHttpClient>() private val okHttp = get<OkHttpClient>()
private val cache = get<PagesCache>() private val cache = get<PagesCache>()
@@ -194,4 +204,13 @@ class PageLoader : KoinComponent, Closeable {
val deferred = CompletableDeferred(file) val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow) return ProgressDeferred(deferred, emptyProgressFlow)
} }
private class InternalErrorHandler :
AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
}
}
} }

View File

@@ -19,6 +19,7 @@ import androidx.transition.TransitionManager
import androidx.transition.TransitionSet import androidx.transition.TransitionSet
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -47,7 +48,6 @@ import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.util.concurrent.TimeUnit
class ReaderActivity : class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(), BaseFullscreenActivity<ActivityReaderBinding>(),
@@ -67,6 +67,9 @@ class ReaderActivity :
) )
} }
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var touchHelper: GridTouchHelper private lateinit var touchHelper: GridTouchHelper
private lateinit var orientationHelper: ScreenOrientationHelper private lateinit var orientationHelper: ScreenOrientationHelper
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
@@ -82,7 +85,7 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
orientationHelper = ScreenOrientationHelper(this) orientationHelper = ScreenOrientationHelper(this)
controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this) controlDelegate = ReaderControlDelegate(get(), this, this)
binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom) binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
insetsDelegate.interceptingWindowInsetsListener = this insetsDelegate.interceptingWindowInsetsListener = this
@@ -146,7 +149,7 @@ class ReaderActivity :
ChaptersBottomSheet.show( ChaptersBottomSheet.show(
supportFragmentManager, supportFragmentManager,
viewModel.manga?.chapters.orEmpty(), viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L viewModel.getCurrentState()?.chapterId ?: 0L,
) )
} }
R.id.action_screen_rotate -> { R.id.action_screen_rotate -> {
@@ -210,7 +213,7 @@ class ReaderActivity :
val resolveTextId = ExceptionResolver.getResolveStringId(e) val resolveTextId = ExceptionResolver.getResolveStringId(e)
if (resolveTextId != 0) { if (resolveTextId != 0) {
dialog.setPositiveButton(resolveTextId, listener) dialog.setPositiveButton(resolveTextId, listener)
} else { } else if (e.isReportable()) {
dialog.setPositiveButton(R.string.report, listener) dialog.setPositiveButton(R.string.report, listener)
} }
dialog.show() dialog.show()
@@ -317,12 +320,12 @@ class ReaderActivity :
binding.appbarTop.updatePadding( binding.appbarTop.updatePadding(
top = systemBars.top, top = systemBars.top,
right = systemBars.right, right = systemBars.right,
left = systemBars.left left = systemBars.left,
) )
binding.appbarBottom?.updatePadding( binding.appbarBottom?.updatePadding(
bottom = systemBars.bottom, bottom = systemBars.bottom,
right = systemBars.right, right = systemBars.right,
left = systemBars.left left = systemBars.left,
) )
return WindowInsetsCompat.Builder(insets) return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)

View File

@@ -1,33 +1,39 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences
import android.view.KeyEvent import android.view.KeyEvent
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.DefaultLifecycleObserver
import kotlinx.coroutines.Dispatchers import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
class ReaderControlDelegate( class ReaderControlDelegate(
scope: LifecycleCoroutineScope, private val settings: AppSettings,
settings: AppSettings, private val listener: OnInteractionListener,
private val listener: OnInteractionListener owner: LifecycleOwner,
) { ) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
private var isTapSwitchEnabled: Boolean = true private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true
init { init {
settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch } owner.lifecycle.addObserver(this)
.flowOn(Dispatchers.Default) settings.subscribe(this)
.onEach { updateSettings()
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it }
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope) override fun onDestroy(owner: LifecycleOwner) {
settings.unsubscribe(this)
owner.lifecycle.removeObserver(this)
super.onDestroy(owner)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
updateSettings()
} }
fun onGridTouch(area: Int, view: View) { fun onGridTouch(area: Int, view: View) {
@@ -41,7 +47,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP) view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
} }
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1) listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
} }
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
@@ -49,7 +55,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN) view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
} }
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1) listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
} }
} }
@@ -72,17 +78,25 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT -> { -> {
listener.switchPageBy(1) listener.switchPageBy(1)
true true
} }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT -> { -> {
listener.switchPageBy(-1) listener.switchPageBy(-1)
true true
} }
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> { KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility() listener.toggleUiVisibility()
true true
@@ -97,8 +111,21 @@ class ReaderControlDelegate(
) )
} }
private fun updateSettings() {
val switch = settings.readerPageSwitch
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in switch
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in switch
isReaderTapsAdaptive = settings.isReaderTapsAdaptive
}
private fun isReaderTapsReversed(): Boolean {
return isReaderTapsAdaptive && listener.readerMode == ReaderMode.REVERSED
}
interface OnInteractionListener { interface OnInteractionListener {
val readerMode: ReaderMode?
fun switchPageBy(delta: Int) fun switchPageBy(delta: Int)
fun toggleUiVisibility() fun toggleUiVisibility()

View File

@@ -6,6 +6,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.util.Date
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -31,7 +32,7 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import java.util.* import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
private const val PAGES_TRIM_THRESHOLD = 120 private const val PAGES_TRIM_THRESHOLD = 120
@@ -69,7 +70,7 @@ class ReaderViewModel(
mangaName = manga?.title, mangaName = manga?.title,
chapterName = chapter?.name, chapterName = chapter?.name,
chapterNumber = chapter?.number ?: 0, chapterNumber = chapter?.number ?: 0,
chaptersTotal = chapters.size() chaptersTotal = chapters.size(),
) )
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
@@ -80,7 +81,7 @@ class ReaderViewModel(
val readerAnimation = settings.observeAsLiveData( val readerAnimation = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default, context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_READER_ANIMATION, key = AppSettings.KEY_READER_ANIMATION,
valueProducer = { readerAnimation } valueProducer = { readerAnimation },
) )
val isScreenshotsBlockEnabled = combine( val isScreenshotsBlockEnabled = combine(
@@ -123,12 +124,12 @@ class ReaderViewModel(
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(mangaData.value)
dataRepository.savePreferences( dataRepository.savePreferences(
manga = manga, manga = manga,
mode = newMode mode = newMode,
) )
readerMode.value = newMode readerMode.value = newMode
content.value?.run { content.value?.run {
content.value = copy( content.value = copy(
state = getCurrentState() state = getCurrentState(),
) )
} }
} }
@@ -358,7 +359,7 @@ class ReaderViewModel(
?: manga.chapters?.randomOrNull() ?: manga.chapters?.randomOrNull()
?: error("There are no chapters in this manga") ?: error("There are no chapters in this manga")
val pages = repo.getPages(chapter) val pages = repo.getPages(chapter)
return runCatching { return runCatchingCancellable {
val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages) val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
if (isWebtoon) ReaderMode.WEBTOON else defaultMode if (isWebtoon) ReaderMode.WEBTOON else defaultMode
}.onSuccess { }.onSuccess {
@@ -389,7 +390,7 @@ class ReaderViewModel(
*/ */
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job { private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
return processLifecycleScope.launch(Dispatchers.Default) { return processLifecycleScope.launch(Dispatchers.Default) {
runCatching { runCatchingCancellable {
addOrUpdate( addOrUpdate(
manga = manga, manga = manga,
chapterId = state.chapterId, chapterId = state.chapterId,

View File

@@ -13,7 +13,7 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis") @Suppress("LeakingThis")
@@ -37,6 +37,14 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage) protected abstract fun onBind(data: ReaderPage)
@CallSuper
open fun onAttachedToWindow() {
}
@CallSuper
open fun onDetachedFromWindow() {
}
@CallSuper @CallSuper
open fun onRecycled() { open fun onRecycled() {
delegate.onRecycle() delegate.onRecycle()

View File

@@ -4,12 +4,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
@@ -35,6 +35,16 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
super.onViewRecycled(holder) super.onViewRecycled(holder)
} }
override fun onViewAttachedToWindow(holder: H) {
super.onViewAttachedToWindow(holder)
holder.onAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: H) {
super.onViewDetachedFromWindow(holder)
holder.onDetachedFromWindow()
}
open fun getItem(position: Int): ReaderPage = differ.currentList[position] open fun getItem(position: Int): ReaderPage = differ.currentList[position]
open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position) open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position)
@@ -45,7 +55,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder( final override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int,
): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont -> suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
@@ -58,7 +68,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
): H ): H
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() { private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
@@ -70,6 +80,5 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }
} }

View File

@@ -3,24 +3,30 @@ package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.* import java.io.File
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import java.io.IOException
class PageHolderDelegate( class PageHolderDelegate(
private val loader: PageLoader, private val loader: PageLoader,
private val settings: AppSettings, private val settings: AppSettings,
private val callback: Callback, private val callback: Callback,
private val exceptionResolver: ExceptionResolver private val exceptionResolver: ExceptionResolver,
) : SubsamplingScaleImageView.DefaultOnImageEventListener() { ) : SubsamplingScaleImageView.DefaultOnImageEventListener() {
private val scope = loader.loaderScope + Dispatchers.Main.immediate private val scope = loader.loaderScope + Dispatchers.Main.immediate
@@ -88,6 +94,8 @@ class PageHolderDelegate(
loader.convertInPlace(file) loader.convertInPlace(file)
state = State.CONVERTED state = State.CONVERTED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) { } catch (e2: Throwable) {
e.addSuppressed(e2) e.addSuppressed(e2)
state = State.ERROR state = State.ERROR
@@ -110,7 +118,7 @@ class PageHolderDelegate(
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Throwable) {
state = State.ERROR state = State.ERROR
error = e error = e
callback.onError(e) callback.onError(e)

View File

@@ -25,11 +25,11 @@ class WebtoonHolder(
View.OnClickListener { View.OnClickListener {
private var scrollToRestore = 0 private var scrollToRestore = 0
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init { init {
binding.ssiv.setOnImageEventListener(delegate) binding.ssiv.setOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)
GoneOnInvisibleListener(bindingInfo.progressBar).attach()
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
@@ -41,6 +41,16 @@ class WebtoonHolder(
binding.ssiv.recycle() binding.ssiv.recycle()
} }
override fun onAttachedToWindow() {
super.onAttachedToWindow()
goneOnInvisibleListener.attach()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
goneOnInvisibleListener.detach()
}
override fun onLoadingStarted() { override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.showCompat() bindingInfo.progressBar.showCompat()

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.setTextColorAttr import org.koitharu.kotatsu.utils.ext.setTextColorAttr
fun pageThumbnailAD( fun pageThumbnailAD(
@@ -69,7 +70,7 @@ fun pageThumbnailAD(
text = (item.number).toString() text = (item.number).toString()
} }
job = scope.launch { job = scope.launch {
val drawable = runCatching { val drawable = runCatchingCancellable {
loadPageThumbnail(item) loadPageThumbnail(item)
}.getOrNull() }.getOrNull()
binding.imageViewThumb.setImageDrawable(drawable) binding.imageViewThumb.setImageDrawable(drawable)

View File

@@ -2,10 +2,16 @@ package org.koitharu.kotatsu.remotelist.ui
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
@@ -16,7 +22,14 @@ import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator
import org.koitharu.kotatsu.list.ui.filter.FilterItem import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.filter.FilterState import org.koitharu.kotatsu.list.ui.filter.FilterState
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -132,6 +145,8 @@ class RemoteListViewModel(
mangaList.value = mangaList.value?.plus(list) ?: list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
listError.value = e listError.value = e

View File

@@ -3,15 +3,20 @@ package org.koitharu.kotatsu.scrobbling.domain
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.getOrElse import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import java.util.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.domain.model.* import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.utils.ext.findKeyByValue import org.koitharu.kotatsu.utils.ext.findKeyByValue
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.util.EnumMap
abstract class Scrobbler( abstract class Scrobbler(
protected val db: MangaDatabase, protected val db: MangaDatabase,
@@ -47,7 +52,7 @@ abstract class Scrobbler(
private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) { val mangaInfo = infoCache.getOrElse(targetId) {
runCatching { runCatchingCancellable {
getMangaInfo(targetId) getMangaInfo(targetId)
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
@@ -72,9 +77,9 @@ abstract class Scrobbler(
} }
suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean {
return runCatching { return runCatchingCancellable {
scrobble(mangaId, chapter) scrobble(mangaId, chapter)
}.onFailure { }.onFailure {
it.printStackTraceDebug() it.printStackTraceDebug()
}.isSuccess }.isSuccess
} }

View File

@@ -10,10 +10,13 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder() val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
storage.accessToken?.let { if (!sourceRequest.url.pathSegments.contains("oauth")) {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
} }
val response = chain.proceed(request.build()) val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) { if (!response.isSuccessful && !response.isRedirect) {
@@ -21,4 +24,4 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
} }
return response return response
} }
} }

View File

@@ -40,13 +40,14 @@ class ShikimoriRepository(
suspend fun authorize(code: String?) { suspend fun authorize(code: String?) {
val body = FormBody.Builder() val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) { if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI) body.add("redirect_uri", REDIRECT_URI)
body.add("code", code) body.add("code", code)
} else { } else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken)) body.add("refresh_token", checkNotNull(storage.refreshToken))
} }
val request = Request.Builder() val request = Request.Builder()

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaSearchRepository( class MangaSearchRepository(
private val settings: AppSettings, private val settings: AppSettings,
@@ -30,7 +31,7 @@ class MangaSearchRepository(
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> = fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
settings.getMangaSources(includeHidden = false).asFlow() settings.getMangaSources(includeHidden = false).asFlow()
.flatMapMerge(concurrency) { source -> .flatMapMerge(concurrency) { source ->
runCatching { runCatchingCancellable {
MangaRepository(source).getList( MangaRepository(source).getList(
offset = 0, offset = 0,
query = query, query = query,
@@ -63,7 +64,7 @@ class MangaSearchRepository(
SUGGESTION_PROJECTION, SUGGESTION_PROJECTION,
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?", "${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
arrayOf("%$query%"), arrayOf("%$query%"),
"date DESC" "date DESC",
)?.use { cursor -> )?.use { cursor ->
val count = minOf(cursor.count, limit) val count = minOf(cursor.count, limit)
if (count == 0) { if (count == 0) {
@@ -113,7 +114,7 @@ class MangaSearchRepository(
SUGGESTION_PROJECTION, SUGGESTION_PROJECTION,
null, null,
arrayOfNulls(1), arrayOfNulls(1),
null null,
)?.use { cursor -> cursor.count } ?: 0 )?.use { cursor -> cursor.count } ?: 0
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.search.ui package org.koitharu.kotatsu.search.ui
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -9,14 +10,20 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class SearchViewModel( class SearchViewModel(
private val repository: MangaRepository, private val repository: MangaRepository,
private val query: String, private val query: String,
settings: AppSettings settings: AppSettings,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
@@ -28,7 +35,7 @@ class SearchViewModel(
mangaList, mangaList,
createListModeFlow(), createListModeFlow(),
listError, listError,
hasNextPage hasNextPage,
) { list, mode, error, hasNext -> ) { list, mode, error, hasNext ->
when { when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
@@ -39,8 +46,9 @@ class SearchViewModel(
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
) ),
) )
else -> { else -> {
val result = ArrayList<ListModel>(list.size + 1) val result = ArrayList<ListModel>(list.size + 1)
list.toUi(result, mode) list.toUi(result, mode)
@@ -88,6 +96,8 @@ class SearchViewModel(
mangaList.value = mangaList.value?.plus(list) ?: list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
private const val MIN_HAS_MORE_ITEMS = 8 private const val MIN_HAS_MORE_ITEMS = 8
@@ -48,8 +49,9 @@ class MultiSearchViewModel(
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0, actionStringRes = 0,
) )
} },
) )
loading -> list + LoadingFooter loading -> list + LoadingFooter
else -> list else -> list
} }
@@ -81,6 +83,8 @@ class MultiSearchViewModel(
loadingData.value = true loadingData.value = true
query.postValue(q) query.postValue(q)
searchImpl(q) searchImpl(q)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} finally { } finally {
@@ -94,7 +98,7 @@ class MultiSearchViewModel(
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = sources.map { source -> val deferredList = sources.map { source ->
async(dispatcher) { async(dispatcher) {
runCatching { runCatchingCancellable {
val list = MangaRepository(source).getList(offset = 0, query = q) val list = MangaRepository(source).getList(offset = 0, query = q)
.toUi(ListMode.GRID) .toUi(ListMode.GRID)
if (list.isNotEmpty()) { if (list.isNotEmpty()) {

View File

@@ -5,12 +5,12 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.* import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
import kotlin.jvm.internal.Intrinsics
class MultiSearchAdapter( class MultiSearchAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -33,11 +33,11 @@ class MultiSearchAdapter(
selectionDecoration = selectionDecoration, selectionDecoration = selectionDecoration,
listener = listener, listener = listener,
itemClickListener = itemClickListener, itemClickListener = itemClickListener,
) ),
) )
.addDelegate(loadingStateAD()) .addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD()) .addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(listener)) .addDelegate(emptyStateListAD(coil, listener))
.addDelegate(errorStateListAD(listener)) .addDelegate(errorStateListAD(listener))
} }

View File

@@ -8,6 +8,12 @@ import androidx.activity.ComponentActivity
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
@@ -20,12 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.ByteArrayInputStream import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
class AppUpdateChecker(private val activity: ComponentActivity) { class AppUpdateChecker(private val activity: ComponentActivity) {
@@ -41,7 +42,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
null null
} }
suspend fun checkNow() = runCatching { suspend fun checkNow() = runCatchingCancellable {
val version = repo.getLatestVersion() val version = repo.getLatestVersion()
val newVersionId = VersionId(version.name) val newVersionId = VersionId(version.name)
val currentVersionId = VersionId(BuildConfig.VERSION_NAME) val currentVersionId = VersionId(BuildConfig.VERSION_NAME)

View File

@@ -7,6 +7,7 @@ import android.view.View
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -65,18 +66,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
clearCache(preference, CacheDir.PAGES) clearCache(preference, CacheDir.PAGES)
true true
} }
AppSettings.KEY_THUMBS_CACHE_CLEAR -> { AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
clearCache(preference, CacheDir.THUMBS) clearCache(preference, CacheDir.THUMBS)
true true
} }
AppSettings.KEY_COOKIES_CLEAR -> { AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies() clearCookies()
true true
} }
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory(preference) clearSearchHistory(preference)
true true
} }
AppSettings.KEY_UPDATES_FEED_CLEAR -> { AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewLifecycleScope.launch { viewLifecycleScope.launch {
trackerRepo.clearLogs() trackerRepo.clearLogs()
@@ -85,11 +90,12 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
Snackbar.make( Snackbar.make(
view ?: return@launch, view ?: return@launch,
R.string.updates_feed_cleared, R.string.updates_feed_cleared,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }
true true
} }
AppSettings.KEY_SHIKIMORI -> { AppSettings.KEY_SHIKIMORI -> {
if (!shikimoriRepository.isAuthorized) { if (!shikimoriRepository.isAuthorized) {
launchShikimoriAuth() launchShikimoriAuth()
@@ -98,6 +104,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
super.onPreferenceTreeClick(preference) super.onPreferenceTreeClick(preference)
} }
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@@ -110,6 +117,8 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
storageManager.clearCache(cache) storageManager.clearCache(cache)
val size = storageManager.computeCacheSize(cache) val size = storageManager.computeCacheSize(cache)
preference.summary = FileSize.BYTES.format(ctx, size) preference.summary = FileSize.BYTES.format(ctx, size)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources) preference.summary = e.getDisplayMessage(ctx.resources)
} finally { } finally {
@@ -136,7 +145,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
Snackbar.make( Snackbar.make(
view ?: return@launch, view ?: return@launch,
R.string.search_history_cleared, R.string.search_history_cleared,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }
}.show() }.show()
@@ -154,7 +163,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
Snackbar.make( Snackbar.make(
listView ?: return@launch, listView ?: return@launch,
R.string.cookies_cleared, R.string.cookies_cleared,
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }
}.show() }.show()

View File

@@ -33,7 +33,7 @@ class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLay
fun setTitle(title: CharSequence?) { fun setTitle(title: CharSequence?) {
currentTitle = title currentTitle = title
if (slidingPaneLayout.isOpen) { if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) {
activity?.title = title activity?.title = title
} }
} }

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,7 +18,13 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.awaitViewLifecycle
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
class SourceSettingsFragment : BasePreferenceFragment(0) { class SourceSettingsFragment : BasePreferenceFragment(0) {
@@ -47,7 +55,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(KEY_AUTH)?.run { findPreference<Preference>(KEY_AUTH)?.run {
if (isVisible) { if (isVisible) {
loadUsername(this) loadUsername(viewLifecycleOwner, this)
} }
} }
} }
@@ -58,12 +66,13 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
startActivity(SourceAuthActivity.newIntent(preference.context, source)) startActivity(SourceAuthActivity.newIntent(preference.context, source))
true true
} }
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
private fun loadUsername(preference: Preference) = viewLifecycleScope.launch { private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch {
runCatching { runCatchingCancellable {
preference.summary = null preference.summary = null
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
requireNotNull(repository?.getAuthProvider()?.getUsername()) requireNotNull(repository?.getAuthProvider()?.getUsername())
@@ -83,17 +92,19 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) } ).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
.show() .show()
} }
else -> preference.summary = error.getDisplayMessage(preference.context.resources) else -> preference.summary = error.getDisplayMessage(preference.context.resources)
} }
error.printStackTraceDebug() error.printStackTraceDebug()
} }
} }
private fun resolveError(error: Throwable): Unit { private fun resolveError(error: Throwable) {
viewLifecycleScope.launch { viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) { if (exceptionResolver.resolve(error)) {
val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch
loadUsername(pref) val lifecycleOwner = awaitViewLifecycle()
loadUsername(lifecycleOwner, pref)
} }
} }
} }

View File

@@ -9,14 +9,14 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File
import java.io.FileOutputStream
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogProgressBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileOutputStream
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
@@ -24,7 +24,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private var backup: File? = null private var backup: File? = null
private val saveFileContract = registerForActivityResult( private val saveFileContract = registerForActivityResult(
ActivityResultContracts.CreateDocument("*/*") ActivityResultContracts.CreateDocument("*/*"),
) { uri -> ) { uri ->
val file = backup val file = backup
if (uri != null && file != null) { if (uri != null && file != null) {
@@ -88,6 +88,8 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show()
dismiss() dismiss()
} catch (e: InterruptedException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
onError(e) onError(e)
} }

View File

@@ -41,7 +41,7 @@ class OnboardDialogFragment :
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder builder
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(true) .setCancelable(false)
if (isWelcome) { if (isWelcome) {
builder.setTitle(R.string.welcome) builder.setTitle(R.string.welcome)
} else { } else {

View File

@@ -3,15 +3,16 @@ package org.koitharu.kotatsu.settings.onboard
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import java.util.*
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class OnboardViewModel( class OnboardViewModel(
private val settings: AppSettings, private val settings: AppSettings,
@@ -27,7 +28,7 @@ class OnboardViewModel(
init { init {
if (settings.isSourcesSelected) { if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale }) selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale })
} else { } else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language x.language
@@ -66,7 +67,7 @@ class OnboardViewModel(
SourceLocale( SourceLocale(
key = key, key = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isChecked = key in selectedLocales isChecked = key in selectedLocales,
) )
}.sortedWith(SourceLocaleComparator()) }.sortedWith(SourceLocaleComparator())
} }

View File

@@ -5,8 +5,8 @@ import android.content.res.TypedArray
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.customview.view.AbsSavedState
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder import androidx.preference.PreferenceViewHolder
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
@@ -40,11 +40,11 @@ class SliderPreference @JvmOverloads constructor(
attrs, attrs,
R.styleable.SliderPreference, R.styleable.SliderPreference,
defStyleAttr, defStyleAttr,
defStyleRes defStyleRes,
) { ) {
valueFrom = getFloat( valueFrom = getFloat(
R.styleable.SliderPreference_android_valueFrom, R.styleable.SliderPreference_android_valueFrom,
valueFrom.toFloat() valueFrom.toFloat(),
).toInt() ).toInt()
valueTo = valueTo =
getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
@@ -117,7 +117,7 @@ class SliderPreference @JvmOverloads constructor(
} }
} }
private class SavedState : View.BaseSavedState { private class SavedState : AbsSavedState {
val valueFrom: Int val valueFrom: Int
val valueTo: Int val valueTo: Int
@@ -134,7 +134,7 @@ class SliderPreference @JvmOverloads constructor(
this.currentValue = currentValue this.currentValue = currentValue
} }
constructor(source: Parcel) : super(source) { constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
valueFrom = source.readInt() valueFrom = source.readInt()
valueTo = source.readInt() valueTo = source.readInt()
currentValue = source.readInt() currentValue = source.readInt()
@@ -148,9 +148,10 @@ class SliderPreference @JvmOverloads constructor(
} }
companion object { companion object {
@Suppress("unused")
@JvmField @JvmField
val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> { val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`) override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size) override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
} }

View File

@@ -8,6 +8,8 @@ import androidx.annotation.FloatRange
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.* import androidx.work.*
import java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -24,8 +26,6 @@ import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asArrayList import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit
import kotlin.math.pow
class SuggestionsWorker(appContext: Context, params: WorkerParameters) : class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params), KoinComponent { CoroutineWorker(appContext, params), KoinComponent {
@@ -47,7 +47,7 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
val channel = NotificationChannel( val channel = NotificationChannel(
WORKER_CHANNEL_ID, WORKER_CHANNEL_ID,
title, title,
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW,
) )
channel.setShowBadge(false) channel.setShowBadge(false)
channel.enableVibration(false) channel.enableVibration(false)
@@ -118,7 +118,7 @@ class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
}.map { manga -> }.map { manga ->
MangaSuggestion( MangaSuggestion(
manga = manga, manga = manga,
relevance = computeRelevance(manga.tags, allTags) relevance = computeRelevance(manga.tags, allTags),
) )
}.sortedBy { it.relevance }.take(LIMIT) }.sortedBy { it.relevance }.take(LIMIT)
suggestionRepository.replace(suggestions) suggestionRepository.replace(suggestions)

View File

@@ -14,7 +14,7 @@ val trackerModule
factory { TrackingRepository(get()) } factory { TrackingRepository(get()) }
factory { TrackerNotificationChannels(androidContext(), get()) } factory { TrackerNotificationChannels(androidContext(), get()) }
factory { Tracker(get(), get(), get()) } factory { Tracker(get(), get(), get(), get()) }
viewModel { FeedViewModel(get()) } viewModel { FeedViewModel(get()) }
} }

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.tracker.domain package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.tracker.work.TrackingItem
class Tracker( class Tracker(
private val settings: AppSettings, private val settings: AppSettings,
private val repository: TrackingRepository, private val repository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val channels: TrackerNotificationChannels, private val channels: TrackerNotificationChannels,
) { ) {
@@ -68,7 +71,7 @@ class Tracker(
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates { suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates {
val manga = MangaRepository(track.manga.source).getDetails(track.manga) val manga = MangaRepository(track.manga.source).getDetails(track.manga)
val updates = compare(track, manga) val updates = compare(track, manga, getBranch(manga))
if (commit) { if (commit) {
repository.saveUpdates(updates) repository.saveUpdates(updates)
} }
@@ -78,7 +81,7 @@ class Tracker(
@VisibleForTesting @VisibleForTesting
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates { suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates {
val track = repository.getTrack(manga) val track = repository.getTrack(manga)
val updates = compare(track, manga) val updates = compare(track, manga, getBranch(manga))
if (commit) { if (commit) {
repository.saveUpdates(updates) repository.saveUpdates(updates)
} }
@@ -90,25 +93,30 @@ class Tracker(
repository.deleteTrack(mangaId) repository.deleteTrack(mangaId)
} }
private suspend fun getBranch(manga: Manga): String? {
val history = historyRepository.getOne(manga)
return manga.getPreferredBranch(history)
}
/** /**
* The main functionality of tracker: check new chapters in [manga] comparing to the [track] * The main functionality of tracker: check new chapters in [manga] comparing to the [track]
*/ */
private fun compare(track: MangaTracking, manga: Manga): MangaUpdates { private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates {
if (track.isEmpty()) { if (track.isEmpty()) {
// first check or manga was empty on last check // first check or manga was empty on last check
return MangaUpdates(manga, emptyList(), isValid = false) return MangaUpdates(manga, emptyList(), isValid = false)
} }
val chapters = requireNotNull(manga.chapters) val chapters = requireNotNull(manga.getChapters(branch))
val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
return when { return when {
newChapters.isEmpty() -> { newChapters.isEmpty() -> {
return MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId) MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
} }
newChapters.size == chapters.size -> { newChapters.size == chapters.size -> {
return MangaUpdates(manga, emptyList(), isValid = false) MangaUpdates(manga, emptyList(), isValid = false)
} }
else -> { else -> {
return MangaUpdates(manga, newChapters, isValid = true) MangaUpdates(manga, newChapters, isValid = true)
} }
} }
} }

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
class FeedFragment : class FeedFragment :
BaseFragment<FragmentFeedBinding>(), BaseFragment<FragmentFeedBinding>(),
@@ -39,7 +41,7 @@ class FeedFragment :
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentFeedBinding.inflate(inflater, container, false) ) = FragmentFeedBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -54,7 +56,7 @@ class FeedFragment :
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration( val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0, FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing fallbackSpacing = spacing,
) )
addItemDecoration(decoration) addItemDecoration(decoration)
} }
@@ -77,12 +79,25 @@ class FeedFragment :
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerView.updatePadding( binding.root.updatePadding(
top = headerHeight + paddingVertical, left = insets.left,
left = insets.left + paddingHorizontal, right = insets.right,
right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
) )
if (activity is MainActivity) {
binding.recyclerView.updatePadding(
top = headerHeight,
bottom = insets.bottom,
)
binding.swipeRefreshLayout.setProgressViewOffset(
true,
headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10),
)
} else {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
}
} }
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit
@@ -101,7 +116,7 @@ class FeedFragment :
Snackbar.make( Snackbar.make(
binding.recyclerView, binding.recyclerView,
R.string.updates_feed_cleared, R.string.updates_feed_cleared,
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG,
).show() ).show()
} }
@@ -109,7 +124,7 @@ class FeedFragment :
Snackbar.make( Snackbar.make(
binding.recyclerView, binding.recyclerView,
e.getDisplayMessage(resources), e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT,
).show() ).show()
} }

View File

@@ -16,12 +16,13 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
class FeedViewModel( class FeedViewModel(
private val repository: TrackingRepository private val repository: TrackingRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null) private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
@@ -32,7 +33,7 @@ class FeedViewModel(
val onFeedCleared = SingleLiveEvent<Unit>() val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine( val content = combine(
logList.filterNotNull(), logList.filterNotNull(),
hasNextPage hasNextPage,
) { list, isHasNextPage -> ) { list, isHasNextPage ->
buildList(list.size + 2) { buildList(list.size + 2) {
if (list.isEmpty()) { if (list.isEmpty()) {
@@ -43,7 +44,7 @@ class FeedViewModel(
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder, textSecondary = R.string.text_feed_holder,
actionStringRes = 0, actionStringRes = 0,
) ),
) )
} else { } else {
list.mapListTo(this) list.mapListTo(this)
@@ -54,7 +55,7 @@ class FeedViewModel(
} }
}.asLiveDataDistinct( }.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default, viewModelScope.coroutineContext + Dispatchers.Default,
listOf(header, LoadingState) listOf(header, LoadingState),
) )
init { init {

View File

@@ -4,11 +4,11 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.* import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.tracker.ui.model.FeedItem import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import kotlin.jvm.internal.Intrinsics
class FeedAdapter( class FeedAdapter(
coil: ImageLoader, coil: ImageLoader,
@@ -23,7 +23,7 @@ class FeedAdapter(
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener)) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) .addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD()) .addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
} }

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.ext.trySetForeground
@@ -80,7 +81,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
val deferredList = coroutineScope { val deferredList = coroutineScope {
tracks.map { (track, channelId) -> tracks.map { (track, channelId) ->
async(dispatcher) { async(dispatcher) {
runCatching { runCatchingCancellable {
tracker.fetchUpdates(track, commit = true) tracker.fetchUpdates(track, commit = true)
}.onSuccess { updates -> }.onSuccess { updates ->
if (updates.isValid && updates.isNotEmpty()) { if (updates.isValid && updates.isNotEmpty()) {

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import java.util.*
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> { class CompositeMutex<T : Any> : Set<T> {
@@ -34,7 +34,7 @@ class CompositeMutex<T : Any> : Set<T> {
} }
suspend fun lock(element: T) { suspend fun lock(element: T) {
while (currentCoroutineContext().isActive) { while (coroutineContext.isActive) {
waitForRemoval(element) waitForRemoval(element)
mutex.withLock { mutex.withLock {
if (data[element] == null) { if (data[element] == null) {
@@ -45,11 +45,9 @@ class CompositeMutex<T : Any> : Set<T> {
} }
} }
suspend fun unlock(element: T) { fun unlock(element: T) {
val continuations = mutex.withLock { val continuations = checkNotNull(data.remove(element)) {
checkNotNull(data.remove(element)) { "CompositeMutex is not locked for $element"
"CompositeMutex is not locked for $element"
}
} }
continuations.forEach { c -> continuations.forEach { c ->
if (c.isActive) { if (c.isActive) {

View File

@@ -19,5 +19,10 @@ class GoneOnInvisibleListener(
fun attach() { fun attach() {
view.viewTreeObserver.addOnGlobalLayoutListener(this) view.viewTreeObserver.addOnGlobalLayoutListener(this)
onGlobalLayout()
} }
}
fun detach() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}

View File

@@ -7,7 +7,7 @@ import kotlin.math.roundToInt
class GridTouchHelper( class GridTouchHelper(
context: Context, context: Context,
private val listener: OnGridTouchListener private val listener: OnGridTouchListener,
) : GestureDetector.SimpleOnGestureListener() { ) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this) private val detector = GestureDetector(context, this)
@@ -16,7 +16,7 @@ class GridTouchHelper(
private var isDispatching = false private var isDispatching = false
init { init {
detector.setIsLongpressEnabled(false) detector.setIsLongpressEnabled(true)
detector.setOnDoubleTapListener(this) detector.setOnDoubleTapListener(this)
} }
@@ -46,7 +46,7 @@ class GridTouchHelper(
} }
2 -> AREA_RIGHT 2 -> AREA_RIGHT
else -> return false else -> return false
} },
) )
return true return true
} }
@@ -66,4 +66,4 @@ class GridTouchHelper(
fun onProcessTouch(rawX: Int, rawY: Int): Boolean fun onProcessTouch(rawX: Int, rawY: Int): Boolean
} }
} }

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class LifecycleAwareServiceConnection(
private val host: Activity,
) : ServiceConnection, DefaultLifecycleObserver {
private val serviceStateFlow = MutableStateFlow<IBinder?>(null)
val service: StateFlow<IBinder?>
get() = serviceStateFlow
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceStateFlow.value = service
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceStateFlow.value = null
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
host.unbindService(this)
}
}
fun Activity.bindServiceWithLifecycle(
owner: LifecycleOwner,
service: Intent,
flags: Int
): LifecycleAwareServiceConnection {
val connection = LifecycleAwareServiceConnection(this)
bindService(service, connection, flags)
owner.lifecycle.addObserver(connection)
return connection
}

View File

@@ -1,50 +0,0 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.MainThread
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Runnable
class PausingDispatcher(
private val dispatcher: CoroutineDispatcher,
) : CoroutineDispatcher() {
@Volatile
private var isPaused = false
private val queue = ConcurrentLinkedQueue<Task>()
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
return isPaused || super.isDispatchNeeded(context)
}
override fun dispatch(context: CoroutineContext, block: Runnable) {
if (isPaused) {
queue.add(Task(context, block))
} else {
dispatcher.dispatch(context, block)
}
}
@MainThread
fun pause() {
isPaused = true
}
@MainThread
fun resume() {
if (!isPaused) {
return
}
isPaused = false
while (true) {
val task = queue.poll() ?: break
dispatcher.dispatch(task.context, task.block)
}
}
private class Task(
val context: CoroutineContext,
val block: Runnable,
)
}

View File

@@ -29,7 +29,7 @@ val Context.activityManager: ActivityManager?
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
val info = getForegroundInfo() val info = getForegroundInfo()
setForeground(info) setForeground(info)
}.isSuccess }.isSuccess

View File

@@ -9,6 +9,7 @@ import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@@ -45,9 +46,28 @@ fun ImageResult.toBitmapOrNull() = when (this) {
} }
fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
return setHeader(CommonHeaders.REFERER, referer) if (referer.isEmpty()) {
return this
}
try {
setHeader(CommonHeaders.REFERER, referer)
} catch (e: IllegalArgumentException) {
val baseUrl = referer.baseUrl()
if (baseUrl != null) {
setHeader(CommonHeaders.REFERER, baseUrl)
}
}
return this
} }
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicator)) return listener(ImageRequestIndicatorListener(indicator))
}
private fun String.baseUrl(): String? {
return (this.toHttpUrlOrNull()?.newBuilder("/") ?: return null)
.username("")
.password("")
.build()
.toString()
} }

View File

@@ -7,8 +7,12 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import java.io.Serializable import java.io.Serializable
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T { inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size) val b = Bundle(size)
@@ -49,4 +53,20 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
fun Fragment.addMenuProvider(provider: MenuProvider) { fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED) requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner = suspendCancellableCoroutine { cont ->
val liveData = viewLifecycleOwnerLiveData
val observer = object : Observer<LifecycleOwner> {
override fun onChanged(result: LifecycleOwner?) {
if (result != null) {
liveData.removeObserver(this)
cont.resume(result)
}
}
}
liveData.observeForever(observer)
cont.invokeOnCancellation {
liveData.removeObserver(observer)
}
} }

View File

@@ -2,40 +2,70 @@ package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import androidx.collection.arraySetOf
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import kotlinx.coroutines.CancellationException
import okio.FileNotFoundException import okio.FileNotFoundException
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.* import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is ActivityNotFoundException, is ActivityNotFoundException,
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported) is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found) is FileNotFoundException -> resources.getString(R.string.file_not_found)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ContentUnavailableException -> message is ContentUnavailableException -> message
is ParseException -> shortMessage is ParseException -> shortMessage
is SocketTimeoutException -> resources.getString(R.string.network_error) is UnknownHostException,
is SocketTimeoutException,
-> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
else -> localizedMessage else -> localizedMessage
} ?: resources.getString(R.string.error_occurred) } ?: resources.getString(R.string.error_occurred)
fun Throwable.isReportable(): Boolean { fun Throwable.isReportable(): Boolean {
if (this !is Exception) { return this is Error || this.javaClass in reportableExceptions
return true
}
return this is ParseException || this is IllegalArgumentException ||
this is IllegalStateException || this.javaClass == RuntimeException::class.java
} }
fun Throwable.report(message: String?) { fun Throwable.report(message: String?) {
CaughtException(this, message).sendWithAcra() val exception = CaughtException(this, message)
exception.sendWithAcra()
}
private val reportableExceptions = arraySetOf<Class<*>>(
ParseException::class.java,
RuntimeException::class.java,
IllegalStateException::class.java,
IllegalArgumentException::class.java,
ConcurrentModificationException::class.java,
UnsupportedOperationException::class.java,
)
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: InterruptedException) {
throw e
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
} }

View File

@@ -114,7 +114,8 @@ fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
fun Slider.setValueRounded(newValue: Float) { fun Slider.setValueRounded(newValue: Float) {
val step = stepSize val step = stepSize
value = (newValue / step).roundToInt() * step val roundedValue = (newValue / step).roundToInt() * step
value = roundedValue.coerceIn(valueFrom, valueTo)
} }
val RecyclerView.isScrolledToTop: Boolean val RecyclerView.isScrolledToTop: Boolean

View File

@@ -0,0 +1,83 @@
package org.koitharu.kotatsu.utils.image
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.widget.ImageView
import coil.size.Dimension
import coil.size.Size
import coil.size.SizeResolver
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
private const val ASPECT_RATIO_HEIGHT = 18f
private const val ASPECT_RATIO_WIDTH = 13f
class CoverSizeResolver(
private val imageView: ImageView,
) : SizeResolver {
override suspend fun size(): Size {
getSize()?.let { return it }
return suspendCancellableCoroutine { cont ->
val layoutListener = LayoutListener(cont)
imageView.addOnLayoutChangeListener(layoutListener)
cont.invokeOnCancellation {
imageView.removeOnLayoutChangeListener(layoutListener)
}
}
}
private fun getSize(): Size? {
val lp = imageView.layoutParams
var width = getDimension(lp.width, imageView.width, imageView.paddingLeft + imageView.paddingRight)
var height = getDimension(lp.height, imageView.height, imageView.paddingTop + imageView.paddingBottom)
if (width == null && height == null) {
return null
}
if (height == null && width != null) {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
} else if (width == null && height != null) {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
}
return Size(checkNotNull(width), checkNotNull(height))
}
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return null
}
val insetParamSize = paramSize - paddingSize
if (insetParamSize > 0) {
return Dimension(insetParamSize)
}
val insetViewSize = viewSize - paddingSize
if (insetViewSize > 0) {
return Dimension(insetViewSize)
}
return null
}
private inner class LayoutListener(
private val continuation: CancellableContinuation<Size>,
) : OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val size = getSize() ?: return
v.removeOnLayoutChangeListener(this)
continuation.resume(size)
}
}
}

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.utils.image package org.koitharu.kotatsu.utils.image
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.get import androidx.annotation.ColorInt
import androidx.core.graphics.*
import coil.size.Size import coil.size.Size
import coil.transform.Transformation import coil.transform.Transformation
import kotlin.math.abs
class TrimTransformation : Transformation { class TrimTransformation(
private val tolerance: Int = 20,
) : Transformation {
override val cacheKey: String = javaClass.name override val cacheKey: String = javaClass.name
@@ -20,7 +24,7 @@ class TrimTransformation : Transformation {
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (input[x, y] != prevColor) { if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false isColBlank = false
break break
} }
@@ -39,7 +43,7 @@ class TrimTransformation : Transformation {
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (input[x, y] != prevColor) { if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false isColBlank = false
break break
} }
@@ -55,7 +59,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (input[x, y] != prevColor) { if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false isRowBlank = false
break break
} }
@@ -71,7 +75,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (input[x, y] != prevColor) { if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false isRowBlank = false
break break
} }
@@ -93,4 +97,11 @@ class TrimTransformation : Transformation {
override fun equals(other: Any?) = other is TrimTransformation override fun equals(other: Any?) = other is TrimTransformation
override fun hashCode() = javaClass.hashCode() override fun hashCode() = javaClass.hashCode()
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
} }

View File

@@ -29,6 +29,9 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:background="@null" android:background="@null"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/margin_normal"
app:tabGravity="center" app:tabGravity="center"
app:tabMode="scrollable" /> app:tabMode="scrollable" />

View File

@@ -48,7 +48,6 @@
android:gravity="center" android:gravity="center"
android:text="@string/text_downloads_holder" android:text="@string/text_downloads_holder"
android:textAppearance="?attr/textAppearanceBody2" android:textAppearance="?attr/textAppearanceBody2"
android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -23,6 +23,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:scrollIndicators="top|bottom"
android:scrollbars="vertical" android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_source_locale" /> tools:listitem="@layout/item_source_locale" />

View File

@@ -77,10 +77,10 @@
android:id="@+id/textView_percent" android:id="@+id/textView_percent"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBaseline_toBaselineOf="@id/textView_status" app:layout_constraintBaseline_toBaselineOf="@id/textView_status"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"
tools:text="25%" /> tools:text="25%" />
<TextView <TextView
@@ -98,4 +98,30 @@
app:layout_constraintTop_toBottomOf="@id/textView_status" app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" /> tools:text="@tools:sample/lorem[3]" />
<Button
android:id="@+id/button_cancel"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_details"
tools:visibility="visible" />
<Button
android:id="@+id/button_resume"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/try_again"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/textView_details"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -12,10 +12,9 @@
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover" android:id="@+id/imageView_cover"
android:layout_width="42dp" android:layout_width="42dp"
android:layout_height="0dp" android:layout_height="42dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"

View File

@@ -12,10 +12,9 @@
<com.google.android.material.imageview.ShapeableImageView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover" android:id="@+id/imageView_cover"
android:layout_width="42dp" android:layout_width="42dp"
android:layout_height="0dp" android:layout_height="42dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"

View File

@@ -6,4 +6,4 @@
android:layout_width="@dimen/widget_cover_width" android:layout_width="@dimen/widget_cover_width"
android:layout_height="@dimen/widget_cover_height" android:layout_height="@dimen/widget_cover_height"
android:scaleType="centerCrop" android:scaleType="centerCrop"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />

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