Compare commits

...

31 Commits

Author SHA1 Message Date
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
Koitharu
14f5d5daa4 Update parsers 2022-08-01 17:14:35 +03:00
Koitharu
f342cd6b56 Fix crash on widgets update 2022-08-01 17:00:00 +03:00
Koitharu
8faacab53a Fix github url 2022-07-30 16:10:03 +03:00
Koitharu
659c327a6d Update parsers and version 2022-07-30 16:05:01 +03:00
Koitharu
bcc2f531c3 Ability to resume download after IOException 2022-07-30 16:02:13 +03:00
Koitharu
020df5c1f7 Fix saving pages from cbz 2022-07-30 14:15:12 +03:00
Koitharu
d6781e1d14 Yet another attempt to make webtoon reader great again 2022-07-29 15:39:04 +03:00
Zakhar Timoshenko
d42cd59880 Fix enabling disabled new sources
Co-authored-by: Koitharu <8948226+nv95@users.noreply.github.com>
2022-07-28 09:49:30 +03:00
Koitharu
be19c32fea Update prasers 2022-07-27 17:36:39 +03:00
Koitharu
8da0e98d23 Fix FragmentManager leak 2022-07-27 17:36:39 +03:00
Koitharu
73a2f05509 Fix FadingSnackbar text color 2022-07-27 17:36:39 +03:00
Koitharu
bb23f998e0 Fix crash on description selection 2022-07-27 17:36:35 +03:00
TheDawnOvO
75915ff366 Update activity_main.xml 2022-07-27 15:24:39 +03:00
Dpper
517e801580 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
J. Lavoie
12474e23f9 Translated using Weblate (Finnish)
Currently translated at 96.2% (311 of 323 strings)

Translated using Weblate (French)

Currently translated at 100.0% (323 of 323 strings)

Translated using Weblate (Italian)

Currently translated at 99.0% (320 of 323 strings)

Translated using Weblate (German)

Currently translated at 97.8% (316 of 323 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
kuragehime
00bdd859a7 Translated using Weblate (Japanese)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
Oğuz Ersen
3a3af9ea00 Translated using Weblate (Turkish)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
Koitharu
1803b1a2ee Remove unused WebViewClientCompat 2022-07-25 11:25:43 +03:00
Koitharu
4175c84363 Move create category button in bs to toolbar 2022-07-22 19:04:43 +03:00
Koitharu
1840d7b50e Fix get current page #165 2022-07-20 20:32:01 +03:00
Zakhar Timoshenko
37b69833b3 Update parsers 2022-07-20 19:52:56 +03:00
77 changed files with 765 additions and 406 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

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 417 versionCode 421
versionName '3.4.5' versionName '3.4.9'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,19 +79,19 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.nv95:kotatsu-parsers:c4abb758f3') { implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') {
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.1'
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'
@@ -101,11 +101,11 @@ dependencies {
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-alpha03'
//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'
@@ -137,6 +137,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.13.0'
} }

View File

@@ -73,7 +73,7 @@ class KotatsuApp : Application() {
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
shikimoriModule, shikimoriModule,
bookmarksModule, bookmarksModule
) )
} }
} }
@@ -91,8 +91,7 @@ class KotatsuApp : Application() {
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION, ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA, ReportField.SHARED_PREFERENCES
ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
text = getString(R.string.crash_text) text = getString(R.string.crash_text)

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

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.text.Selection
import android.text.Spannable
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.annotation.AttrRes
import com.google.android.material.textview.MaterialTextView
class SelectableTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = android.R.attr.textViewStyle,
) : MaterialTextView(context, attrs, defStyleAttr) {
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
fixSelectionRange()
return super.dispatchTouchEvent(event)
}
// https://stackoverflow.com/questions/22810147/error-when-selecting-text-from-textview-java-lang-indexoutofboundsexception-se
private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return
Selection.setSelection(spannableText, text.length)
}
}
}

View File

@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import org.koin.core.component.KoinComponent import android.webkit.WebViewClient
import org.koitharu.kotatsu.core.network.WebViewClientCompat
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent { class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)

View File

@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.ProgressBar
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.utils.ext.setProgressCompat
private const val PROGRESS_MAX = 100 private const val PROGRESS_MAX = 100
class ProgressChromeClient( class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>, private val progressIndicator: ProgressBar,
) : WebChromeClient() { ) : WebChromeClient() {
init { init {
@@ -24,7 +25,7 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = false progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true) progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else { } else {
progressIndicator.setIndeterminate(true) progressIndicator.isIndeterminate = true
} }
} }
} }

View File

@@ -2,19 +2,19 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.WebViewClientCompat
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: AndroidCookieJar, private val cookieJar: AndroidCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String private val targetUrl: String,
) : WebViewClientCompat() { ) : WebViewClient() {
private val oldClearance = getCookieValue(CF_CLEARANCE) private val oldClearance = getClearance()
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@@ -32,14 +32,14 @@ class CloudFlareClient(
} }
private fun checkClearance() { private fun checkClearance() {
val clearance = getCookieValue(CF_CLEARANCE) val clearance = getClearance()
if (clearance != null && clearance != oldClearance) { if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed() callback.onCheckPassed()
} }
} }
private fun getCookieValue(name: String): String? { private fun getClearance(): String? {
return cookieJar.loadForRequest(targetUrl.toHttpUrl()) return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == name }?.value .find { it.name == CF_CLEARANCE }?.value
} }
} }

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

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.annotation.TargetApi
import android.os.Build
import android.webkit.*
@Suppress("OverridingDeprecatedMember")
abstract class WebViewClientCompat : WebViewClient() {
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
return false
}
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
return null
}
open fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean
) {
}
@TargetApi(Build.VERSION_CODES.N)
final override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean = shouldOverrideUrlCompat(view, request.url.toString())
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrlCompat(view, url)
}
final override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? = shouldInterceptRequestCompat(view, request.url.toString())
final override fun shouldInterceptRequest(
view: WebView,
url: String
): WebResourceResponse? = shouldInterceptRequestCompat(view, url)
@TargetApi(Build.VERSION_CODES.M)
final override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
onReceivedErrorCompat(
view,
error.errorCode,
error.description?.toString(),
request.url.toString(),
request.isForMainFrame
)
}
final override fun onReceivedError(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String
) {
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
}
@TargetApi(Build.VERSION_CODES.M)
final override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
error: WebResourceResponse
) {
onReceivedErrorCompat(
view,
error.statusCode,
error.reasonPhrase,
request.url
.toString(),
request.isForMainFrame
)
}
}

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

@@ -103,8 +103,9 @@ class DetailsActivity :
private fun onMangaRemoved(manga: Manga) { private fun onMangaRemoved(manga: Manga) {
Toast.makeText( Toast.makeText(
this, getString(R.string._s_deleted_from_local_storage, manga.title), this,
Toast.LENGTH_SHORT getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT,
).show() ).show()
finishAfterTransition() finishAfterTransition()
} }
@@ -130,7 +131,7 @@ class DetailsActivity :
onActionClick = { onActionClick = {
e.report("DetailsActivity::onError") e.report("DetailsActivity::onError")
dismiss() dismiss()
} },
) )
} }
else -> { else -> {
@@ -141,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,
) )
} }
@@ -158,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
} }
@@ -274,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) { _, _ ->
@@ -349,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,6 @@ 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
class DetailsViewModel( class DetailsViewModel(
intent: MangaIntent, intent: MangaIntent,
@@ -91,8 +95,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 +114,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)
@@ -225,7 +229,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 +246,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,9 +16,6 @@ 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
class MangaDetailsDelegate( class MangaDetailsDelegate(
@@ -45,12 +42,7 @@ 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 = runCatching {
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
@@ -91,7 +83,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 +98,9 @@ class MangaDetailsDelegate(
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} }
if (result.size < chapters.size / 2) {
result.trimToSize()
}
return result return result
} }
@@ -161,24 +156,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

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
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.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@@ -25,11 +26,9 @@ 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.waitForNetwork import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L private const val SLOWDOWN_DELAY = 200L
@@ -43,9 +42,6 @@ class DownloadManager(
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
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
) )
@@ -58,21 +54,24 @@ class DownloadManager(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
startId: Int, startId: Int,
): ProgressJob<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 job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId) val pausingHandle = PausingHandle()
return ProgressJob(job, stateFlow) val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
return PausingProgressJob(job, stateFlow, pausingHandle)
} }
private fun downloadMangaImpl( private fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga @Suppress("NAME_SHADOWING")
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)
@@ -108,38 +107,28 @@ class DownloadManager(
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga" "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
} }
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = repo.getPages(chapter) val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) { for ((pageIndex, page) in pages.withIndex()) {
var retryCounter = 0 runFailsafe(outState, pausingHandle) {
failsafe@ while (true) { val url = repo.getPageUrl(page)
try { val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
val url = repo.getPageUrl(page) output.addPage(
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) chapter = chapter,
output.addPage( file = file,
chapter = chapter, pageNumber = pageIndex,
file = file, ext = MimeTypeMap.getFileExtensionFromUrl(url)
pageNumber = pageIndex, )
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
break@failsafe
} catch (e: IOException) {
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
delay(DOWNLOAD_ERROR_DELAY)
connectivityManager.waitForNetwork()
retryCounter++
} else {
throw e
}
}
} }
outState.value = DownloadState.Progress( outState.value = DownloadState.Progress(
startId, data, cover, startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageIndex
) )
if (settings.isDownloadsSlowdownEnabled) { if (settings.isDownloadsSlowdownEnabled) {
@@ -157,15 +146,40 @@ class DownloadManager(
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e) outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
output?.cleanup() output?.cleanup()
File(destination, tempFileName).deleteAwait() File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
} }
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
} }
} }
@@ -195,6 +209,7 @@ class DownloadManager(
manga = prevValue.manga, manga = prevValue.manga,
cover = prevValue.cover, cover = prevValue.cover,
error = throwable, error = throwable,
canRetry = false
) )
} }
@@ -225,7 +240,7 @@ class DownloadManager(
okHttp = okHttp, okHttp = okHttp,
cache = cache, cache = cache,
localMangaRepository = localMangaRepository, localMangaRepository = localMangaRepository,
settings = settings, settings = settings
) )
} }
} }

View File

@@ -108,6 +108,7 @@ sealed interface DownloadState {
} }
} }
@Deprecated("TODO: remove")
class WaitingForNetwork( class WaitingForNetwork(
override val startId: Int, override val startId: Int,
override val manga: Manga, override val manga: Manga,
@@ -170,6 +171,7 @@ sealed interface DownloadState {
override val manga: Manga, override val manga: Manga,
override val cover: Drawable?, override val cover: Drawable?,
val error: Throwable, val error: Throwable,
val canRetry: Boolean,
) : DownloadState { ) : DownloadState {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -182,6 +184,7 @@ sealed interface DownloadState {
if (manga != other.manga) return false if (manga != other.manga) return false
if (cover != other.cover) return false if (cover != other.cover) return false
if (error != other.error) return false if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true return true
} }
@@ -191,6 +194,7 @@ sealed interface DownloadState {
result = 31 * result + manga.hashCode() result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0) result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode() result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result return result
} }
} }

View File

@@ -29,16 +29,26 @@ class DownloadNotification(private val context: Context, startId: Int) {
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, context,
startId, startId * 2,
DownloadService.getCancelIntent(startId), DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE 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 {
@@ -89,10 +99,14 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(!state.canRetry)
builder.setOngoing(false) builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)

View File

@@ -11,6 +11,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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 kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -28,16 +29,16 @@ 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
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
private val jobs = LinkedHashMap<Int, ProgressJob<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 private var binder: DownloadBinder? = null
@@ -49,10 +50,13 @@ class DownloadService : BaseService() {
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) val wakeLock = (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( downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1))
) )
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
registerReceiver(controlReceiver, intentFilter)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -90,7 +94,7 @@ class DownloadService : BaseService() {
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
): ProgressJob<DownloadState> { ): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId) val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job) listenJob(job)
return job return job
@@ -144,7 +148,7 @@ class DownloadService : BaseService() {
} }
private val DownloadState.isTerminal: Boolean private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
@@ -155,6 +159,10 @@ class DownloadService : BaseService() {
jobs.remove(cancelId)?.cancel() jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size jobCount.value = jobs.size
} }
ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume()
}
} }
} }
} }
@@ -173,6 +181,7 @@ class DownloadService : BaseService() {
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
private const val EXTRA_MANGA = "manga" private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
@@ -219,6 +228,9 @@ class DownloadService : BaseService() {
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId) .putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? { fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.download.ui.service
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
class PausingHandle {
private val paused = MutableStateFlow(false)
@get:AnyThread
val isPaused: Boolean
get() = paused.value
@AnyThread
suspend fun awaitResumed() {
paused.filter { !it }.first()
}
@AnyThread
fun pause() {
paused.value = true
}
@AnyThread
fun resume() {
paused.value = false
}
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -26,7 +28,8 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener { View.OnClickListener,
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga }) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@@ -44,7 +47,7 @@ class FavouriteCategoriesBottomSheet :
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.buttonDone.setOnClickListener(this) binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this) binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,11 +60,18 @@ class FavouriteCategoriesBottomSheet :
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
R.id.button_done -> dismiss() R.id.button_done -> dismiss()
} }
} }
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
else -> return false
}
return true
}
override fun onItemClick(item: MangaCategoryItem, view: View) { override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked) viewModel.setChecked(item.id, !item.isChecked)
} }

View File

@@ -68,7 +68,7 @@ abstract class MangaListFragment :
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentListBinding.inflate(inflater, container, false) ) = FragmentListBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -76,13 +76,13 @@ abstract class MangaListFragment :
listAdapter = MangaListAdapter( listAdapter = MangaListAdapter(
coil = get(), coil = get(),
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
listener = this, listener = this
) )
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = requireActivity(), activity = requireActivity(),
decoration = MangaSelectionDecoration(view.context), decoration = MangaSelectionDecoration(view.context),
registryOwner = this, registryOwner = this,
callback = this, callback = this
) )
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
@@ -97,7 +97,7 @@ abstract class MangaListFragment :
setOnRefreshListener(this@MangaListFragment) setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled isEnabled = isSwipeRefreshEnabled
} }
addMenuProvider(MangaListMenuProvider(childFragmentManager)) addMenuProvider(MangaListMenuProvider(this))
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -171,21 +171,21 @@ abstract class MangaListFragment :
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right
) )
if (activity is MainActivity) { if (activity is MainActivity) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
top = headerHeight, top = headerHeight,
bottom = insets.bottom, bottom = insets.bottom
) )
binding.swipeRefreshLayout.setProgressViewOffset( binding.swipeRefreshLayout.setProgressViewOffset(
true, true,
headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10), headerHeight + resources.resolveDp(10)
) )
} else { } else {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom
) )
} }
} }

View File

@@ -4,11 +4,11 @@ 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 androidx.fragment.app.FragmentManager import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class MangaListMenuProvider( class MangaListMenuProvider(
private val fragmentManager: FragmentManager, private val fragment: Fragment,
) : MenuProvider { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -17,7 +17,7 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> { R.id.action_list_mode -> {
ListModeSelectDialog.show(fragmentManager) ListModeSelectDialog.show(fragment.childFragmentManager)
true true
} }
else -> false else -> false

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

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

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 {

View File

@@ -14,7 +14,7 @@ val readerModule
factory { MangaDataRepository(get()) } factory { MangaDataRepository(get()) }
single { PagesCache(get()) } single { PagesCache(get()) }
factory { PageSaveHelper(get(), androidContext()) } factory { PageSaveHelper(androidContext()) }
viewModel { params -> viewModel { params ->
ReaderViewModel( ReaderViewModel(
@@ -25,7 +25,7 @@ val readerModule
historyRepository = get(), historyRepository = get(),
settings = get(), settings = get(),
pageSaveHelper = get(), pageSaveHelper = get(),
bookmarksRepository = get(), bookmarksRepository = get()
) )
} }
} }

View File

@@ -4,6 +4,10 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import java.io.File
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -11,19 +15,14 @@ import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.base.domain.MangaUtils import org.koitharu.kotatsu.base.domain.MangaUtils
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
private const val MAX_FILENAME_LENGTH = 10 private const val MAX_FILENAME_LENGTH = 10
private const val EXTENSION_FALLBACK = "png" private const val EXTENSION_FALLBACK = "png"
class PageSaveHelper( class PageSaveHelper(
private val cache: PagesCache,
context: Context, context: Context,
) { ) {
@@ -61,7 +60,11 @@ class PageSaveHelper(
} != null } != null
private suspend fun getProposedFileName(url: String, file: File): String { private suspend fun getProposedFileName(url: String, file: File): String {
var name = url.toHttpUrl().pathSegments.last() var name = if (url.startsWith("cbz://")) {
requireNotNull(url.toUri().fragment)
} else {
url.toHttpUrl().pathSegments.last()
}
var extension = name.substringAfterLast('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {

View File

@@ -166,10 +166,9 @@ class ReaderActivity :
} }
} }
R.id.action_save_page -> { R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page -> viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val page = viewModel.getCurrentPage() ?: return false
viewModel.saveCurrentPage(page, savePageRequest) viewModel.saveCurrentPage(page, savePageRequest)
} ?: return false
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value == true) { if (viewModel.isBookmarkAdded.value == true) {

View File

@@ -7,6 +7,8 @@ import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
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
@@ -26,6 +28,8 @@ open class PageHolder(
View.OnClickListener { View.OnClickListener {
init { init {
binding.ssiv.setExecutor(Dispatchers.Default.asExecutor())
binding.ssiv.setEagerLoadingEnabled(!isLowRamDevice(context))
binding.ssiv.setOnImageEventListener(delegate) binding.ssiv.setOnImageEventListener(delegate)
@Suppress("LeakingThis") @Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)

View File

@@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class WebtoonFrameLayout @JvmOverloads constructor( class WebtoonFrameLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
private val target by lazy { private val target by lazy(LazyThreadSafetyMode.NONE) {
findViewById<WebtoonImageView>(R.id.ssiv) findViewById<WebtoonImageView>(R.id.ssiv)
} }

View File

@@ -13,14 +13,14 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder( class WebtoonHolder(
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver), ) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener { View.OnClickListener {
@@ -29,6 +29,7 @@ class WebtoonHolder(
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) {
@@ -61,9 +62,9 @@ class WebtoonHolder(
override fun onImageShowing(zoom: ZoomMode) { override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * width / sWidth.toFloat()
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
minScale = width / sWidth.toFloat() minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo( scrollTo(
when { when {
scrollToRestore != 0 -> scrollToRestore scrollToRestore != 0 -> scrollToRestore

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.parents
private const val SCROLL_UNKNOWN = -1 private const val SCROLL_UNKNOWN = -1
@@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor(
) : SubsamplingScaleImageView(context, attr) { ) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF() private val ct = PointF()
private val displayHeight = if (context is Activity) {
context.window.decorView.height
} else {
context.resources.displayMetrics.heightPixels
}
private var scrollPos = 0 private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var scrollRange = SCROLL_UNKNOWN
init {
setExecutor(Dispatchers.Default.asExecutor())
setEagerLoadingEnabled(!isLowRamDevice(context))
}
fun scrollBy(delta: Int) { fun scrollBy(delta: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
if (maxScroll == 0) { if (maxScroll == 0) {
@@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
fun scrollTo(y: Int) { fun scrollTo(y: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
if (maxScroll == 0) { if (maxScroll == 0) {
resetScaleAndCenter()
return return
} }
scrollToInternal(y.coerceIn(0, maxScroll)) scrollToInternal(y.coerceIn(0, maxScroll))
@@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor(
override fun getSuggestedMinimumHeight(): Int { override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight() var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0 && desiredHeight < displayHeight) { if (sHeight == 0) {
desiredHeight = displayHeight val parentHeight = parentHeight()
if (desiredHeight < parentHeight) {
desiredHeight = parentHeight
}
} }
return desiredHeight return desiredHeight
} }
@@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor(
} }
} }
width = width.coerceAtLeast(suggestedMinimumWidth) width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight) height = height.coerceIn(suggestedMinimumHeight, parentHeight())
setMeasuredDimension(width, height) setMeasuredDimension(width, height)
} }
@@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor(
val totalHeight = (sHeight * minScale).toIntUp() val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0) scrollRange = (totalHeight - height).coerceAtLeast(0)
} }
private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
}
} }

View File

@@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign import kotlin.math.sign
@Suppress("unused")
class WebtoonLayoutManager : LinearLayoutManager { class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0 private var scrollDirection: Int = 0

View File

@@ -23,7 +23,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false) ) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size import coil.size.Size
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -24,7 +25,6 @@ fun pageThumbnailAD(
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>( ) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
) { ) {
var job: Job? = null var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size( val thumbSize = Size(
@@ -39,6 +39,7 @@ fun pageThumbnailAD(
.data(url) .data(url)
.referer(item.page.referer) .referer(item.page.referer)
.size(thumbSize) .size(thumbSize)
.scale(Scale.FILL)
.allowRgb565(true) .allowRgb565(true)
.build() .build()
).drawable ).drawable

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

@@ -33,15 +33,22 @@ class NewSourcesViewModel(
private fun buildList() { private fun buildList() {
val locales = LocaleListCompat.getDefault().mapToSet { it.language } val locales = LocaleListCompat.getDefault().mapToSet { it.language }
val hidden = settings.hiddenSources val pendingHidden = HashSet<String>()
sources.value = initialList.map { sources.value = initialList.map {
val locale = it.locale val locale = it.locale
val isEnabledByLocale = locale == null || locale in locales
if (!isEnabledByLocale) {
pendingHidden += it.name
}
SourceConfigItem.SourceItem( SourceConfigItem.SourceItem(
source = it, source = it,
summary = it.getLocaleTitle(), summary = it.getLocaleTitle(),
isEnabled = it.name !in hidden && (locale == null || locale in locales), isEnabled = isEnabledByLocale,
isDraggable = false, isDraggable = false
) )
} }
if (pendingHidden.isNotEmpty()) {
settings.hiddenSources += pendingHidden
}
} }
} }

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

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

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.utils
import android.view.View
import android.view.ViewTreeObserver
/**
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
* It`s final so we need this workaround
*/
class GoneOnInvisibleListener(
private val view: View,
) : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (view.visibility == View.INVISIBLE) {
view.visibility = View.GONE
}
}
fun attach() {
view.viewTreeObserver.addOnGlobalLayoutListener(this)
}
}

View File

@@ -1,19 +1,17 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,32 +20,12 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network { val Context.activityManager: ActivityManager?
val request = NetworkRequest.Builder().build() get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// fast path
activeNetwork?.let { return it }
}
return suspendCancellableCoroutine { cont ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
unregisterNetworkCallback(this)
if (cont.isActive) {
cont.resume(network)
}
}
}
registerNetworkCallback(request, callback)
cont.invokeOnCancellation {
unregisterNetworkCallback(callback)
}
}
}
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
@@ -92,4 +70,8 @@ fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
delay(delay) delay(delay)
runnable.run() runnable.run()
} }
}
fun isLowRamDevice(context: Context): Boolean {
return context.activityManager?.isLowRamDevice ?: false
} }

View File

@@ -2,6 +2,7 @@ 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 java.net.SocketTimeoutException
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
@@ -10,13 +11,13 @@ 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)
@@ -37,5 +38,6 @@ fun Throwable.isReportable(): Boolean {
} }
fun Throwable.report(message: String?) { fun Throwable.report(message: String?) {
CaughtException(this, message).sendWithAcra() val exception = CaughtException(this, message)
exception.sendWithAcra()
} }

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -138,4 +139,13 @@ fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
} }
} }
} }
} }
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}

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

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils.progress
import androidx.annotation.AnyThread
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.service.PausingHandle
class PausingProgressJob<P>(
job: Job,
progress: StateFlow<P>,
private val pausingHandle: PausingHandle,
) : ProgressJob<P>(job, progress) {
@get:AnyThread
val isPaused: Boolean
get() = pausingHandle.isPaused
@AnyThread
suspend fun awaitResumed() = pausingHandle.awaitResumed()
@AnyThread
fun pause() = pausingHandle.pause()
@AnyThread
fun resume() = pausingHandle.resume()
}

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class ProgressJob<P>( open class ProgressJob<P>(
private val job: Job, private val job: Job,
private val progress: StateFlow<P>, private val progress: StateFlow<P>,
) : Job by job { ) : Job by job {

View File

@@ -15,12 +15,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.history.domain.HistoryRepository 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.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap import org.koitharu.kotatsu.utils.ext.requireBitmap
class RecentListFactory( class RecentListFactory(
private val context: Context, private val context: Context,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val coil: ImageLoader private val coil: ImageLoader,
) : RemoteViewsService.RemoteViewsFactory { ) : RemoteViewsService.RemoteViewsFactory {
private val dataSet = ArrayList<Manga>() private val dataSet = ArrayList<Manga>()
@@ -29,7 +30,7 @@ class RecentListFactory(
) )
private val coverSize = Size( private val coverSize = Size(
context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height), context.resources.getDimensionPixelSize(R.dimen.widget_cover_height)
) )
override fun onCreate() = Unit override fun onCreate() = Unit
@@ -39,9 +40,8 @@ class RecentListFactory(
override fun getItemId(position: Int) = dataSet[position].id override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() { override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking { historyRepository.getList(0, 10) } val data = runBlocking { historyRepository.getList(0, 10) }
dataSet.addAll(data) dataSet.replaceWith(data)
} }
override fun hasStableIds() = true override fun hasStableIds() = true

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap import org.koitharu.kotatsu.utils.ext.requireBitmap
class ShelfListFactory( class ShelfListFactory(
@@ -32,7 +33,7 @@ class ShelfListFactory(
) )
private val coverSize = Size( private val coverSize = Size(
context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height), context.resources.getDimensionPixelSize(R.dimen.widget_cover_height)
) )
override fun onCreate() = Unit override fun onCreate() = Unit
@@ -42,7 +43,6 @@ class ShelfListFactory(
override fun getItemId(position: Int) = dataSet[position].id override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() { override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking { val data = runBlocking {
val category = config.categoryId val category = config.categoryId
if (category == 0L) { if (category == 0L) {
@@ -51,7 +51,7 @@ class ShelfListFactory(
favouritesRepository.getManga(category) favouritesRepository.getManga(category)
} }
} }
dataSet.addAll(data) dataSet.replaceWith(data)
} }
override fun hasStableIds() = true override fun hasStableIds() = true
@@ -85,4 +85,4 @@ class ShelfListFactory(
override fun getViewTypeCount() = 1 override fun getViewTypeCount() = 1
override fun onDestroy() = Unit override fun onDestroy() = Unit
} }

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M3 16H10V14H3M18 14V10H16V14H12V16H16V20H18V16H22V14M14 6H3V8H14M14 10H3V12H14V10Z" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" />
</vector>

View File

@@ -217,7 +217,7 @@
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks" app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -9,7 +9,7 @@
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView" android:id="@+id/navigationView"
android:layout_width="260dp" android:layout_width="230dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="false" android:fitsSystemWindows="false"
app:drawerLayoutCornerSize="0dp" app:drawerLayoutCornerSize="0dp"
@@ -91,4 +91,4 @@
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout> </LinearLayout>

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

@@ -12,6 +12,7 @@
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
app:menu="@menu/opt_categories"
app:title="@string/add_to_favourites"> app:title="@string/add_to_favourites">
<Button <Button
@@ -35,15 +36,4 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_checkable_new" /> tools:listitem="@layout/item_checkable_new" />
<TextView
android:id="@+id/item_create"
style="?listItemTextViewStyle"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/create_category"
android:textAppearance="?attr/textAppearanceButton" />
</LinearLayout> </LinearLayout>

View File

@@ -40,8 +40,6 @@
android:maxLines="4" android:maxLines="4"
android:padding="@dimen/margin_normal" android:padding="@dimen/margin_normal"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Design.Snackbar.Message"
android:textColor="@android:color/white"
tools:text="Look at all the wonderful snack bar text..." /> tools:text="Look at all the wonderful snack bar text..." />
<Button <Button
@@ -53,7 +51,6 @@
android:paddingStart="@dimen/margin_normal" android:paddingStart="@dimen/margin_normal"
android:paddingEnd="@dimen/margin_normal" android:paddingEnd="@dimen/margin_normal"
android:visibility="gone" android:visibility="gone"
tools:targetApi="o"
tools:text="Action" tools:text="Action"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@@ -214,7 +214,7 @@
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks" app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

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

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_create"
android:icon="@drawable/ic_list_create"
android:title="@string/create_category"
app:showAsAction="always" />
</menu>

View File

@@ -317,4 +317,5 @@
<string name="invalid_domain_message">Ungültige Domäne</string> <string name="invalid_domain_message">Ungültige Domäne</string>
<string name="status_reading">Lesen</string> <string name="status_reading">Lesen</string>
<string name="select_range">Bereich auswählen</string> <string name="select_range">Bereich auswählen</string>
<string name="not_found_404">Inhalt nicht gefunden oder entfernt</string>
</resources> </resources>

View File

@@ -309,4 +309,5 @@
<string name="data_deletion">Tietojen poistaminen</string> <string name="data_deletion">Tietojen poistaminen</string>
<string name="show_all">Näytä kaikki</string> <string name="show_all">Näytä kaikki</string>
<string name="select_range">Valitse alue</string> <string name="select_range">Valitse alue</string>
<string name="not_found_404">Sisältöä ei löydy tai se on poistettu</string>
</resources> </resources>

View File

@@ -319,4 +319,5 @@
<string name="status_re_reading">Relecture</string> <string name="status_re_reading">Relecture</string>
<string name="invalid_domain_message">Domaine invalide</string> <string name="invalid_domain_message">Domaine invalide</string>
<string name="select_range">Sélectionner une plage</string> <string name="select_range">Sélectionner une plage</string>
<string name="not_found_404">Contenu non trouvé ou supprimé</string>
</resources> </resources>

View File

@@ -319,4 +319,5 @@
<string name="status_dropped">Abbandonato</string> <string name="status_dropped">Abbandonato</string>
<string name="invalid_domain_message">Dominio non valido</string> <string name="invalid_domain_message">Dominio non valido</string>
<string name="select_range">Seleziona l\'intervallo</string> <string name="select_range">Seleziona l\'intervallo</string>
<string name="not_found_404">Contenuto non trovato o rimosso</string>
</resources> </resources>

View File

@@ -318,4 +318,6 @@
<string name="exclude_nsfw_from_history_summary">NSFWとマークされたマンガは履歴に追加されず、進行状況も保存されない</string> <string name="exclude_nsfw_from_history_summary">NSFWとマークされたマンガは履歴に追加されず、進行状況も保存されない</string>
<string name="show_all">すべて表示</string> <string name="show_all">すべて表示</string>
<string name="invalid_domain_message">無効なドメイン</string> <string name="invalid_domain_message">無効なドメイン</string>
<string name="select_range">範囲を選択</string>
<string name="not_found_404">コンテンツが見つからない、または削除された</string>
</resources> </resources>

View File

@@ -269,4 +269,55 @@
<string name="percent_string_pattern">%1$s%%</string> <string name="percent_string_pattern">%1$s%%</string>
<string name="batch_manga_save_confirm">Är du säker på att du vill ladda ner alla utvalda manga med alla kapitel\? Den här åtgärden kan kräva mycket nätverkstrafik och lagringsutrymme</string> <string name="batch_manga_save_confirm">Är du säker på att du vill ladda ner alla utvalda manga med alla kapitel\? Den här åtgärden kan kräva mycket nätverkstrafik och lagringsutrymme</string>
<string name="parallel_downloads">Parallella nedladdningar</string> <string name="parallel_downloads">Parallella nedladdningar</string>
<string name="hide">Dölj</string>
<string name="disable_all">Inaktivera alla</string>
<string name="invalid_domain_message">Ogiltig domän</string>
<string name="dns_over_https">DNS över HTTPS</string>
<string name="detect_reader_mode">Autodetektera läsarläge</string>
<string name="disable_battery_optimization">Inaktivera batterioptimering</string>
<string name="local_manga_processing">Bearbetar nedladdad manga</string>
<string name="detect_reader_mode_summary">Automatiskt detektera om manga är en webtoon</string>
<string name="appwidget_recent_description">Dina nyligen lästa manga</string>
<string name="disable_battery_optimization_summary">Hjälper till med att leta efter uppdateringar i bakgrunden</string>
<string name="new_sources_text">Nya mangakällor finns tillgängliga</string>
<string name="report">Anmäl</string>
<string name="chapters_will_removed_background">Kapitel kommer att tas bort i bakgrunden. Det kan ta lite tid</string>
<string name="tracking">Spårning</string>
<string name="logout">Logga ut</string>
<string name="default_mode">Standardläge</string>
<string name="status_planned">Planerad</string>
<string name="status_reading">Läser</string>
<string name="status_re_reading">Läser om</string>
<string name="status_completed">Läst</string>
<string name="status_on_hold">Pausad</string>
<string name="status_dropped">Övergiven</string>
<string name="use_fingerprint">Använd fingeravtryck om tillgängligt</string>
<string name="appwidget_shelf_description">Manga från dina favoriter</string>
<string name="show_reading_indicators">Visa indikatorer om läsförlopp</string>
<string name="data_deletion">Radering av data</string>
<string name="show_reading_indicators_summary">Visa hur långt du har läst i procent på listor för historik och favoriter</string>
<string name="exclude_nsfw_from_history_summary">Manga markerad som NSFW kommer aldrig läggas till i historiken och din framfart kommer inte att sparas</string>
<string name="clear_cookies_summary">Kan vara till hjälp för att lösa vissa problem. Alla auktoriseringar kommer att ogiltigförklaras</string>
<string name="download_slowdown">Begränsa nedladdningshastighet</string>
<string name="check_new_chapters_title">Leta efter och avisera om nya kapitel</string>
<string name="show_notification_new_chapters_on">Du kommer att få aviseringar om uppdateringar på manga du läser</string>
<string name="show_notification_new_chapters_off">Do kommer inte att få aviseringar men nya kapitel kommer att markeras i listan</string>
<string name="notifications_enable">Aktivera aviseringar</string>
<string name="empty_favourite_categories">Inga favoritkategorier</string>
<string name="name">Namn</string>
<string name="edit">Redigera</string>
<string name="edit_category">Redigera kategori</string>
<string name="show_all">Visa alla</string>
<string name="removed_from_history">Borttaget från historiken</string>
<string name="bookmark_add">Lägg till bokmärke</string>
<string name="bookmark_remove">Ta bort bokmärke</string>
<string name="bookmarks">Bokmärken</string>
<string name="bookmark_removed">Bokmärke borttaget</string>
<string name="bookmark_added">Bokmärke tillagt</string>
<string name="undo">Ångra</string>
<string name="download_slowdown_summary">Hjälper för att undvika att din IP-adress blir blockerad</string>
<string name="select_range">Välj intervall</string>
<string name="not_found_404">Innehållet kunde inte hittas eller har tagits bort</string>
<string name="crash_text">Något gick fel. Skicka en felrapport till utvecklarna för att hjälpa oss att åtgärda problemet.</string>
<string name="send">Skicka</string>
</resources> </resources>

View File

@@ -319,4 +319,5 @@
<string name="show_all">Tümünü göster</string> <string name="show_all">Tümünü göster</string>
<string name="invalid_domain_message">Geçersiz etki alanı</string> <string name="invalid_domain_message">Geçersiz etki alanı</string>
<string name="select_range">Aralık seç</string> <string name="select_range">Aralık seç</string>
<string name="not_found_404">İçerik bulunamadı veya kaldırıldı</string>
</resources> </resources>

View File

@@ -318,4 +318,6 @@
<string name="exclude_nsfw_from_history_summary">Манґа, позначена як NSFW, ніколи не буде додана до історії і ваш прогрес не буде збережений</string> <string name="exclude_nsfw_from_history_summary">Манґа, позначена як NSFW, ніколи не буде додана до історії і ваш прогрес не буде збережений</string>
<string name="clear_cookies_summary">Може допомогти в разі виникнення проблем. Усі авторизації будуть анульовані</string> <string name="clear_cookies_summary">Може допомогти в разі виникнення проблем. Усі авторизації будуть анульовані</string>
<string name="show_all">Показати всі</string> <string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</string>
<string name="not_found_404">Вміст не знайдено або видалено</string>
</resources> </resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="url_github">https://github.com/nv95/Kotatsu</string> <string name="url_github">https://github.com/KotatsuApp/Kotatsu</string>
<string name="url_discord">https://discord.gg/NNJ5RgVBC5</string> <string name="url_discord">https://discord.gg/NNJ5RgVBC5</string>
<string name="url_forpda">https://4pda.to/forum/index.php?showtopic=697669</string> <string name="url_forpda">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_twitter">https://twitter.com/kotatsuapp</string> <string name="url_twitter">https://twitter.com/kotatsuapp</string>