Compare commits

...

28 Commits
v2.1 ... v2.1.5

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
51 changed files with 559 additions and 248 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -21,6 +21,10 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.NEWEST SortOrder.NEWEST
) )
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/favicons/favicon.png"
}
override suspend fun getList2( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,

View File

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

View File

@@ -148,7 +148,7 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
chapters = feed.mapNotNull { jo -> chapters = feed.mapNotNull { jo ->
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
if (attrs.optJSONArray("data").isNullOrEmpty()) { if (!attrs.isNull("externalUrl")) {
return@mapNotNull null return@mapNotNull null
} }
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage")) val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
@@ -171,15 +171,14 @@ class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = getDomain() val domain = getDomain()
val attrs = loaderContext.httpGet("https://api.$domain/chapter/${chapter.url}") val chapter = loaderContext.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
.parseJson() .parseJson()
.getJSONObject("data") .getJSONObject("chapter")
.getJSONObject("attributes") val pages = chapter.getJSONArray("data")
val data = attrs.getJSONArray("data") val prefix = "https://uploads.$domain/data/${chapter.getString("hash")}/"
val prefix = "https://uploads.$domain/data/${attrs.getString("hash")}/"
val referer = "https://$domain/" val referer = "https://$domain/"
return List(data.length()) { i -> return List(pages.length()) { i ->
val url = prefix + data.getString(i) val url = prefix + pages.getString(i)
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import android.util.Base64
import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* 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 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 table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US) 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( return manga.copy(
description = info.selectFirst(".description")?.html(), description = info.selectFirst(".description")?.html(),
largeCoverUrl = info.select("img").first()?.let { img -> largeCoverUrl = info.select("img").first()?.let { img ->
@@ -100,7 +105,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
id = generateUid(href), id = generateUid(href),
name = a.select("label").text(), name = a.select("label").text(),
number = i + 1, number = i + 1,
url = href, url = "$href?tr=$tr&s=$s",
scanlator = null, scanlator = null,
branch = null, branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()), uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
@@ -120,7 +125,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl, referer = url,
source = MangaSource.MANGAOWL, source = MangaSource.MANGAOWL,
) )
} }

View File

@@ -128,7 +128,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
scanlator = null, scanlator = null,
branch = null, branch = null,
) )
} } ?: bypassLicensedChapters(manga)
) )
} }
@@ -191,6 +191,32 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
map[SourceSettings.KEY_USE_SSL] = true 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 fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
private companion object { private companion object {

View File

@@ -55,7 +55,7 @@ class MangareadRepository(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.inContextOf(div), 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(), title = summary?.selectFirst("h3")?.text().orEmpty(),
rating = div.selectFirst("span.total_votes")?.ownText() rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f, ?.toFloatOrNull()?.div(5f) ?: -1f,
@@ -107,16 +107,6 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area") val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page") ?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found") ?: 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) val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
return manga.copy( return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a") tags = root.selectFirst("div.genres-content")?.select("a")
@@ -132,7 +122,7 @@ class MangareadRepository(
?.select("p") ?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") } ?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() }, ?.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 a = li.selectFirst("a")
val href = a?.relUrl("href").orEmpty().ifEmpty { val href = a?.relUrl("href").orEmpty().ifEmpty {
parseFailed("Link is missing") parseFailed("Link is missing")
@@ -144,7 +134,7 @@ class MangareadRepository(
url = href, url = href,
uploadDate = parseChapterDate( uploadDate = parseChapterDate(
dateFormat, dateFormat,
doc2.selectFirst("span.chapter-release-date i")?.text() li.selectFirst("span.chapter-release-date i")?.text()
), ),
source = MangaSource.MANGAREAD, source = MangaSource.MANGAREAD,
scanlator = null, scanlator = null,
@@ -161,8 +151,10 @@ class MangareadRepository(
?.selectFirst("div.reading-content") ?.selectFirst("div.reading-content")
?: throw ParseException("Root not found") ?: throw ParseException("Root not found")
return root.select("div.page-break").map { div -> return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") val img = div.selectFirst("img") ?: parseFailed("Page image not found")
val url = img?.relUrl("src") ?: parseFailed("Page image not found") val url = img.relUrl("data-src").ifEmpty {
img.relUrl("src")
}
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.withContext import org.koitharu.kotatsu.utils.ext.deleteAwait
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -14,10 +14,13 @@ class WritableCbzFile(private val file: File) {
private val dir = File(file.parentFile, file.nameWithoutExtension) private val dir = File(file.parentFile, file.nameWithoutExtension)
@Suppress("BlockingMethodInNonBlockingContext") suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
suspend fun prepare() = withContext(Dispatchers.IO) { if (!dir.list().isNullOrEmpty()) {
check(dir.list().isNullOrEmpty()) { if (overwrite) {
"Dir ${dir.name} is not empty" dir.deleteRecursively()
} else {
throw IllegalStateException("Dir ${dir.name} is not empty")
}
} }
if (!dir.exists()) { if (!dir.exists()) {
dir.mkdir() dir.mkdir()
@@ -27,11 +30,13 @@ class WritableCbzFile(private val file: File) {
} }
ZipInputStream(FileInputStream(file)).use { zip -> ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry var entry = zip.nextEntry
while (entry != null) { while (entry != null && currentCoroutineContext().isActive) {
val target = File(dir.path + File.separator + entry.name) val target = File(dir.path + File.separator + entry.name)
target.parentFile?.mkdirs() runInterruptible {
target.outputStream().use { out -> target.parentFile?.mkdirs()
zip.copyTo(out) target.outputStream().use { out ->
zip.copyTo(out)
}
} }
zip.closeEntry() zip.closeEntry()
entry = zip.nextEntry entry = zip.nextEntry
@@ -44,52 +49,50 @@ class WritableCbzFile(private val file: File) {
} }
@CheckResult @CheckResult
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun flush() = withContext(Dispatchers.IO) { suspend fun flush() = withContext(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp") val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) { if (tempFile.exists()) {
tempFile.delete() tempFile.deleteAwait()
} }
try { try {
ZipOutputStream(FileOutputStream(tempFile)).use { zip -> runInterruptible {
dir.listFiles()?.forEach { ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
zipFile(it, it.name, zip) dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
} }
zip.flush()
} }
tempFile.renameTo(file) tempFile.renameTo(file)
} finally { } finally {
if (tempFile.exists()) { if (tempFile.exists()) {
tempFile.delete() tempFile.deleteAwait()
} }
} }
} }
operator fun get(name: String) = File(dir, name) 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) file.copyTo(this[name], overwrite = true)
} }
companion object { private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) { if (fileName.endsWith("/")) {
if (fileToZip.isDirectory) { zipOut.putNextEntry(ZipEntry(fileName))
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else { } else {
FileInputStream(fileToZip).use { fis -> zipOut.putNextEntry(ZipEntry("$fileName/"))
val zipEntry = ZipEntry(fileName) }
zipOut.putNextEntry(zipEntry) zipOut.closeEntry()
fis.copyTo(zipOut) 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.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -42,38 +43,39 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
getFromFile(Uri.parse(manga.url).toFile()) getFromFile(Uri.parse(manga.url).toFile())
} else manga } else manga
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val uri = Uri.parse(chapter.url) return runInterruptible(Dispatchers.IO){
val file = uri.toFile() val uri = Uri.parse(chapter.url)
val zip = ZipFile(file) val file = uri.toFile()
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex) val zip = ZipFile(file)
var entries = zip.entries().asSequence() val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
entries = if (index != null) { var entries = zip.entries().asSequence()
val pattern = index.getChapterNamesPattern(chapter) entries = if (index != null) {
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } val pattern = index.getChapterNamesPattern(chapter)
} else { entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
val parent = uri.fragment.orEmpty() } else {
entries.filter { x -> val parent = uri.fragment.orEmpty()
!x.isDirectory && x.name.substringBeforeLast( entries.filter { x ->
File.separatorChar, !x.isDirectory && x.name.substringBeforeLast(
"" File.separatorChar,
) == parent ""
) == 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 { suspend fun delete(manga: Manga): Boolean {
@@ -137,20 +139,18 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
val file = runCatching { val file = runCatching {
Uri.parse(localManga.url).toFile() Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null }.getOrNull() ?: return null
return withContext(Dispatchers.IO) { return runInterruptible(Dispatchers.IO) {
@Suppress("BlockingMethodInNonBlockingContext")
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null val index = entry?.let(zip::readText)?.let(::MangaIndex)
index.getMangaInfo() index?.getMangaInfo()
} }
} }
} }
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) { suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) {
val files = getAllFiles() val files = getAllFiles()
for (file in files) { for (file in files) {
@Suppress("BlockingMethodInNonBlockingContext")
val index = ZipFile(file).use { zip -> val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
entry?.let(zip::readText)?.let(::MangaIndex) entry?.let(zip::readText)?.let(::MangaIndex)
@@ -158,7 +158,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
val info = index.getMangaInfo() ?: continue val info = index.getMangaInfo() ?: continue
if (info.id == remoteManga.id) { if (info.id == remoteManga.id) {
val fileUri = file.toUri().toString() val fileUri = file.toUri().toString()
return@withContext info.copy( return@runInterruptible info.copy(
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
url = fileUri, url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) } chapters = info.chapters?.map { c -> c.copy(url = fileUri) }

View File

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

View File

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

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.utils.ext.toIntUp import org.koitharu.kotatsu.utils.ext.toIntUp
class WebtoonImageView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null) : class WebtoonImageView @JvmOverloads constructor(
SubsamplingScaleImageView(context, attr) { context: Context,
attr: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF() 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 scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var scrollRange = SCROLL_UNKNOWN
@@ -55,6 +58,30 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu
return desiredHeight 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) { private fun scrollToInternal(pos: Int) {
scrollPos = pos scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.settings.sources package org.koitharu.kotatsu.settings.sources
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.*
import android.view.View import androidx.appcompat.widget.SearchView
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -19,14 +19,13 @@ import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(), class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
SourceConfigListener { SourceConfigListener, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
private lateinit var reorderHelper: ItemTouchHelper private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModel<SourcesSettingsViewModel>() private val viewModel by viewModel<SourcesSettingsViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
reorderHelper = ItemTouchHelper(SourcesReorderCallback())
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
@@ -42,12 +41,14 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this) val sourcesAdapter = SourceConfigAdapter(this, get(), viewLifecycleOwner)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(SourceConfigItemDecoration(view.context)) addItemDecoration(SourceConfigItemDecoration(view.context))
adapter = sourcesAdapter adapter = sourcesAdapter
reorderHelper.attachToRecyclerView(this) reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also {
it.attachToRecyclerView(this)
}
} }
viewModel.items.observe(viewLifecycleOwner) { viewModel.items.observe(viewLifecycleOwner) {
sourcesAdapter.items = it sourcesAdapter.items = it
@@ -55,10 +56,21 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
} }
override fun onDestroyView() { override fun onDestroyView() {
reorderHelper.attachToRecyclerView(null) reorderHelper = null
super.onDestroyView() super.onDestroyView()
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
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) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom,
@@ -76,13 +88,27 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
} }
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) { override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper.startDrag(holder) reorderHelper?.startDrag(holder)
} }
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) { override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
viewModel.expandOrCollapse(header.localeId) 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( private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0, 0,

View File

@@ -21,6 +21,7 @@ class SourcesSettingsViewModel(
val items = MutableLiveData<List<SourceConfigItem>>(emptyList()) val items = MutableLiveData<List<SourceConfigItem>>(emptyList())
private val expandedGroups = HashSet<String?>() private val expandedGroups = HashSet<String?>()
private var searchQuery: String? = null
init { init {
buildList() buildList()
@@ -63,9 +64,30 @@ class SourcesSettingsViewModel(
buildList() buildList()
} }
fun performSearch(query: String?) {
searchQuery = query?.trim()
buildList()
}
private fun buildList() { private fun buildList() {
val sources = MangaProviderFactory.getSources(settings, includeHidden = true) val sources = MangaProviderFactory.getSources(settings, includeHidden = true)
val hiddenSources = settings.hiddenSources 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())) { val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it.name !in hiddenSources) { if (it.name !in hiddenSources) {
KEY_ENABLED KEY_ENABLED
@@ -81,6 +103,7 @@ class SourcesSettingsViewModel(
SourceConfigItem.SourceItem( SourceConfigItem.SourceItem(
source = it, source = it,
isEnabled = true, isEnabled = true,
isDraggable = true,
) )
} }
} }
@@ -102,6 +125,7 @@ class SourcesSettingsViewModel(
SourceConfigItem.SourceItem( SourceConfigItem.SourceItem(
source = it, source = it,
isEnabled = false, isEnabled = false,
isDraggable = false,
) )
} }
} }

View File

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

View File

@@ -4,14 +4,19 @@ import android.annotation.SuppressLint
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.CompoundButton import android.widget.CompoundButton
import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner
import androidx.core.view.updatePaddingRelative import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemExpandableBinding import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>( fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
@@ -38,11 +43,44 @@ fun sourceConfigGroupDelegate(
} }
} }
@SuppressLint("ClickableViewAccessibility")
fun sourceConfigItemDelegate( fun sourceConfigItemDelegate(
listener: SourceConfigListener, listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>( ) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) } { 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, val eventListener = object : View.OnClickListener, View.OnTouchListener,
@@ -70,11 +108,9 @@ fun sourceConfigItemDelegate(
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled binding.switchToggle.isChecked = item.isEnabled
binding.imageViewHandle.isVisible = item.isEnabled
binding.imageViewConfig.isVisible = item.isEnabled
binding.root.updatePaddingRelative(
start = if (item.isEnabled) 0 else binding.imageViewHandle.paddingStart * 2,
end = if (item.isEnabled) 0 else binding.imageViewConfig.paddingEnd,
)
} }
} }
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty
) { }

View File

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

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.settings.sources.model package org.koitharu.kotatsu.settings.sources.model
import android.net.Uri
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
@@ -49,8 +50,12 @@ sealed interface SourceConfigItem {
class SourceItem( class SourceItem(
val source: MangaSource, val source: MangaSource,
val isEnabled: Boolean, val isEnabled: Boolean,
val isDraggable: Boolean,
) : SourceConfigItem { ) : SourceConfigItem {
val faviconUrl: Uri
get() = Uri.fromParts("favicon", source.name, null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@@ -59,6 +64,7 @@ sealed interface SourceConfigItem {
if (source != other.source) return false if (source != other.source) return false
if (isEnabled != other.isEnabled) return false if (isEnabled != other.isEnabled) return false
if (isDraggable != other.isDraggable) return false
return true return true
} }
@@ -66,7 +72,10 @@ sealed interface SourceConfigItem {
override fun hashCode(): Int { override fun hashCode(): Int {
var result = source.hashCode() var result = source.hashCode()
result = 31 * result + isEnabled.hashCode() result = 31 * result + isEnabled.hashCode()
result = 31 * result + isDraggable.hashCode()
return result return result
} }
} }
object EmptySearchResult : SourceConfigItem
} }

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ class ShareHelper(private val context: Context) {
fun shareImage(uri: Uri) { fun shareImage(uri: Uri) {
val intent = Intent(Intent.ACTION_SEND) 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) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image)) val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image))
context.startActivity(shareIntent) context.startActivity(shareIntent)

View File

@@ -31,7 +31,7 @@ class RecentWidgetProvider : AppWidgetProvider() {
context, context,
0, 0,
intent, 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) 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() { class RecentWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { 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, context,
0, 0,
intent, 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) views.setEmptyView(R.id.gridView, R.id.textView_holder)

View File

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

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

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

View File

@@ -4,9 +4,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item <item
android:id="@+id/action_languages" android:id="@+id/action_search"
android:icon="@drawable/ic_locale" android:icon="@drawable/ic_search"
android:title="@string/languages" android:title="@string/search"
app:showAsAction="ifRoom" /> app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
</menu> </menu>

View File

@@ -246,4 +246,7 @@
<string name="system_default">Standard</string> <string name="system_default">Standard</string>
<string name="exclude_nsfw_from_history">NSFW-Manga aus dem Verlauf ausschließen</string> <string name="exclude_nsfw_from_history">NSFW-Manga aus dem Verlauf ausschließen</string>
<string name="error_empty_name">Der Name sollte nicht leer sein</string> <string name="error_empty_name">Der Name sollte nicht leer sein</string>
<string name="show_pages_numbers">Seitenzahlen anzeigen</string>
<string name="enabled_sources">Freigegebene Quellen</string>
<string name="available_sources">Verfügbare Quellen</string>
</resources> </resources>

View File

@@ -246,4 +246,7 @@
<string name="system_default">Par défaut</string> <string name="system_default">Par défaut</string>
<string name="exclude_nsfw_from_history">Exclure les mangas osés de l\'historique</string> <string name="exclude_nsfw_from_history">Exclure les mangas osés de l\'historique</string>
<string name="error_empty_name">Le nom ne doit pas être vide</string> <string name="error_empty_name">Le nom ne doit pas être vide</string>
<string name="show_pages_numbers">Afficher les numéros de pages</string>
<string name="enabled_sources">Sources activées</string>
<string name="available_sources">Sources disponibles</string>
</resources> </resources>

View File

@@ -246,4 +246,7 @@
<string name="date_format">Formato della data</string> <string name="date_format">Formato della data</string>
<string name="exclude_nsfw_from_history">Escludi i manga NSFW dalla storia</string> <string name="exclude_nsfw_from_history">Escludi i manga NSFW dalla storia</string>
<string name="error_empty_name">Il nome non dovrebbe essere vuoto</string> <string name="error_empty_name">Il nome non dovrebbe essere vuoto</string>
<string name="show_pages_numbers">Mostra i numeri delle pagine</string>
<string name="enabled_sources">Fonti abilitate</string>
<string name="available_sources">Fonti disponibili</string>
</resources> </resources>

View File

@@ -3,6 +3,9 @@
<external-files-path <external-files-path
name="manga-ext" name="manga-ext"
path="/manga" /> path="/manga" />
<files-path
name="manga"
path="/manga" />
<external-files-path <external-files-path
name="backups-ext" name="backups-ext"
path="/backups" /> path="/backups" />

View File

@@ -6,6 +6,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.Parameterized import org.junit.runners.Parameterized
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koin.core.logger.Level
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koin.test.KoinTest import org.koin.test.KoinTest
import org.koin.test.KoinTestRule import org.koin.test.KoinTestRule
@@ -18,7 +19,7 @@ import org.koitharu.kotatsu.utils.TestResponse
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.utils.ext.medianOrNull
import org.koitharu.kotatsu.utils.isAbsoluteUrl import org.koitharu.kotatsu.utils.isAbsoluteUrl
import org.koitharu.kotatsu.utils.isRelativeUrl import org.koitharu.kotatsu.utils.isNotAbsoluteUrl
@RunWith(Parameterized::class) @RunWith(Parameterized::class)
class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest { class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
@@ -29,7 +30,7 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
@get:Rule @get:Rule
val koinTestRule = KoinTestRule.create { val koinTestRule = KoinTestRule.create {
printLogger() printLogger(Level.ERROR)
modules(repositoryTestModule) modules(repositoryTestModule)
} }
@@ -112,7 +113,7 @@ class RemoteMangaRepositoryTest(private val source: MangaSource) : KoinTest {
Truth.assertThat(list.map { it.id }).containsNoDuplicates() Truth.assertThat(list.map { it.id }).containsNoDuplicates()
for (item in list) { for (item in list) {
Truth.assertThat(item.url).isNotEmpty() Truth.assertThat(item.url).isNotEmpty()
Truth.assertThat(item.url).isRelativeUrl() Truth.assertThat(item.url).isNotAbsoluteUrl()
Truth.assertThat(item.coverUrl).isAbsoluteUrl() Truth.assertThat(item.coverUrl).isAbsoluteUrl()
Truth.assertThat(item.title).isNotEmpty() Truth.assertThat(item.title).isNotEmpty()
Truth.assertThat(item.publicUrl).isAbsoluteUrl() Truth.assertThat(item.publicUrl).isAbsoluteUrl()

View File

@@ -3,25 +3,25 @@ package org.koitharu.kotatsu.utils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher import org.junit.rules.TestWatcher
import org.junit.runner.Description import org.junit.runner.Description
class CoroutineTestRule( class CoroutineTestRule(
private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(), private val testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() { ) : TestWatcher() {
override fun starting(description: Description?) { override fun starting(description: Description) {
super.starting(description) super.starting(description)
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
} }
override fun finished(description: Description?) { override fun finished(description: Description) {
super.finished(description) super.finished(description)
Dispatchers.resetMain() Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
} }
fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) { fun runBlockingTest(block: suspend CoroutineScope.() -> Unit) {

View File

@@ -8,4 +8,6 @@ private val PATTERN_URL_RELATIVE = Pattern.compile("^/[^\\s]+", Pattern.CASE_INS
fun StringSubject.isRelativeUrl() = matches(PATTERN_URL_RELATIVE) fun StringSubject.isRelativeUrl() = matches(PATTERN_URL_RELATIVE)
fun StringSubject.isAbsoluteUrl() = matches(PATTERN_URL_ABSOLUTE) fun StringSubject.isAbsoluteUrl() = matches(PATTERN_URL_ABSOLUTE)
fun StringSubject.isNotAbsoluteUrl() = doesNotMatch(PATTERN_URL_ABSOLUTE)