Compare commits

...

57 Commits

Author SHA1 Message Date
Koitharu
15d0addb7b Fix Remanga chapters parsing 2022-02-22 19:34:00 +02:00
Koitharu
1713efb51f Increase version 2022-02-20 18:43:12 +02:00
Koitharu
9089555320 Fix internal storage sharing #98 2022-02-20 18:41:38 +02:00
Koitharu
2f3b1f397c Fix Remanga source and check for paid chapters #101 2022-02-20 18:40:47 +02:00
Koitharu
7ebb98ce06 Allow overwrite non-empty download directory #99 2022-02-20 18:13:54 +02:00
Koitharu
c218ae0baa Merge branch 'release/2.1.4' 2022-02-12 20:37:41 +02:00
Koitharu
5820b2f511 Fix saved page sharing 2022-02-12 20:31:30 +02:00
Koitharu
79c2bf17fd Quick search across manga sources 2022-02-12 15:16:30 +02:00
Koitharu
78aa4d76db Show favicons in sources list 2022-02-12 15:16:11 +02:00
Koitharu
e2f3ba19b8 Update dependencies minor versions 2022-02-12 15:05:58 +02:00
Koitharu
41045686fc Increase version 2022-02-12 14:58:03 +02:00
Koitharu
8b0b375dfe Fix ActivityNotFoundException 2022-02-12 14:57:27 +02:00
Koitharu
c7c23b9768 Fix ItemTouchHelper leak 2022-02-12 14:57:16 +02:00
Koitharu
33190ae3ea Fix DownloadBinder leak 2022-02-12 14:57:10 +02:00
Koitharu
03590f4b82 Fix widgets context leak 2022-02-12 14:57:03 +02:00
Koitharu
cbcf98e1d4 Fix blocking calls in coroutines 2022-02-12 14:56:30 +02:00
Koitharu
e3a80b5a6d Enhance download cancellation in blocking io tasks #90 2022-01-23 10:31:50 +02:00
Koitharu
66dc5a9597 Fix MangaTown licensed chapters 2022-01-23 10:31:50 +02:00
Koitharu
cb6bf91dd3 Fix missing fragment crash #91 2022-01-23 10:31:50 +02:00
Koitharu
fb815abad0 Fix widgets #86 2022-01-23 10:31:50 +02:00
Koitharu
8ef7580097 Fix MangaRead parse #87 2022-01-23 10:31:50 +02:00
Koitharu
197393fbd1 Fix webtoon scroll 2022-01-15 17:21:03 +02:00
J. Lavoie
51ef6e3c78 Translated using Weblate (French)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-15 08:37:11 +02:00
J. Lavoie
663277fe6f Translated using Weblate (Italian)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-15 08:37:11 +02:00
J. Lavoie
332a38d674 Translated using Weblate (German)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-15 08:37:11 +02:00
Zakhar Timoshenko
e9410a2f54 [MangaOwl] Fix missing pages 2022-01-15 08:20:17 +02:00
Koitharu
b5fa2bd660 Fix MangaDex pages extraction 2022-01-14 08:50:04 +02:00
Koitharu
e56c61d834 Update manga parsers 2022-01-10 18:36:52 +02:00
Koitharu
677f71dd84 Increase version 2022-01-10 08:53:54 +02:00
Allan Nordhøy
3f90f88600 Translated using Weblate (Norwegian Bokmål)
Currently translated at 87.9% (219 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-10 08:03:16 +02:00
Koitharu
229a7c70d9 Implement new manga sources settings list screen #78 2022-01-07 19:04:59 +02:00
Koitharu
a2dbec98f9 Fix Ninemanga filter 2022-01-06 17:46:46 +02:00
Koitharu
3a02f8090e Fix pages numbers 2022-01-06 17:31:49 +02:00
Koitharu
17519db44e Refactor sources settings list 2022-01-06 17:26:16 +02:00
Koitharu
99186bf269 Show pages numbers in reader 2022-01-06 09:58:20 +02:00
Koitharu
9a65e40be1 Update page flip animation 2022-01-05 15:46:22 +02:00
Koitharu
f0add59f99 Fix smooth cover loading in details 2022-01-05 15:04:14 +02:00
Koitharu
f18c182a6a Refactor filters 2022-01-05 14:49:18 +02:00
Koitharu
68e9588f24 Fix AniBel endpoint 2022-01-05 11:49:56 +02:00
Koitharu
eea427216d Fullscreen cover view activity 2022-01-05 11:42:03 +02:00
Koitharu
8e9b89f6f0 Fix GroupLe sources covers 2022-01-05 10:51:36 +02:00
Koitharu
4f3281be99 MangaDex source 2022-01-05 10:43:32 +02:00
badlop
eb56a82702 Translated using Weblate (Spanish)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2022-01-04 12:37:53 +02:00
nzgha
089ccc9d15 Translated using Weblate (Spanish)
Currently translated at 99.1% (244 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2022-01-04 12:37:53 +02:00
J. Lavoie
12c1365513 Translated using Weblate (Finnish)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
2022-01-04 12:37:53 +02:00
J. Lavoie
7ecf9316e3 Translated using Weblate (French)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-04 12:37:53 +02:00
J. Lavoie
12e98ec36a Translated using Weblate (Italian)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-04 12:37:53 +02:00
J. Lavoie
22977fc7bc Translated using Weblate (German)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-04 12:37:53 +02:00
Aliaksiej Razumaŭ
b387a49a4e Translated using Weblate (Belarusian)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
dbbb0d0f64 Translated using Weblate (Norwegian Bokmål)
Currently translated at 88.2% (217 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
0bbf2b752f Translated using Weblate (English)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2022-01-04 12:37:53 +02:00
Aliaksiej Razumaŭ
14c1eacffa Translated using Weblate (Belarusian)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2022-01-04 12:37:53 +02:00
J. Lavoie
c2a0525bb8 Translated using Weblate (French)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-04 12:37:53 +02:00
J. Lavoie
4f502e580c Translated using Weblate (Italian)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-04 12:37:53 +02:00
J. Lavoie
7cb303966a Translated using Weblate (German)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
3f0431f88b Translated using Weblate (Norwegian Bokmål)
Currently translated at 87.3% (214 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
aad5601df1 Translated using Weblate (English)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2022-01-04 12:37:53 +02:00
115 changed files with 2012 additions and 839 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 31
versionCode 374
versionName '2.0.2'
versionCode 381
versionName '2.1.5'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -24,10 +24,6 @@ android {
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
debug {
applicationIdSuffix = '.debug'
@@ -45,6 +41,17 @@ android {
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
]
}
lintOptions {
disable 'MissingTranslation'
abortOnError false
@@ -54,28 +61,20 @@ android {
unitTests.returnDefaultValues = false
}
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
]
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
@@ -83,11 +82,11 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.4.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
implementation 'androidx.room:room-runtime:2.3.0'
implementation 'androidx.room:room-ktx:2.3.0'
kapt 'androidx.room:room-compiler:2.3.0'
implementation 'androidx.room:room-runtime:2.4.1'
implementation 'androidx.room:room-ktx:2.4.1'
kapt 'androidx.room:room-compiler:2.4.1'
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.okio:okio:2.10.0'
@@ -99,20 +98,20 @@ dependencies {
implementation 'io.insert-koin:koin-android:3.1.4'
implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.3'
implementation 'com.github.solkin:disk-lru-cache:1.4'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
testImplementation 'junit:junit:4.13.2'
testImplementation 'com.google.truth:truth:1.1.3'
testImplementation 'org.json:json:20210307'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
testImplementation 'org.json:json:20211205'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.room:room-testing:2.3.0'
androidTestImplementation 'androidx.room:room-testing:2.4.1'
androidTestImplementation 'com.google.truth:truth:1.1.3'
}

View File

@@ -7,5 +7,6 @@
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View File

@@ -32,7 +32,7 @@
</intent-filter>
<meta-data
android:name="android.app.default_searchable"
android:value=".ui.search.SearchActivity" />
android:value="org.koitharu.kotatsu.ui.search.SearchActivity" />
</activity>
<activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
@@ -94,11 +94,12 @@
android:noHistory="true"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -92,7 +92,6 @@ class KotatsuApp : Application() {
.detectFragmentReuse()
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectTargetFragmentUsage()
.detectSetUserVisibleHint()
.build()
}

View File

@@ -8,7 +8,6 @@ object MangaProviderFactory {
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = settings.sourcesOrder
val hidden = settings.hiddenSources
val sorted = list.sortedBy { x ->
val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e
@@ -16,6 +15,7 @@ object MangaProviderFactory {
return if (includeHidden) {
sorted
} else {
val hidden = settings.hiddenSources
sorted.filterNot { x ->
x.name in hidden
}

View File

@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.base.domain
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
@@ -23,18 +25,18 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide
*/
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
try {
val page = pages.medianOrNull() ?: return null
val url = page.source.repository.getPageUrl(page)
val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
getBitmapSize(it)
}
}
} else {
val client = get<OkHttpClient>()
@@ -45,7 +47,9 @@ object MangaUtils : KoinComponent {
.cacheControl(CacheUtils.CONTROL_DISABLED)
.build()
client.newCall(request).await().use {
getBitmapSize(it.body?.byteStream())
withContext(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream())
}
}
}
return size.width * 2 < size.height

View File

@@ -1,28 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koin.core.component.KoinComponent
@Deprecated("")
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
RecyclerView.ViewHolder(binding.root), KoinComponent {
var boundData: T? = null
private set
val context get() = itemView.context!!
fun bind(data: T, extra: E) {
boundData = data
onBind(data, extra)
}
fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E)
}

View File

@@ -0,0 +1,65 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.utils.ext.getThemeDrawable
import kotlin.math.roundToInt
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val divider = context.getThemeDrawable(android.R.attr.listDivider)
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
) {
outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0)
}
// TODO implement for horizontal lists on demand
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
if (parent.layoutManager == null || divider == null) {
return
}
canvas.save()
val left: Int
val right: Int
if (parent.clipToPadding) {
left = parent.paddingLeft
right = parent.width - parent.paddingRight
canvas.clipRect(
left, parent.paddingTop, right,
parent.height - parent.paddingBottom
)
} else {
left = 0
right = parent.width
}
var previous: RecyclerView.ViewHolder? = null
for (child in parent.children) {
val holder = parent.getChildViewHolder(child)
if (previous != null && shouldDrawDivider(previous, holder)) {
parent.getDecoratedBoundsWithMargins(child, bounds)
val top: Int = bounds.top + child.translationY.roundToInt()
val bottom: Int = top + divider.intrinsicHeight
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
previous = holder
}
canvas.restore()
}
protected abstract fun shouldDrawDivider(
above: RecyclerView.ViewHolder,
below: RecyclerView.ViewHolder,
): Boolean
}

View File

@@ -1,96 +0,0 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.inflate
import kotlin.math.max
/**
* https://github.com/paetztm/recycler_view_headers
*/
class SectionItemDecoration(
private val isSticky: Boolean,
private val callback: Callback
) : RecyclerView.ItemDecoration() {
private var headerView: TextView? = null
private var headerOffset: Int = 0
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (headerOffset == 0) {
headerOffset = parent.resources.getDimensionPixelSize(R.dimen.header_height)
}
val pos = parent.getChildAdapterPosition(view)
outRect.set(0, if (callback.isSection(pos)) headerOffset else 0, 0, 0)
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
headerView = it
}
fixLayoutSize(textView, parent)
for (child in parent.children) {
val pos = parent.getChildAdapterPosition(child)
if (callback.isSection(pos)) {
textView.text = callback.getSectionTitle(pos) ?: continue
c.save()
if (isSticky) {
c.translate(
0f,
max(0f, (child.top - textView.height).toFloat())
)
} else {
c.translate(
0f,
(child.top - textView.height).toFloat()
)
}
textView.draw(c)
c.restore()
}
}
}
/**
* Measures the header view to make sure its size is greater than 0 and will be drawn
* https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
*/
private fun fixLayoutSize(view: View, parent: ViewGroup) {
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
val heightSpec =
View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
val childWidth = ViewGroup.getChildMeasureSpec(
widthSpec,
parent.paddingLeft + parent.paddingRight,
view.layoutParams.width
)
val childHeight = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
view.layoutParams.height
)
view.measure(childWidth, childHeight)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
interface Callback {
fun isSection(position: Int): Boolean
fun getSectionTitle(position: Int): CharSequence?
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
@@ -33,8 +34,7 @@ class BackupArchive(file: File) : MutableZipFile(file) {
private const val DIR_BACKUPS = "backups"
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) {
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}

View File

@@ -40,7 +40,8 @@ enum class MangaSource(
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java)
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java),
MANGADEX("MangaDex", null, MangaDexRepository::class.java),
;
@get:Throws(NoBeanDefFoundException::class)

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.parser
import android.net.Uri
import coil.map.Mapper
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.model.MangaSource
class FaviconMapper() : Mapper<Uri, HttpUrl> {
override fun map(data: Uri): HttpUrl {
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl()
}
override fun handles(data: Uri) = data.scheme == "favicon"
}

View File

@@ -34,4 +34,5 @@ val parserModule
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
}

View File

@@ -29,6 +29,8 @@ abstract class RemoteMangaRepository(
override suspend fun getTags(): Set<MangaTag> = emptySet()
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
open fun onCreatePreferences(map: MutableMap<String, Any>) {
map[SourceSettings.KEY_DOMAIN] = defaultDomain
}
@@ -53,8 +55,10 @@ abstract class RemoteMangaRepository(
if (subdomain != null) {
append(subdomain)
append('.')
append(conf.getDomain(defaultDomain).removePrefix("www."))
} else {
append(conf.getDomain(defaultDomain))
}
append(conf.getDomain(defaultDomain))
append(this@withDomain)
}
else -> this

View File

@@ -21,6 +21,10 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.NEWEST
)
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/favicons/favicon.png"
}
override suspend fun getList2(
offset: Int,
query: String?,
@@ -220,7 +224,7 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
}
private suspend fun apiCall(request: String): JSONObject {
return loaderContext.graphQLQuery("https://api.${getDomain()}/", request)
return loaderContext.graphQLQuery("https://api.${getDomain()}/graphql", request)
.getJSONObject("data")
}

View File

@@ -93,14 +93,14 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
description = json.getString("description"),
chapters = chaptersList.mapIndexed { i, it ->
val chid = it.getLong("id")
val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch")
val title = if (it.getString("title") == "null") "" else it.getString("title")
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
val title = it.optString("title", "null").takeUnless { it == "null" }
MangaChapter(
id = generateUid(chid),
source = manga.source,
url = "$baseChapterUrl$chid",
uploadDate = it.getLong("date") * 1000,
name = if (title.isEmpty()) volChap else "$volChap: $title",
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
number = totalChapters - i,
scanlator = null,
branch = null,

View File

@@ -110,11 +110,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
"data-full"
),
largeCoverUrl = coverImg?.attr("data-full"),
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null

View File

@@ -0,0 +1,215 @@
package org.koitharu.kotatsu.core.parser.site
import android.os.Build
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.json.JSONObject
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
private const val PAGE_SIZE = 20
private const val CONTENT_RATING =
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
private const val LOCALE_FALLBACK = "en"
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.MANGADEX
override val defaultDomain = "mangadex.org"
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.POPULARITY,
)
override suspend fun getList2(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?,
): List<Manga> {
val domain = getDomain()
val url = buildString {
append("https://api.")
append(domain)
append("/manga?limit=")
append(PAGE_SIZE)
append("&offset=")
append(offset)
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
tags?.forEach { tag ->
append("includedTags[]=")
append(tag.key)
append('&')
}
if (!query.isNullOrEmpty()) {
append("title=")
append(query.urlEncoded())
append('&')
}
append(CONTENT_RATING)
append("&order")
append(when (sortOrder) {
null,
SortOrder.UPDATED,
-> "[latestUploadedChapter]=desc"
SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.NEWEST -> "[createdAt]=desc"
SortOrder.POPULARITY -> "[followedCount]=desc"
else -> "[followedCount]=desc"
})
}
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
return json.map { jo ->
val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes")
val relations = jo.getJSONArray("relationships").associateByKey("type")
val cover = relations["cover_art"]
?.getJSONObject("attributes")
?.getString("fileName")
?.let {
"https://uploads.$domain/covers/$id/$it"
}
Manga(
id = generateUid(id),
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
"Title should not be null"
},
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
url = id,
publicUrl = "https://$domain/title/$id",
rating = Manga.NO_RATING,
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
coverUrl = cover?.plus(".256.jpg").orEmpty(),
largeCoverUrl = cover,
description = attrs.optJSONObject("description")?.selectByLocale(),
tags = attrs.getJSONArray("tags").mapToSet { tag ->
MangaTag(
title = tag.getJSONObject("attributes")
.getJSONObject("name")
.firstStringValue(),
key = tag.getString("id"),
source = source,
)
},
state = when (jo.getStringOrNull("status")) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
},
author = (relations["author"] ?: relations["artist"])
?.getJSONObject("attributes")
?.getStringOrNull("name"),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
val domain = getDomain()
val attrsDeferred = async {
loaderContext.httpGet(
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
).parseJson().getJSONObject("data").getJSONObject("attributes")
}
val feedDeferred = async {
val url = buildString {
append("https://api.")
append(domain)
append("/manga/")
append(manga.url)
append("/feed")
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
append(CONTENT_RATING)
}
loaderContext.httpGet(url).parseJson().getJSONArray("data")
}
val mangaAttrs = attrsDeferred.await()
val feed = feedDeferred.await()
//2022-01-02T00:27:11+00:00
val dateFormat = SimpleDateFormat(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
"yyyy-MM-dd'T'HH:mm:ssX"
} else {
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
},
Locale.ROOT
)
manga.copy(
description = mangaAttrs.getJSONObject("description").selectByLocale()
?: manga.description,
chapters = feed.mapNotNull { jo ->
val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes")
if (!attrs.isNull("externalUrl")) {
return@mapNotNull null
}
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
val relations = jo.getJSONArray("relationships").associateByKey("type")
val number = attrs.optInt("chapter", 0)
MangaChapter(
id = generateUid(id),
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
?: "Chapter #$number",
number = number,
url = id,
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
branch = locale.displayName.toTitleCase(locale),
source = source,
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = getDomain()
val chapter = loaderContext.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
.parseJson()
.getJSONObject("chapter")
val pages = chapter.getJSONArray("data")
val prefix = "https://uploads.$domain/data/${chapter.getString("hash")}/"
val referer = "https://$domain/"
return List(pages.length()) { i ->
val url = prefix + pages.getString(i)
MangaPage(
id = generateUid(url),
url = url,
referer = referer,
preview = null, // TODO prefix + dataSaver.getString(i),
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
.getJSONArray("data")
return tags.mapToSet { jo ->
MangaTag(
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
key = jo.getString("id"),
source = source,
)
}
}
private fun JSONObject.firstStringValue() = values().next() as String
private fun JSONObject.selectByLocale(): String? {
val preferredLocales = LocaleListCompat.getAdjustedDefault()
repeat(preferredLocales.size()) { i ->
val locale = preferredLocales.get(i)
getStringOrNull(locale.language)?.let { return it }
getStringOrNull(locale.toLanguageTag())?.let { return it }
}
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser.site
import android.util.Base64
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
@@ -75,6 +76,10 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null } ?: parseFailed("Oops, tr not found")
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
val s = Base64.encodeToString(defaultDomain.toByteArray(), Base64.NO_PADDING)
return manga.copy(
description = info.selectFirst(".description")?.html(),
largeCoverUrl = info.select("img").first()?.let { img ->
@@ -100,7 +105,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
id = generateUid(href),
name = a.select("label").text(),
number = i + 1,
url = href,
url = "$href?tr=$tr&s=$s",
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
@@ -120,7 +125,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
id = generateUid(url),
url = url,
preview = null,
referer = fullUrl,
referer = url,
source = MangaSource.MANGAOWL,
)
}

View File

@@ -128,7 +128,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
scanlator = null,
branch = null,
)
}
} ?: bypassLicensedChapters(manga)
)
}
@@ -191,6 +191,32 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
map[SourceSettings.KEY_USE_SSL] = true
}
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
val doc = loaderContext.httpGet(manga.url.withDomain("m")).parseHtml()
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.relUrl("href")
val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty {
a.ownText()
}
MangaChapter(
id = generateUid(href),
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.time")?.text()
),
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
scanlator = null,
branch = null,
)
}
}
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
private companion object {

View File

@@ -55,7 +55,7 @@ class MangareadRepository(
id = generateUid(href),
url = href,
publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
coverUrl = div.selectFirst("img")?.absUrl("data-src").orEmpty(),
title = summary?.selectFirst("h3")?.text().orEmpty(),
rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f,
@@ -107,16 +107,6 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found")
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
?.attr("data-post")?.toLongOrNull()
?: throw ParseException("Cannot obtain manga id")
val doc2 = loaderContext.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php",
mapOf(
"action" to "manga_get_chapters",
"manga" to mangaId.toString()
)
).parseHtml()
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
@@ -132,7 +122,7 @@ class MangareadRepository(
?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() },
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
chapters = root2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a?.relUrl("href").orEmpty().ifEmpty {
parseFailed("Link is missing")
@@ -144,7 +134,7 @@ class MangareadRepository(
url = href,
uploadDate = parseChapterDate(
dateFormat,
doc2.selectFirst("span.chapter-release-date i")?.text()
li.selectFirst("span.chapter-release-date i")?.text()
),
source = MangaSource.MANGAREAD,
scanlator = null,
@@ -161,8 +151,10 @@ class MangareadRepository(
?.selectFirst("div.reading-content")
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
val url = img.relUrl("data-src").ifEmpty {
img.relUrl("src")
}
MangaPage(
id = generateUid(url),
url = url,

View File

@@ -41,7 +41,7 @@ abstract class NineMangaRepository(
append("&page=")
}
!tags.isNullOrEmpty() -> {
append("/search/&category_id=")
append("/search/?category_id=")
for (tag in tags) {
append(tag.key)
append(',')

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
@@ -32,7 +33,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
sortOrder: SortOrder?,
): List<Manga> {
copyCookies()
val domain = getDomain()
@@ -97,9 +98,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}
val branchId = content.getJSONArray("branches").optJSONObject(0)
?.getLong("id") ?: throw ParseException("No branches found")
val chapters = loaderContext.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
).parseJson().getJSONArray("content")
val chapters = grabChapters(domain, branchId)
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy(
description = content.getString("description"),
@@ -118,24 +117,24 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
chapters = chapters.mapIndexed { i, jo ->
val id = jo.getLong("id")
val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.getJSONArray("publishers")
val publishers = jo.optJSONArray("publishers")
MangaChapter(
id = generateUid(id),
url = "/api/titles/chapters/$id/",
number = chapters.length() - i,
number = chapters.size - i,
name = buildString {
append("Том ")
append(jo.getString("tome"))
append(jo.optString("tome", "0"))
append(". ")
append("Глава ")
append(jo.getString("chapter"))
append(jo.optString("chapter", "0"))
if (name.isNotEmpty()) {
append(" - ")
append(name)
}
},
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
scanlator = publishers?.optJSONObject(0)?.getStringOrNull("name"),
source = MangaSource.REMANGA,
branch = null,
)
@@ -146,16 +145,28 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val referer = "https://${getDomain()}/"
val content = loaderContext.httpGet(chapter.url.withDomain(subdomain = "api")).parseJson()
.getJSONObject("content").getJSONArray("pages")
val pages = ArrayList<MangaPage>(content.length())
for (i in 0 until content.length()) {
when (val item = content.get(i)) {
is JSONObject -> pages += parsePage(item, referer)
is JSONArray -> item.mapTo(pages) { parsePage(it, referer) }
.getJSONObject("content")
val pages = content.optJSONArray("pages")
if (pages == null) {
val pubDate = content.getStringOrNull("pub_date")?.let {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).tryParse(it)
}
if (pubDate != null && pubDate > System.currentTimeMillis()) {
val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate))
parseFailed("Глава станет доступной $at")
} else {
parseFailed("Глава недоступна")
}
}
val result = ArrayList<MangaPage>(pages.length())
for (i in 0 until pages.length()) {
when (val item = pages.get(i)) {
is JSONObject -> result += parsePage(item, referer)
is JSONArray -> item.mapTo(result) { parsePage(it, referer) }
else -> throw ParseException("Unknown json item $item")
}
}
return pages
return result
}
override suspend fun getTags(): Set<MangaTag> {
@@ -198,6 +209,26 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
source = source,
)
private suspend fun grabChapters(domain: String, branchId: Long): List<JSONObject> {
val result = ArrayList<JSONObject>(100)
var page = 1
while (true) {
val content = loaderContext.httpGet(
"https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100"
).parseJson().getJSONArray("content")
val len = content.length()
if (len == 0) {
break
}
result.ensureCapacity(result.size + len)
for (i in 0 until len) {
result.add(content.getJSONObject(i))
}
page++
}
return result
}
private companion object {
const val PAGE_SIZE = 30

View File

@@ -8,7 +8,7 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@@ -109,6 +109,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
@@ -143,7 +145,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
fun observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
sendBlocking(key)
trySendBlocking(key)
}
prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
@@ -195,6 +197,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -5,6 +5,7 @@ import coil.ImageLoader
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.FaviconMapper
import org.koitharu.kotatsu.local.data.CbzFetcher
val uiModule
@@ -15,6 +16,7 @@ val uiModule
.componentRegistry(
ComponentRegistry.Builder()
.add(CbzFetcher())
.add(FaviconMapper())
.build()
).build()
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui
import android.app.ActivityOptions
import android.os.Bundle
import android.text.Spanned
import android.view.LayoutInflater
@@ -12,6 +13,7 @@ import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -27,6 +29,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.SearchActivity
@@ -50,6 +53,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
binding.buttonFavorite.setOnClickListener(this)
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.coverCard.setOnClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
@@ -58,14 +62,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
private fun onMangaUpdated(manga: Manga) {
with(binding) {
// Main
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
.referer(manga.publicUrl)
.fallback(R.drawable.ic_placeholder)
.placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
@@ -189,6 +187,17 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
)
)
}
R.id.cover_card -> {
val options = ActivityOptions.makeSceneTransitionAnimation(
requireActivity(),
binding.imageViewCover,
binding.imageViewCover.transitionName,
)
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
options.toBundle()
)
}
}
}
@@ -239,4 +248,22 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
}
)
}
private fun loadCover(manga: Manga) {
val currentCover = binding.imageViewCover.drawable
val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
if (currentCover != null) {
request.data(manga.largeCoverUrl ?: return)
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
.fallback(currentCover)
} else {
request.crossfade(true)
.data(manga.coverUrl)
.fallback(R.drawable.ic_placeholder)
}
request.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
}
}

View File

@@ -24,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.io.IOException
import java.util.*
class DetailsViewModel(
intent: MangaIntent,
@@ -127,9 +129,7 @@ class DetailsViewModel(
selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch
} else {
manga.chapters
?.groupBy { it.branch }
?.maxByOrNull { it.value.size }?.key
predictBranch(manga.chapters)
}
mangaData.value = manga
if (manga.source == MangaSource.LOCAL) {
@@ -240,4 +240,21 @@ class DetailsViewModel(
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
val locale = Locale.getDefault()
var language = locale.displayLanguage.toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.displayName.toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
return groups.maxByOrNull { it.value.size }?.key
}
}

View File

@@ -145,7 +145,7 @@ class DownloadManager(
while (true) {
try {
val response = call.clone().await()
withContext(Dispatchers.IO) {
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}

View File

@@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -79,6 +78,11 @@ class DownloadService : BaseService() {
return binder ?: DownloadBinder(this).also { binder = it }
}
override fun onUnbind(intent: Intent?): Boolean {
binder = null
return super.onUnbind(intent)
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null

View File

@@ -0,0 +1,92 @@
package org.koitharu.kotatsu.image.ui
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.target.PoolableViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.indicator
class ImageActivity : BaseActivity<ActivityImageBinding>() {
private val coil: ImageLoader by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityImageBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
loadImage(intent.data)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top,
)
}
private fun loadImage(url: Uri?) {
ImageRequest.Builder(this)
.data(url)
.memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this)
.target(SsivTarget(binding.ssiv))
.indicator(binding.progressBar)
.enqueueWith(coil)
}
private class SsivTarget(
override val view: SubsamplingScaleImageView,
) : PoolableViewTarget<SubsamplingScaleImageView> {
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
override fun onError(error: Drawable?) = setDrawable(error)
override fun onSuccess(result: Drawable) = setDrawable(result)
override fun onClear() = setDrawable(null)
override fun equals(other: Any?): Boolean {
return (this === other) || (other is SsivTarget && view == other.view)
}
override fun hashCode() = view.hashCode()
override fun toString() = "SsivTarget(view=$view)"
private fun setDrawable(drawable: Drawable?) {
if (drawable != null) {
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
} else {
view.recycle()
}
}
}
companion object {
fun newIntent(context: Context, url: String): Intent {
return Intent(context, ImageActivity::class.java)
.setData(Uri.parse(url))
}
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.list.domain
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
class AvailableFilters(
val sortOrders: Set<SortOrder>,
val tags: Set<MangaTag>,
) {
val size: Int
get() = sortOrders.size + tags.size
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AvailableFilters
if (sortOrders != other.sortOrders) return false
if (tags != other.tags) return false
return true
}
override fun hashCode(): Int {
var result = sortOrders.hashCode()
result = 31 * result + tags.hashCode()
return result
}
fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty()
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.list.ui
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
data class MangaFilterConfig(
val sortOrders: List<SortOrder>,
val tags: List<MangaTag>,
val currentFilter: MangaFilter?
)

View File

@@ -22,19 +22,17 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2
import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
@@ -42,10 +40,11 @@ import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
PaginationScrollListener.Callback, OnListItemClickListener<Manga>,
SwipeRefreshLayout.OnRefreshListener {
private var listAdapter: MangaListAdapter? = null
private var filterAdapter: FilterAdapter2? = null
private var paginationListener: PaginationScrollListener? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
@@ -78,6 +77,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
onRetryClick = ::resolveException,
onTagRemoveClick = viewModel::onRemoveFilterTag
)
filterAdapter = FilterAdapter2(viewModel)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
@@ -94,8 +94,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
with(binding.recyclerViewFilter) {
setHasFixedSize(true)
addItemDecoration(ItemTypeDividerDecoration(view.context))
addItemDecoration(SectionItemDecoration(false, this@MangaListFragment))
adapter = filterAdapter
}
(parentFragment as? RecycledViewPoolHolder)?.let {
@@ -113,6 +112,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onDestroyView() {
drawer = null
listAdapter = null
filterAdapter = null
paginationListener = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
@@ -203,28 +203,21 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
}
protected fun onInitFilter(config: MangaFilterConfig) {
binding.recyclerViewFilter.adapter = FilterAdapter(
sortOrders = config.sortOrders,
tags = config.tags,
state = config.currentFilter,
listener = this
)
protected fun onInitFilter(filter: List<FilterItem>) {
filterAdapter?.items = filter
drawer?.setDrawerLockMode(
if (config.sortOrders.isEmpty() && config.tags.isEmpty()) {
if (filter.isEmpty()) {
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
} else {
DrawerLayout.LOCK_MODE_UNLOCKED
}
) ?: binding.dividerFilter?.let {
it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty()
it.isGone = filter.isEmpty()
binding.recyclerViewFilter.isVisible = it.isVisible
}
activity?.invalidateOptionsMenu()
}
override fun onFilterChanged(filter: MangaFilter) = Unit
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerViewFilter.updatePadding(
@@ -284,20 +277,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
}
}
final override fun isSection(position: Int): Boolean {
return position == 0 || binding.recyclerViewFilter.adapter?.run {
getItemViewType(position) != getItemViewType(position - 1)
} ?: false
}
final override fun getSectionTitle(position: Int): CharSequence? {
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
else -> null
}
}
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false

View File

@@ -1,23 +1,32 @@
package org.koitharu.kotatsu.list.ui
import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.domain.AvailableFilters
import org.koitharu.kotatsu.list.ui.filter.FilterItem
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel(
private val settings: AppSettings
) : BaseViewModel() {
private val settings: AppSettings,
) : BaseViewModel(), OnFilterChangedListener {
abstract val content: LiveData<List<ListModel>>
val filter = MutableLiveData<MangaFilterConfig>()
val filter = MutableLiveData<List<FilterItem>>()
val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe()
.filter { it == AppSettings.KEY_GRID_SIZE }
@@ -37,7 +46,62 @@ abstract class MangaListViewModel(
}
}
open fun onRemoveFilterTag(tag: MangaTag) = Unit
protected var currentFilter: MangaFilter = MangaFilter(null, emptySet())
private set(value) {
field = value
onFilterChanged()
}
protected var availableFilters: AvailableFilters? = null
private var filterJob: Job? = null
final override fun onSortItemClick(item: FilterItem.Sort) {
currentFilter = currentFilter.copy(sortOrder = item.order)
}
final override fun onTagItemClick(item: FilterItem.Tag) {
val tags = if (item.isChecked) {
currentFilter.tags - item.tag
} else {
currentFilter.tags + item.tag
}
currentFilter = currentFilter.copy(tags = tags)
}
fun onRemoveFilterTag(tag: MangaTag) {
val tags = currentFilter.tags
if (tag !in tags) {
return
}
currentFilter = currentFilter.copy(tags = tags - tag)
}
@CallSuper
open fun onFilterChanged() {
val previousJob = filterJob
filterJob = launchJob(Dispatchers.Default) {
previousJob?.cancelAndJoin()
filter.postValue(
availableFilters?.run {
val list = ArrayList<FilterItem>(size + 2)
if (sortOrders.isNotEmpty()) {
val selectedSort = currentFilter.sortOrder ?: sortOrders.first()
list += FilterItem.Header(R.string.sort_order)
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
FilterItem.Sort(it, isSelected = it == selectedSort)
}
}
if (tags.isNotEmpty()) {
list += FilterItem.Header(R.string.genres)
tags.sortedBy { it.title }.mapTo(list) {
FilterItem.Tag(it, isChecked = it in currentFilter.tags)
}
}
ensureActive()
list
}.orEmpty()
)
}
}
abstract fun onRefresh()

View File

@@ -1,94 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
class FilterAdapter(
private val sortOrders: List<SortOrder> = emptyList(),
private val tags: List<MangaTag> = emptyList(),
state: MangaFilter?,
private val listener: OnFilterChangedListener
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
itemView.setOnClickListener {
setCheckedSort(requireData())
}
}
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
itemView.setOnClickListener {
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
}
}
else -> throw IllegalArgumentException("Unknown viewType $viewType")
}
override fun getItemCount() = sortOrders.size + tags.size
override fun onBindViewHolder(holder: BaseViewHolder<*, Boolean, *>, position: Int) {
when (holder) {
is FilterSortHolder -> {
val item = sortOrders[position]
holder.bind(item, item == currentState.sortOrder)
}
is FilterTagHolder -> {
val item = tags[position - sortOrders.size]
holder.bind(item, item in currentState.tags)
}
}
}
override fun getItemViewType(position: Int) = when (position) {
in sortOrders.indices -> VIEW_TYPE_SORT
else -> VIEW_TYPE_TAG
}
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
currentState = if (tag in currentState.tags) {
if (!isChecked) {
currentState.copy(tags = currentState.tags - tag)
} else {
return
}
} else {
if (isChecked) {
currentState.copy(tags = currentState.tags + tag)
} else {
return
}
}
val index = tags.indexOf(tag)
if (index in tags.indices) {
notifyItemChanged(sortOrders.size + index)
}
listener.onFilterChanged(currentState)
}
fun setCheckedSort(sort: SortOrder) {
if (sort != currentState.sortOrder) {
val oldItemPos = sortOrders.indexOf(currentState.sortOrder)
val newItemPos = sortOrders.indexOf(sort)
currentState = currentState.copy(sortOrder = sort)
if (oldItemPos in sortOrders.indices) {
notifyItemChanged(oldItemPos)
}
if (newItemPos in sortOrders.indices) {
notifyItemChanged(newItemPos)
}
listener.onFilterChanged(currentState)
}
}
companion object {
const val VIEW_TYPE_SORT = 0
const val VIEW_TYPE_TAG = 1
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.list.ui.filter
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter2(
listener: OnFilterChangedListener,
) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(),
filterSortDelegate(listener),
filterTagDelegate(listener),
filterHeaderDelegate(),
)

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.list.ui.filter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
fun filterSortDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Sort, FilterItem, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) }
) {
itemView.setOnClickListener {
listener.onSortItemClick(item)
}
bind {
binding.root.setText(item.order.titleRes)
binding.root.isChecked = item.isSelected
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, FilterItem, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
}
bind {
binding.root.text = item.tag.title
binding.root.isChecked = item.isChecked
}
}
fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, FilterItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
) {
bind {
binding.root.setText(item.titleResId)
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.recyclerview.widget.DiffUtil
class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when {
oldItem.javaClass != newItem.javaClass -> false
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.tag == newItem.tag
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.order == newItem.order
}
else -> false
}
}
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
return when {
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked == newItem.isChecked
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected == newItem.isSelected
}
else -> false
}
}
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
val isCheckedChanged = when {
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
oldItem.isChecked != newItem.isChecked
}
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
oldItem.isSelected != newItem.isSelected
}
else -> false
}
return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem)
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
sealed interface FilterItem {
class Header(
@StringRes val titleResId: Int,
) : FilterItem
class Sort(
val order: SortOrder,
val isSelected: Boolean,
) : FilterItem
class Tag(
val tag: MangaTag,
val isChecked: Boolean,
) : FilterItem
}

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
class FilterSortHolder(parent: ViewGroup) :
BaseViewHolder<SortOrder, Boolean, ItemCheckableSingleBinding>(
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
override fun onBind(data: SortOrder, extra: Boolean) {
binding.root.setText(data.titleRes)
binding.root.isChecked = extra
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.list.ui.filter
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
class FilterTagHolder(parent: ViewGroup) :
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
val isChecked: Boolean
get() = binding.root.isChecked
override fun onBind(data: MangaTag, extra: Boolean) {
binding.root.text = data.title
binding.root.isChecked = extra
}
}

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.list.ui.filter
import org.koitharu.kotatsu.core.model.MangaFilter
interface OnFilterChangedListener {
fun interface OnFilterChangedListener {
fun onSortItemClick(item: FilterItem.Sort)
fun onFilterChanged(filter: MangaFilter)
fun onTagItemClick(item: FilterItem.Tag)
}

View File

@@ -9,23 +9,24 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.source
import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun fetch(
pool: BitmapPool,
data: Uri,
size: Size,
options: Options,
): FetchResult {
): FetchResult = runInterruptible(Dispatchers.IO) {
val zip = ZipFile(data.schemeSpecificPart)
val entry = zip.getEntry(data.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
return SourceResult(
SourceResult(
source = ExtraCloseableBufferedSource(
zip.getInputStream(entry).source().buffer(),
zip,

View File

@@ -16,7 +16,7 @@ class MangaZip(val file: File) {
private var index = MangaIndex(null)
suspend fun prepare(manga: Manga) {
writableCbz.prepare()
writableCbz.prepare(overwrite = true)
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
@@ -31,7 +31,7 @@ class MangaZip(val file: File) {
return writableCbz.flush()
}
fun addCover(file: File, ext: String) {
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
@@ -39,11 +39,11 @@ class MangaZip(val file: File) {
append(ext)
}
}
writableCbz[name] = file
writableCbz.put(name, file)
index.setCoverEntry(name)
}
fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
@@ -51,7 +51,7 @@ class MangaZip(val file: File) {
append(ext)
}
}
writableCbz[name] = file
writableCbz.put(name, file)
index.addChapter(chapter)
}

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.utils.ext.deleteAwait
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -14,10 +14,13 @@ class WritableCbzFile(private val file: File) {
private val dir = File(file.parentFile, file.nameWithoutExtension)
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun prepare() = withContext(Dispatchers.IO) {
check(dir.list().isNullOrEmpty()) {
"Dir ${dir.name} is not empty"
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
if (!dir.list().isNullOrEmpty()) {
if (overwrite) {
dir.deleteRecursively()
} else {
throw IllegalStateException("Dir ${dir.name} is not empty")
}
}
if (!dir.exists()) {
dir.mkdir()
@@ -27,11 +30,13 @@ class WritableCbzFile(private val file: File) {
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
while (entry != null && currentCoroutineContext().isActive) {
val target = File(dir.path + File.separator + entry.name)
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
runInterruptible {
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
}
zip.closeEntry()
entry = zip.nextEntry
@@ -44,52 +49,50 @@ class WritableCbzFile(private val file: File) {
}
@CheckResult
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun flush() = withContext(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.delete()
tempFile.deleteAwait()
}
try {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
runInterruptible {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
zip.flush()
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.delete()
tempFile.deleteAwait()
}
}
}
operator fun get(name: String) = File(dir, name)
operator fun set(name: String, file: File) {
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
file.copyTo(this[name], overwrite = true)
}
companion object {
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -42,38 +43,39 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
getFromFile(Uri.parse(manga.url).toFile())
} else manga
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
""
) == parent
return runInterruptible(Dispatchers.IO){
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
""
) == parent
}
}
entries
.toList()
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
referer = chapter.url,
source = MangaSource.LOCAL,
)
}
}
return entries
.toList()
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
referer = chapter.url,
source = MangaSource.LOCAL,
)
}
}
suspend fun delete(manga: Manga): Boolean {
@@ -137,20 +139,18 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
val file = runCatching {
Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null
return withContext(Dispatchers.IO) {
@Suppress("BlockingMethodInNonBlockingContext")
return runInterruptible(Dispatchers.IO) {
ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo()
}
}
}
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) {
val files = getAllFiles()
for (file in files) {
@Suppress("BlockingMethodInNonBlockingContext")
val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
entry?.let(zip::readText)?.let(::MangaIndex)
@@ -158,7 +158,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
val info = index.getMangaInfo() ?: continue
if (info.id == remoteManga.id) {
val fileUri = file.toUri().toString()
return@withContext info.copy(
return@runInterruptible info.copy(
source = MangaSource.LOCAL,
url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
@@ -81,10 +82,11 @@ class LocalListViewModel(
}
val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable")
@Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)
runInterruptible {
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)
}
}
} ?: throw IOException("Cannot open input stream: $uri")
}

View File

@@ -238,8 +238,8 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
) {
false
} else {
val targets = binding.root.hitTest(rawX, rawY)
targets.none { it.hasOnClickListeners() }
val touchables = window.peekDecorView()?.touchables
touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true
}
}
@@ -281,7 +281,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
private fun onPageSaved(uri: Uri?) {
if (uri != null) {
Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(binding.appbarBottom)
.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)

View File

@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
class ReversedPageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) {
with(page) {
val pageWidth = width
when {
position > 1 -> alpha = 0f
position >= 0 -> {
alpha = 1f
translationX = 0f
translationZ = 0f
scaleX = 1 + FACTOR * position
scaleY = 1f
}
position >= -1 -> {
alpha = 1f
translationX = pageWidth * -position
translationZ = -1f
scaleX = 1f
scaleY = 1f
}
else -> alpha = 0f
override fun transformPage(page: View, position: Float) = with(page) {
translationX = -position * width
pivotX = width.toFloat()
pivotY = height / 2f
cameraDistance = 20000f
when {
position < -1f || position > 1f -> {
alpha = 0f
rotationY = 0f
translationZ = -1f
}
position <= 0f -> {
alpha = 1f
rotationY = 0f
translationZ = 0f
}
position > 0f -> {
alpha = 1f
rotationY = 120 * position
translationZ = 2f
}
}
}
private companion object {
const val FACTOR = 0.1f
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF
import android.view.Gravity
import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
@@ -16,6 +18,11 @@ class ReversedPageHolder(
exceptionResolver: ExceptionResolver
) : PageHolder(binding, loader, settings, exceptionResolver) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = Gravity.START or Gravity.BOTTOM
}
override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) {
maxScale = 2f * maxOf(

View File

@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
class PageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) {
page.apply {
val pageWidth = width
when {
position < -1 -> alpha = 0f
position <= 0 -> { // [-1,0]
alpha = 1f
translationX = 0f
translationZ = 0f
scaleX = 1 + FACTOR * position
scaleY = 1f
}
position <= 1 -> { // (0,1]
alpha = 1f
translationX = pageWidth * -position
translationZ = -1f
scaleX = 1f
scaleY = 1f
}
else -> alpha = 0f
override fun transformPage(page: View, position: Float) = with(page) {
translationX = -position * width
pivotX = 0f
pivotY = height / 2f
cameraDistance = 20000f
when {
position < -1f || position > 1f -> {
alpha = 0f
rotationY = 0f
translationZ = -1f
}
position > 0f -> {
alpha = 1f
rotationY = 0f
translationZ = 0f
}
position <= 0f -> {
alpha = 1f
rotationY = 120 * position
translationZ = 2f
}
}
}
private companion object {
const val FACTOR = 0.1f
}
}

View File

@@ -20,17 +20,20 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
open class PageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: AppSettings, exceptionResolver: ExceptionResolver
settings: AppSettings,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener {
init {
binding.ssiv.setOnImageEventListener(delegate)
binding.buttonRetry.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
binding.textViewNumber.text = (data.index + 1).toString()
}
override fun onRecycled() {

View File

@@ -1,16 +1,19 @@
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 com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.utils.ext.toIntUp
class WebtoonImageView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null) :
SubsamplingScaleImageView(context, attr) {
class WebtoonImageView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF()
private val displayHeight = resources.displayMetrics.heightPixels
private val displayHeight = (context as Activity).window.decorView.height
private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN
@@ -55,6 +58,30 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu
return desiredHeight
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val parentWidth = MeasureSpec.getSize(widthMeasureSpec)
val parentHeight = MeasureSpec.getSize(heightMeasureSpec)
val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY
val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY
var width = parentWidth
var height = parentHeight
if (sWidth > 0 && sHeight > 0) {
if (resizeWidth && resizeHeight) {
width = sWidth
height = sHeight
} else if (resizeHeight) {
height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt()
} else if (resizeWidth) {
width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt()
}
}
width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight)
setMeasuredDimension(width, height)
}
private fun scrollToInternal(pos: Int) {
scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)

View File

@@ -34,7 +34,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
consumed[0] = 0
consumed[1] = consumedY
}
return consumedY != 0
return consumedY != 0 || dy == 0
}
private fun consumeVerticalScroll(dy: Int): Int {

View File

@@ -6,7 +6,6 @@ import android.view.MenuItem
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
@@ -29,10 +28,6 @@ class RemoteListFragment : MangaListFragment() {
return source.title
}
override fun onFilterChanged(filter: MangaFilter) {
viewModel.applyFilter(filter)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_list_remote, menu)

View File

@@ -9,12 +9,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaFilterConfig
import org.koitharu.kotatsu.list.domain.AvailableFilters
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -27,7 +25,6 @@ class RemoteListViewModel(
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)
private var appliedFilter: MangaFilter? = null
private var loadingJob: Job? = null
private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0)
@@ -68,16 +65,6 @@ class RemoteListViewModel(
loadList(append = !mangaList.value.isNullOrEmpty())
}
override fun onRemoveFilterTag(tag: MangaTag) {
val filter = appliedFilter ?: return
if (tag !in filter.tags) {
return
}
applyFilter(
filter.copy(tags = filter.tags - tag)
)
}
fun loadNextPage() {
if (hasNextPage.value && listError.value == null) {
loadList(append = true)
@@ -93,8 +80,8 @@ class RemoteListViewModel(
listError.value = null
val list = repository.getList2(
offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = appliedFilter?.sortOrder,
tags = appliedFilter?.tags,
sortOrder = currentFilter.sortOrder,
tags = currentFilter.tags,
)
if (!append) {
mangaList.value = list
@@ -111,26 +98,29 @@ class RemoteListViewModel(
}
}
fun applyFilter(newFilter: MangaFilter) {
appliedFilter = newFilter
override fun onFilterChanged() {
super.onFilterChanged()
mangaList.value = null
hasNextPage.value = false
loadList(false)
filter.value?.run {
filter.value = copy(currentFilter = newFilter)
}
}
private fun createFilterModel() = appliedFilter?.run {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
private fun createFilterModel(): CurrentFilterModel? {
val tags = currentFilter.tags
return if (tags.isEmpty()) {
null
} else {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
}
}
private fun loadFilter() {
launchJob(Dispatchers.Default) {
try {
val sorts = repository.sortOrders.sortedBy { it.ordinal }
val tags = repository.getTags().sortedBy { it.title }
filter.postValue(MangaFilterConfig(sorts, tags, appliedFilter))
val sorts = repository.sortOrders
val tags = repository.getTags()
availableFilters = AvailableFilters(sorts, tags)
onFilterChanged()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()

View File

@@ -30,7 +30,6 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(),
}
}
@Suppress("DEPRECATION")
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
val settingsModule
get() = module {
@@ -25,4 +26,5 @@ val settingsModule
}
viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(get()) }
}

View File

@@ -5,6 +5,7 @@ import android.net.Uri
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupArchive
@@ -32,8 +33,7 @@ class RestoreViewModel(
}
val contentResolver = context.contentResolver
@Suppress("BlockingMethodInNonBlockingContext")
val backup = withContext(Dispatchers.IO) {
val backup = runInterruptible(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri)
?: throw FileNotFoundException()).use { input ->

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.util.*
class OnboardViewModel(
@@ -27,9 +28,9 @@ class OnboardViewModel(
init {
if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale })
selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale })
} else {
val deviceLocales = LocaleListCompat.getDefault().map { x ->
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language
}
selectedLocales.retainAll(deviceLocales)
@@ -64,7 +65,7 @@ class OnboardViewModel(
} else null
SourceLocale(
key = key,
title = locale?.getDisplayLanguage(locale)?.capitalize(locale),
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isChecked = key in selectedLocales
)
}.sortedWith(SourceLocaleComparator())

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
class SourceViewHolder(parent: ViewGroup) :
BaseViewHolder<MangaSource, Boolean, ItemSourceConfigBinding>(
ItemSourceConfigBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
override fun onBind(data: MangaSource, extra: Boolean) {
binding.textViewTitle.text = data.title
binding.switchToggle.isChecked = extra
}
}

View File

@@ -1,69 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.mapToSet
class SourcesAdapter(
private val settings: AppSettings,
private val onItemClickListener: OnListItemClickListener<MangaSource>,
) : RecyclerView.Adapter<SourceViewHolder>() {
private val dataSet =
MangaProviderFactory.getSources(settings, includeHidden = true).toMutableList()
private val hiddenItems = settings.hiddenSources.mapNotNull {
runCatching {
MangaSource.valueOf(it)
}.getOrNull()
}.toMutableSet()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = SourceViewHolder(parent).also(::onViewHolderCreated)
override fun getItemCount() = dataSet.size
override fun onBindViewHolder(holder: SourceViewHolder, position: Int) {
val item = dataSet[position]
holder.bind(item, !hiddenItems.contains(item))
}
@SuppressLint("ClickableViewAccessibility")
private fun onViewHolderCreated(holder: SourceViewHolder) {
holder.binding.switchToggle.setOnCheckedChangeListener { _, it ->
if (it) {
hiddenItems.remove(holder.requireData())
} else {
hiddenItems.add(holder.requireData())
}
settings.hiddenSources = hiddenItems.mapToSet { x -> x.name }
}
holder.binding.imageViewConfig.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), v)
}
holder.binding.imageViewHandle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onItemClickListener.onItemLongClick(
holder.requireData(),
holder.itemView
)
} else {
false
}
}
}
fun moveItem(oldPos: Int, newPos: Int) {
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
settings.sourcesOrder = dataSet.map { it.ordinal }
}
}

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
class SourcesReorderCallback :
ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val adapter = recyclerView.adapter as? SourcesAdapter ?: return false
val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}

View File

@@ -2,28 +2,30 @@ package org.koitharu.kotatsu.settings.sources
import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigItemDecoration
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
OnListItemClickListener<MangaSource> {
SourceConfigListener, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
private lateinit var reorderHelper: ItemTouchHelper
private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModel<SourcesSettingsViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
reorderHelper = ItemTouchHelper(SourcesReorderCallback())
setHasOptionsMenu(true)
}
@@ -39,32 +41,34 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this, get(), viewLifecycleOwner)
with(binding.recyclerView) {
addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
adapter = SourcesAdapter(get(), this@SourcesSettingsFragment)
reorderHelper.attachToRecyclerView(this)
setHasFixedSize(true)
addItemDecoration(SourceConfigItemDecoration(view.context))
adapter = sourcesAdapter
reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.items.observe(viewLifecycleOwner) {
sourcesAdapter.items = it
}
}
override fun onDestroyView() {
reorderHelper.attachToRecyclerView(null)
reorderHelper = null
super.onDestroyView()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
// TODO handle changes in dialog
// inflater.inflate(R.menu.opt_sources, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when(item.itemId) {
R.id.action_languages -> {
OnboardDialogFragment.show(parentFragmentManager)
true
}
else -> super.onOptionsItemSelected(item)
}
inflater.inflate(R.menu.opt_sources, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -75,14 +79,61 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
)
}
override fun onItemClick(item: MangaSource, view: View) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item)
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item.source)
}
override fun onItemLongClick(item: MangaSource, view: View): Boolean {
reorderHelper.startDrag(
binding.recyclerView.findContainingViewHolder(view) ?: return false
)
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper?.startDrag(holder)
}
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
viewModel.expandOrCollapse(header.localeId)
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performSearch(newText)
return true
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as SearchView).setQuery("", false)
return true
}
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder(
current.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
}

View File

@@ -0,0 +1,158 @@
package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
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.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.move
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.util.*
private const val KEY_ENABLED = "!"
class SourcesSettingsViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableLiveData<List<SourceConfigItem>>(emptyList())
private val expandedGroups = HashSet<String?>()
private var searchQuery: String? = null
init {
buildList()
}
fun reorderSources(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value?.toMutableList() ?: return false
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
snapshot.move(oldPos, newPos)
settings.sourcesOrder = snapshot.mapNotNull {
(it as? SourceConfigItem.SourceItem)?.source?.ordinal
}
buildList()
return true
}
fun canReorder(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value?.toMutableList() ?: return false
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
return true
}
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
settings.hiddenSources = if (isEnabled) {
settings.hiddenSources - source.name
} else {
settings.hiddenSources + source.name
}
buildList()
}
fun expandOrCollapse(headerId: String?) {
if (headerId in expandedGroups) {
expandedGroups.remove(headerId)
} else {
expandedGroups.add(headerId)
}
buildList()
}
fun performSearch(query: String?) {
searchQuery = query?.trim()
buildList()
}
private fun buildList() {
val sources = MangaProviderFactory.getSources(settings, includeHidden = true)
val hiddenSources = settings.hiddenSources
val query = searchQuery
if (!query.isNullOrEmpty()) {
items.value = sources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
isEnabled = it.name !in hiddenSources,
isDraggable = false,
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
}
return
}
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it.name !in hiddenSources) {
KEY_ENABLED
} else {
it.locale
}
}
val result = ArrayList<SourceConfigItem>(sources.size + map.size + 1)
val enabledSources = map.remove(KEY_ENABLED)
if (!enabledSources.isNullOrEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
isEnabled = true,
isDraggable = true,
)
}
}
if (enabledSources?.size != sources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
for ((key, list) in map) {
val locale = if (key != null) {
Locale(key)
} else null
list.sortBy { it.ordinal }
val isExpanded = key in expandedGroups
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
isEnabled = false,
isDraggable = false,
)
}
}
}
}
items.value = result
}
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
}
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.settings.sources.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourceConfigAdapter(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) : AsyncListDifferDelegationAdapter<SourceConfigItem>(
SourceConfigDiffCallback(),
sourceConfigHeaderDelegate(),
sourceConfigGroupDelegate(listener),
sourceConfigItemDelegate(listener, coil, lifecycleOwner),
sourceConfigDraggableItemDelegate(listener),
sourceConfigEmptySearchDelegate(),
)

View File

@@ -0,0 +1,116 @@
package org.koitharu.kotatsu.settings.sources.adapter
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.CompoundButton
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
) {
bind {
binding.root.setText(item.titleResId)
}
}
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) }
) {
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
bind {
binding.root.text = item.title ?: getString(R.string.other)
binding.root.isChecked = item.isExpanded
}
}
fun sourceConfigItemDelegate(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable }
) {
var imageRequest: Disposable? = null
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
bind {
binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled
imageRequest = ImageRequest.Builder(context)
.data(item.faviconUrl)
.error(R.drawable.ic_favicon_fallback)
.target(binding.imageViewIcon)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
}
onViewRecycled {
imageRequest?.dispose()
imageRequest = null
}
}
@SuppressLint("ClickableViewAccessibility")
fun sourceConfigDraggableItemDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigDraggableBinding>(
{ layoutInflater, parent -> ItemSourceConfigDraggableBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
) {
val eventListener = object : View.OnClickListener, View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
override fun onTouch(v: View?, event: MotionEvent): Boolean {
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
true
} else {
false
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
listener.onItemEnabledChanged(item, isChecked)
}
}
binding.imageViewConfig.setOnClickListener(eventListener)
binding.switchToggle.setOnCheckedChangeListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled
}
}
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty
) { }

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.settings.sources.adapter
import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.*
class SourceConfigDiffCallback : DiffUtil.ItemCallback<SourceConfigItem>() {
override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
return when {
oldItem.javaClass != newItem.javaClass -> false
oldItem is LocaleGroup && newItem is LocaleGroup -> {
oldItem.localeId == newItem.localeId
}
oldItem is SourceItem && newItem is SourceItem -> {
oldItem.source == newItem.source
}
oldItem is Header && newItem is Header -> {
oldItem.titleResId == newItem.titleResId
}
oldItem == EmptySearchResult && newItem == EmptySearchResult -> {
true
}
else -> false
}
}
override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.settings.sources.adapter
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration
class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) {
override fun shouldDrawDivider(
above: RecyclerView.ViewHolder,
below: RecyclerView.ViewHolder,
): Boolean {
return above.itemViewType != 0 && below.itemViewType != 0
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.settings.sources.adapter
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
interface SourceConfigListener {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
}

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.settings.sources.model
import android.net.Uri
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.MangaSource
sealed interface SourceConfigItem {
class Header(
@StringRes val titleResId: Int,
) : SourceConfigItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Header
return titleResId == other.titleResId
}
override fun hashCode(): Int = titleResId
}
class LocaleGroup(
val localeId: String?,
val title: String?,
val isExpanded: Boolean,
) : SourceConfigItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocaleGroup
if (localeId != other.localeId) return false
if (title != other.title) return false
if (isExpanded != other.isExpanded) return false
return true
}
override fun hashCode(): Int {
var result = localeId?.hashCode() ?: 0
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + isExpanded.hashCode()
return result
}
}
class SourceItem(
val source: MangaSource,
val isEnabled: Boolean,
val isDraggable: Boolean,
) : SourceConfigItem {
val faviconUrl: Uri
get() = Uri.fromParts("favicon", source.name, null)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SourceItem
if (source != other.source) return false
if (isEnabled != other.isEnabled) return false
if (isDraggable != other.isDraggable) return false
return true
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + isEnabled.hashCode()
result = 31 * result + isDraggable.hashCode()
return result
}
}
object EmptySearchResult : SourceConfigItem
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
@@ -11,12 +12,11 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@Suppress("BlockingMethodInNonBlockingContext")
open class MutableZipFile(val file: File) {
protected val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun unpack(): Unit = withContext(Dispatchers.IO) {
suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) {
check(dir.list().isNullOrEmpty()) {
"Dir ${dir.name} is not empty"
}
@@ -24,7 +24,7 @@ open class MutableZipFile(val file: File) {
dir.mkdir()
}
if (!file.exists()) {
return@withContext
return@runInterruptible
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
@@ -45,7 +45,7 @@ open class MutableZipFile(val file: File) {
}
@CheckResult
suspend fun flush(): Boolean = withContext(Dispatchers.IO) {
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.delete()
@@ -57,7 +57,7 @@ open class MutableZipFile(val file: File) {
}
zip.flush()
}
return@withContext tempFile.renameTo(file)
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.delete()

View File

@@ -10,4 +10,10 @@ object PendingIntentCompat {
} else {
0
}
val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
}

View File

@@ -7,7 +7,7 @@ import android.database.ContentObserver
import android.os.Handler
import android.provider.Settings
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.sendBlocking
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart
@@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
fun observeAutoOrientation() = callbackFlow<Boolean> {
val observer = object : ContentObserver(Handler(activity.mainLooper)) {
override fun onChange(selfChange: Boolean) {
sendBlocking(isAutoRotationEnabled)
trySendBlocking(isAutoRotationEnabled)
}
}
activity.contentResolver.registerContentObserver(

View File

@@ -46,7 +46,7 @@ class ShareHelper(private val context: Context) {
fun shareImage(uri: Uri) {
val intent = Intent(Intent.ACTION_SEND)
intent.setDataAndType(uri, context.contentResolver.getType(uri))
intent.setDataAndType(uri, context.contentResolver.getType(uri) ?: "image/*")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image))
context.startActivity(shareIntent)

View File

@@ -7,16 +7,16 @@ import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@Suppress("NOTHING_TO_INLINE")
inline fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.target(this)
@Suppress("NOTHING_TO_INLINE")
inline fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
fun ImageResult.requireBitmap() = when (this) {
is SuccessResult -> drawable.toBitmap()
@@ -32,7 +32,10 @@ fun ImageResult.toBitmapOrNull() = when (this) {
is ErrorResult -> null
}
@Suppress("NOTHING_TO_INLINE")
inline fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
return setHeader(CommonHeaders.REFERER, referer)
}
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicator))
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSparseArray
import java.util.*
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
clear()
@@ -72,4 +73,12 @@ fun <T, K> Collection<T>.isDistinctBy(selector: (T) -> K): Boolean {
}
}
return set.size == size
}
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
if (sourceIndex <= targetIndex) {
Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
} else {
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.utils.ext
fun <T> Iterator<T>.nextOrNull(): T? = if (hasNext()) next() else null
fun <T> Iterator<T>.toList(): List<T> {
if (!hasNext()) {
return emptyList()
}
val list = ArrayList<T>()
while (hasNext()) list += next()
return list
}
fun <T> Iterator<T>.toSet(): Set<T> {
if (!hasNext()) {
return emptySet()
}
val list = LinkedHashSet<T>()
while (hasNext()) list += next()
return list
}

View File

@@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.utils.json.JSONIterator
import org.koitharu.kotatsu.utils.json.JSONStringIterator
import org.koitharu.kotatsu.utils.json.JSONValuesIterator
import kotlin.contracts.contract
inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
destination: C,
@@ -16,10 +20,26 @@ inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
return destination
}
inline fun <R, C : MutableCollection<in R>> JSONArray.mapNotNullTo(
destination: C,
block: (JSONObject) -> R?
): C {
val len = length()
for (i in 0 until len) {
val jo = getJSONObject(i)
destination.add(block(jo) ?: continue)
}
return destination
}
inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
return mapTo(ArrayList(length()), block)
}
inline fun <T> JSONArray.mapNotNull(block: (JSONObject) -> T?): List<T> {
return mapNotNullTo(ArrayList(length()), block)
}
fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> {
val len = length()
val result = ArrayList<T>(len)
@@ -46,26 +66,6 @@ operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): JSONObject = array.getJSONObject(index++)
}
private class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): String = array.getString(index++)
}
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
val len = length()
val result = ArraySet<T>(len)
@@ -74,4 +74,24 @@ fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
result.add(block(jo))
}
return result
}
fun JSONObject.values(): Iterator<Any> = JSONValuesIterator(this)
fun JSONArray.associateByKey(key: String): Map<String, JSONObject> {
val destination = LinkedHashMap<String, JSONObject>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
destination[keyValue] = item
}
return destination
}
fun JSONArray?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.length() == 0
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.utils.ext
import androidx.core.os.LocaleListCompat
import java.util.*
import kotlin.collections.ArrayList
fun LocaleListCompat.toList(): List<Locale> {
val list = ArrayList<Locale>(size())
@@ -26,4 +25,8 @@ inline fun <R, C : MutableCollection<in R>> LocaleListCompat.mapTo(
inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> {
return mapTo(ArrayList(size()), block)
}
inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
return mapTo(LinkedHashSet(size()), block)
}

View File

@@ -133,6 +133,8 @@ fun View.resetTransformations() {
translationZ = 0f
scaleX = 1f
scaleY = 1f
rotationX = 0f
rotationY = 0f
}
inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) {

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
import org.json.JSONObject
class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): JSONObject = array.getJSONObject(index++)
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): String = array.getString(index++)
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONObject
class JSONValuesIterator(
private val jo: JSONObject,
): Iterator<Any> {
private val keyIterator = jo.keys()
override fun hasNext(): Boolean = keyIterator.hasNext()
override fun next(): Any {
val key = keyIterator.next()
return jo.get(key)
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.utils.progress
import coil.request.ImageRequest
import coil.request.ImageResult
import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener(
private val indicator: BaseProgressIndicator<*>,
) : ImageRequest.Listener {
override fun onCancel(request: ImageRequest) = indicator.hide()
override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide()
override fun onStart(request: ImageRequest) = indicator.show()
override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide()
}

View File

@@ -31,7 +31,7 @@ class RecentWidgetProvider : AppWidgetProvider() {
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
)
views.setEmptyView(R.id.stackView, R.id.textView_holder)

View File

@@ -7,6 +7,6 @@ import org.koin.android.ext.android.get
class RecentWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return RecentListFactory(this, get(), get())
return RecentListFactory(applicationContext, get(), get())
}
}

View File

@@ -31,7 +31,7 @@ class ShelfWidgetProvider : AppWidgetProvider() {
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE
)
)
views.setEmptyView(R.id.gridView, R.id.textView_holder)

View File

@@ -12,6 +12,6 @@ class ShelfWidgetService : RemoteViewsService() {
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
return ShelfListFactory(this, get(), get(), widgetId)
return ShelfListFactory(applicationContext, get(), get(), widgetId)
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_expand_less" android:state_checked="true" />
<item android:drawable="@drawable/ic_expand_more" android:state_checked="false" />
</selector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
</vector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="?colorControlLight" />
</shape>
</item>
<item
android:bottom="4dp"
android:drawable="@drawable/ic_web"
android:left="4dp"
android:right="4dp"
android:top="4dp" />
</layer-list>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:transitionName="cover" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.Kotatsu.Toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -40,6 +40,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:scaleType="centerCrop"
android:transitionName="cover"
tools:background="@tools:sample/backgrounds/scenic"
tools:ignore="ContentDescription" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckedTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground"
android:drawablePadding="12dp"
android:gravity="center_vertical|start"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
app:drawableEndCompat="@drawable/ic_expand_collapse"
app:drawableTint="?android:textColorPrimary"
tools:text="@tools:sample/full_names" />

View File

@@ -19,6 +19,17 @@
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
android:layout_margin="8dp"
android:singleLine="true"
android:textColor="?android:textColorTertiary"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
tools:text="5" />
<LinearLayout
android:id="@+id/layout_error"
android:layout_width="wrap_content"

View File

@@ -4,17 +4,18 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:windowBackground"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/imageView_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="?listPreferredItemPaddingStart"
android:scaleType="center"
android:src="@drawable/ic_reorder_handle" />
android:id="@+id/imageView_icon"
android:layout_width="?android:listPreferredItemHeightSmall"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_marginHorizontal="?listPreferredItemPaddingStart"
android:labelFor="@id/textView_title"
android:padding="8dp"
android:scaleType="fitCenter"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textView_title"
@@ -31,16 +32,7 @@
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ImageView
android:id="@+id/imageView_config"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:padding="?listPreferredItemPaddingEnd"
android:scaleType="center"
android:src="@drawable/ic_settings" />
android:layout_marginEnd="?listPreferredItemPaddingEnd" />
</LinearLayout>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:windowBackground"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/imageView_handle"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingHorizontal="?listPreferredItemPaddingStart"
android:scaleType="center"
android:src="@drawable/ic_reorder_handle" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?android:attr/textColorPrimary"
tools:text="@tools:sample/lorem[1]" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ImageView
android:id="@+id/imageView_config"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:paddingHorizontal="?listPreferredItemPaddingEnd"
android:scaleType="center"
android:src="@drawable/ic_settings" />
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/nothing_found"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorSecondary" />

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