Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d0addb7b | ||
|
|
1713efb51f | ||
|
|
9089555320 | ||
|
|
2f3b1f397c | ||
|
|
7ebb98ce06 | ||
|
|
c218ae0baa | ||
|
|
5820b2f511 | ||
|
|
79c2bf17fd | ||
|
|
78aa4d76db | ||
|
|
e2f3ba19b8 | ||
|
|
41045686fc | ||
|
|
8b0b375dfe | ||
|
|
c7c23b9768 | ||
|
|
33190ae3ea | ||
|
|
03590f4b82 | ||
|
|
cbcf98e1d4 | ||
|
|
e3a80b5a6d | ||
|
|
66dc5a9597 | ||
|
|
cb6bf91dd3 | ||
|
|
fb815abad0 | ||
|
|
8ef7580097 | ||
|
|
197393fbd1 | ||
|
|
51ef6e3c78 | ||
|
|
663277fe6f | ||
|
|
332a38d674 | ||
|
|
e9410a2f54 | ||
|
|
b5fa2bd660 | ||
|
|
e56c61d834 |
@@ -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'
|
||||||
}
|
}
|
||||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
|
) { }
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
16
app/src/main/res/drawable/ic_favicon_fallback.xml
Normal file
16
app/src/main/res/drawable/ic_favicon_fallback.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
46
app/src/main/res/layout/item_source_config_draggable.xml
Normal file
46
app/src/main/res/layout/item_source_config_draggable.xml
Normal 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>
|
||||||
9
app/src/main/res/layout/item_sources_empty.xml
Normal file
9
app/src/main/res/layout/item_sources_empty.xml
Normal 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" />
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user