Compare commits

...

21 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,8 +151,10 @@ class MangareadRepository(
?.selectFirst("div.reading-content")
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
val url = img.relUrl("data-src").ifEmpty {
img.relUrl("src")
}
MangaPage(
id = generateUid(url),
url = url,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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