Compare commits

...

29 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
76 changed files with 762 additions and 402 deletions

View File

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

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 418
versionName '3.4.6'
versionCode 421
versionName '3.4.9'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,19 +79,19 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.nv95:kotatsu-parsers:fadb06aabb') {
implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.5.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0'
implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
@@ -101,11 +101,11 @@ dependencies {
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha03'
//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-ktx:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2'
implementation 'androidx.room:room-runtime:2.4.3'
implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.3'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
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-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'
}

View File

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

View File

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

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.webkit.WebView
import org.koin.core.component.KoinComponent
import org.koitharu.kotatsu.core.network.WebViewClientCompat
import android.webkit.WebViewClient
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)

View File

@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.ProgressBar
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
class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>,
private val progressIndicator: ProgressBar,
) : WebChromeClient() {
init {
@@ -24,7 +25,7 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} 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.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.WebViewClientCompat
private const val CF_CLEARANCE = "cf_clearance"
class CloudFlareClient(
private val cookieJar: AndroidCookieJar,
private val callback: CloudFlareCallback,
private val targetUrl: String
) : WebViewClientCompat() {
private val targetUrl: String,
) : WebViewClient() {
private val oldClearance = getCookieValue(CF_CLEARANCE)
private val oldClearance = getClearance()
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
@@ -32,14 +32,14 @@ class CloudFlareClient(
}
private fun checkClearance() {
val clearance = getCookieValue(CF_CLEARANCE)
val clearance = getClearance()
if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed()
}
}
private fun getCookieValue(name: String): String? {
private fun getClearance(): String? {
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
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga
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
import java.util.*
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.*
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
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 okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaSource
class FaviconMapper : Mapper<Uri, HttpUrl> {
@@ -13,7 +13,7 @@ class FaviconMapper : Mapper<Uri, HttpUrl> {
if (data.scheme != "favicon") {
return null
}
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl()
}

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.details.ui
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.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel(
intent: MangaIntent,
@@ -91,8 +95,8 @@ class DetailsViewModel(
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml())
emit(description.parseAsHtml(imageGetter = imageGetter))
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
@@ -110,7 +114,7 @@ class DetailsViewModel(
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch
delegate.selectedBranch,
) { branches, selected ->
branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
@@ -225,7 +229,7 @@ class DetailsViewModel(
fun unregisterScrobbling() {
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId
mangaId = delegate.mangaId,
)
}
}
@@ -242,4 +246,13 @@ class DetailsViewModel(
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
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
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.prefs.AppSettings
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.MangaChapter
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
class MangaDetailsDelegate(
@@ -45,12 +42,7 @@ class MangaDetailsDelegate(
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
selectedBranch.value = manga.getPreferredBranch(hist)
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
@@ -91,7 +83,7 @@ class MangaDetailsDelegate(
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
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) {
val chapter = chapters[i]
if (chapter.branch != branch) {
@@ -106,6 +98,9 @@ class MangaDetailsDelegate(
dateFormat = dateFormat,
)
}
if (result.size < chapters.size / 2) {
result.trimToSize()
}
return result
}
@@ -161,24 +156,9 @@ class MangaDetailsDelegate(
}
result.sortBy { it.chapter.number }
}
if (result.size < sourceChapters.size / 2) {
result.trimToSize()
}
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
import java.text.DateFormat
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
val chapter: MangaChapter,
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
get() = flags and MASK_STATUS
@@ -32,7 +43,8 @@ class ChapterListItem(
if (chapter != other.chapter) 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
}
@@ -40,7 +52,8 @@ class ChapterListItem(
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + (uploadDate?.hashCode() ?: 0)
result = 31 * result + uploadDateMs.hashCode()
result = 31 * result + dateFormat.hashCode()
return result
}
@@ -53,4 +66,4 @@ class ChapterListItem(
const val FLAG_DOWNLOADED = 32
const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
}
}
}

View File

@@ -1,12 +1,12 @@
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_DOWNLOADED
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_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter
import java.text.DateFormat
fun MangaChapter.toListItem(
isCurrent: Boolean,
@@ -25,6 +25,7 @@ fun MangaChapter.toListItem(
return ChapterListItem(
chapter = this,
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
import android.content.Context
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import java.io.File
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
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.parser.MangaRepository
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.domain.CbzMangaOutput
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.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L
@@ -43,9 +42,6 @@ class DownloadManager(
private val settings: AppSettings,
) {
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width
)
@@ -58,21 +54,24 @@ class DownloadManager(
manga: Manga,
chaptersIds: LongArray?,
startId: Int,
): ProgressJob<DownloadState> {
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null)
)
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
return ProgressJob(job, stateFlow)
val pausingHandle = PausingHandle()
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
@@ -108,38 +107,28 @@ class DownloadManager(
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
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()) {
var retryCounter = 0
failsafe@ while (true) {
try {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
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
}
}
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url)
)
}
outState.value = DownloadState.Progress(
startId, data, cover,
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
currentPage = pageIndex
)
if (settings.isDownloadsSlowdownEnabled) {
@@ -157,15 +146,40 @@ class DownloadManager(
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e)
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
}
}
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,
cover = prevValue.cover,
error = throwable,
canRetry = false
)
}
@@ -225,7 +240,7 @@ class DownloadManager(
okHttp = okHttp,
cache = cache,
localMangaRepository = localMangaRepository,
settings = settings,
settings = settings
)
}
}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
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.utils.ext.connectivityManager
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.TimeLeftEstimator
import java.util.concurrent.TimeUnit
class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager
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 controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null
@@ -49,10 +50,13 @@ class DownloadService : BaseService() {
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1))
)
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 {
@@ -90,7 +94,7 @@ class DownloadService : BaseService() {
startId: Int,
manga: Manga,
chaptersIds: LongArray?,
): ProgressJob<DownloadState> {
): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
return job
@@ -144,7 +148,7 @@ class DownloadService : BaseService() {
}
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() {
@@ -155,6 +159,10 @@ class DownloadService : BaseService() {
jobs.remove(cancelId)?.cancel()
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"
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_CHAPTERS_IDS = "chapters_ids"
@@ -219,6 +228,9 @@ class DownloadService : BaseService() {
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
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.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -26,7 +28,8 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
View.OnClickListener,
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@@ -44,7 +47,7 @@ class FavouriteCategoriesBottomSheet :
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this)
binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,11 +60,18 @@ class FavouriteCategoriesBottomSheet :
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
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) {
viewModel.setChecked(item.id, !item.isChecked)
}

View File

@@ -68,7 +68,7 @@ abstract class MangaListFragment :
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentListBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -76,13 +76,13 @@ abstract class MangaListFragment :
listAdapter = MangaListAdapter(
coil = get(),
lifecycleOwner = viewLifecycleOwner,
listener = this,
listener = this
)
selectionController = ListSelectionController(
activity = requireActivity(),
decoration = MangaSelectionDecoration(view.context),
registryOwner = this,
callback = this,
callback = this
)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
@@ -97,7 +97,7 @@ abstract class MangaListFragment :
setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled
}
addMenuProvider(MangaListMenuProvider(childFragmentManager))
addMenuProvider(MangaListMenuProvider(this))
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -171,21 +171,21 @@ abstract class MangaListFragment :
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding(
left = insets.left,
right = insets.right,
right = insets.right
)
if (activity is MainActivity) {
binding.recyclerView.updatePadding(
top = headerHeight,
bottom = insets.bottom,
bottom = insets.bottom
)
binding.swipeRefreshLayout.setProgressViewOffset(
true,
headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10),
headerHeight + resources.resolveDp(10)
)
} else {
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.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R
class MangaListMenuProvider(
private val fragmentManager: FragmentManager,
private val fragment: Fragment,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -17,7 +17,7 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> {
ListModeSelectDialog.show(fragmentManager)
ListModeSelectDialog.show(fragment.childFragmentManager)
true
}
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.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD(
coil: ImageLoader,
@@ -24,9 +25,8 @@ fun mangaGridItemAD(
clickListener: OnListItemClickListener<Manga>,
sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {
@@ -46,6 +46,7 @@ fun mangaGridItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(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 coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaListDetailedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {
@@ -36,6 +36,7 @@ fun mangaListDetailedItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(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 coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
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.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
fun mangaListItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {

View File

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

View File

@@ -4,6 +4,10 @@ import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
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.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -11,19 +15,14 @@ import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException
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.util.toFileNameSafe
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 EXTENSION_FALLBACK = "png"
class PageSaveHelper(
private val cache: PagesCache,
context: Context,
) {
@@ -61,7 +60,11 @@ class PageSaveHelper(
} != null
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('.', "")
name = name.substringBeforeLast('.')
if (extension.length !in 2..4) {

View File

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

View File

@@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import org.koitharu.kotatsu.R
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) {
private val target by lazy {
private val target by lazy(LazyThreadSafetyMode.NONE) {
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.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder(
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener {
@@ -29,6 +29,7 @@ class WebtoonHolder(
init {
binding.ssiv.setOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)
GoneOnInvisibleListener(bindingInfo.progressBar).attach()
}
override fun onBind(data: ReaderPage) {
@@ -61,9 +62,9 @@ class WebtoonHolder(
override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) {
maxScale = 2f * width / sWidth.toFloat()
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo(
when {
scrollToRestore != 0 -> scrollToRestore

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import androidx.recyclerview.widget.RecyclerView
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.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.parents
private const val SCROLL_UNKNOWN = -1
@@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor(
) : SubsamplingScaleImageView(context, attr) {
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 scrollRange = SCROLL_UNKNOWN
init {
setExecutor(Dispatchers.Default.asExecutor())
setEagerLoadingEnabled(!isLowRamDevice(context))
}
fun scrollBy(delta: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
@@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
fun scrollTo(y: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
resetScaleAndCenter()
return
}
scrollToInternal(y.coerceIn(0, maxScroll))
@@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor(
override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0 && desiredHeight < displayHeight) {
desiredHeight = displayHeight
if (sHeight == 0) {
val parentHeight = parentHeight()
if (desiredHeight < parentHeight) {
desiredHeight = parentHeight
}
}
return desiredHeight
}
@@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor(
}
}
width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight)
height = height.coerceIn(suggestedMinimumHeight, parentHeight())
setMeasuredDimension(width, height)
}
@@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor(
val totalHeight = (sHeight * minScale).toIntUp()
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 kotlin.math.sign
@Suppress("unused")
class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0

View File

@@ -23,7 +23,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
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 coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size
import com.google.android.material.R as materialR
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -24,7 +25,6 @@ fun pageThumbnailAD(
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size(
@@ -39,6 +39,7 @@ fun pageThumbnailAD(
.data(url)
.referer(item.page.referer)
.size(thumbSize)
.scale(Scale.FILL)
.allowRgb565(true)
.build()
).drawable

View File

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

View File

@@ -33,15 +33,22 @@ class NewSourcesViewModel(
private fun buildList() {
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
val hidden = settings.hiddenSources
val pendingHidden = HashSet<String>()
sources.value = initialList.map {
val locale = it.locale
val isEnabledByLocale = locale == null || locale in locales
if (!isEnabledByLocale) {
pendingHidden += it.name
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it.name !in hidden && (locale == null || locale in locales),
isDraggable = false,
isEnabled = isEnabledByLocale,
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.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import java.util.*
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.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class OnboardViewModel(
private val settings: AppSettings,
@@ -27,7 +28,7 @@ class OnboardViewModel(
init {
if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale })
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale })
} else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language
@@ -66,7 +67,7 @@ class OnboardViewModel(
SourceLocale(
key = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isChecked = key in selectedLocales
isChecked = key in selectedLocales,
)
}.sortedWith(SourceLocaleComparator())
}

View File

@@ -5,8 +5,8 @@ import android.content.res.TypedArray
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.customview.view.AbsSavedState
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.material.slider.Slider
@@ -40,11 +40,11 @@ class SliderPreference @JvmOverloads constructor(
attrs,
R.styleable.SliderPreference,
defStyleAttr,
defStyleRes
defStyleRes,
) {
valueFrom = getFloat(
R.styleable.SliderPreference_android_valueFrom,
valueFrom.toFloat()
valueFrom.toFloat(),
).toInt()
valueTo =
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 valueTo: Int
@@ -134,7 +134,7 @@ class SliderPreference @JvmOverloads constructor(
this.currentValue = currentValue
}
constructor(source: Parcel) : super(source) {
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
valueFrom = source.readInt()
valueTo = source.readInt()
currentValue = source.readInt()
@@ -148,9 +148,10 @@ class SliderPreference @JvmOverloads constructor(
}
companion object {
@Suppress("unused")
@JvmField
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)
}

View File

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

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
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.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.tracker.work.TrackingItem
class Tracker(
private val settings: AppSettings,
private val repository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val channels: TrackerNotificationChannels,
) {
@@ -68,7 +71,7 @@ class Tracker(
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates {
val manga = MangaRepository(track.manga.source).getDetails(track.manga)
val updates = compare(track, manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
@@ -78,7 +81,7 @@ class Tracker(
@VisibleForTesting
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates {
val track = repository.getTrack(manga)
val updates = compare(track, manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
@@ -90,25 +93,30 @@ class Tracker(
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]
*/
private fun compare(track: MangaTracking, manga: Manga): MangaUpdates {
private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates {
if (track.isEmpty()) {
// first check or manga was empty on last check
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 }
return when {
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 -> {
return MangaUpdates(manga, emptyList(), isValid = false)
MangaUpdates(manga, emptyList(), isValid = false)
}
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.model.ListModel
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.MangaTag
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.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
class FeedFragment :
BaseFragment<FragmentFeedBinding>(),
@@ -39,7 +41,7 @@ class FeedFragment :
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentFeedBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -54,7 +56,7 @@ class FeedFragment :
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
@@ -77,12 +79,25 @@ class FeedFragment :
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerView.updatePadding(
top = headerHeight + paddingVertical,
left = insets.left + paddingHorizontal,
right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
binding.root.updatePadding(
left = insets.left,
right = insets.right,
)
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
@@ -101,7 +116,7 @@ class FeedFragment :
Snackbar.make(
binding.recyclerView,
R.string.updates_feed_cleared,
Snackbar.LENGTH_LONG
Snackbar.LENGTH_LONG,
).show()
}
@@ -109,7 +124,7 @@ class FeedFragment :
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_SHORT,
).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.model.TrackingLogItem
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.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
class FeedViewModel(
private val repository: TrackingRepository
private val repository: TrackingRepository,
) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
@@ -32,7 +33,7 @@ class FeedViewModel(
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
logList.filterNotNull(),
hasNextPage
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
if (list.isEmpty()) {
@@ -43,7 +44,7 @@ class FeedViewModel(
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
)
),
)
} else {
list.mapListTo(this)
@@ -54,7 +55,7 @@ class FeedViewModel(
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(header, LoadingState)
listOf(header, LoadingState),
)
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
import android.app.ActivityManager
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.SharedPreferences
import android.content.pm.ResolveInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
@@ -22,32 +20,12 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
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)
}
}
}
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
@@ -92,4 +70,8 @@ fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
delay(delay)
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.res.Resources
import java.net.SocketTimeoutException
import okio.FileNotFoundException
import org.acra.ktx.sendWithAcra
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.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
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 FileNotFoundException -> resources.getString(R.string.file_not_found)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
@@ -37,5 +38,6 @@ fun Throwable.isReportable(): Boolean {
}
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.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import androidx.core.view.children
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
import android.graphics.Bitmap
import androidx.core.graphics.get
import androidx.annotation.ColorInt
import androidx.core.graphics.*
import coil.size.Size
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
@@ -20,7 +24,7 @@ class TrimTransformation : Transformation {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false
break
}
@@ -39,7 +43,7 @@ class TrimTransformation : Transformation {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false
break
}
@@ -55,7 +59,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false
break
}
@@ -71,7 +75,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false
break
}
@@ -93,4 +97,11 @@ class TrimTransformation : Transformation {
override fun equals(other: Any?) = other is TrimTransformation
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.StateFlow
class ProgressJob<P>(
open class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : 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.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap
class RecentListFactory(
private val context: Context,
private val historyRepository: HistoryRepository,
private val coil: ImageLoader
private val coil: ImageLoader,
) : RemoteViewsService.RemoteViewsFactory {
private val dataSet = ArrayList<Manga>()
@@ -29,7 +30,7 @@ class RecentListFactory(
)
private val coverSize = Size(
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
@@ -39,9 +40,8 @@ class RecentListFactory(
override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking { historyRepository.getList(0, 10) }
dataSet.addAll(data)
dataSet.replaceWith(data)
}
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.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap
class ShelfListFactory(
@@ -32,7 +33,7 @@ class ShelfListFactory(
)
private val coverSize = Size(
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
@@ -42,7 +43,6 @@ class ShelfListFactory(
override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking {
val category = config.categoryId
if (category == 0L) {
@@ -51,7 +51,7 @@ class ShelfListFactory(
favouritesRepository.getManga(category)
}
}
dataSet.addAll(data)
dataSet.replaceWith(data)
}
override fun hasStableIds() = true
@@ -85,4 +85,4 @@ class ShelfListFactory(
override fun getViewTypeCount() = 1
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"
tools:visibility="visible" />
<TextView
<org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

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

View File

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

View File

@@ -12,6 +12,7 @@
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:menu="@menu/opt_categories"
app:title="@string/add_to_favourites">
<Button
@@ -35,15 +36,4 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
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>

View File

@@ -40,8 +40,6 @@
android:maxLines="4"
android:padding="@dimen/margin_normal"
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..." />
<Button
@@ -53,7 +51,6 @@
android:paddingStart="@dimen/margin_normal"
android:paddingEnd="@dimen/margin_normal"
android:visibility="gone"
tools:targetApi="o"
tools:text="Action"
tools:visibility="visible" />

View File

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

View File

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

View File

@@ -12,10 +12,9 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="0dp"
android:layout_height="42dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
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="status_reading">Lesen</string>
<string name="select_range">Bereich auswählen</string>
<string name="not_found_404">Inhalt nicht gefunden oder entfernt</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

@@ -269,4 +269,55 @@
<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="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>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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_forpda">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_twitter">https://twitter.com/kotatsuapp</string>