diff --git a/app/build.gradle b/app/build.gradle index 9f01df521..1f5456819 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 31 - versionCode 379 - versionName '2.1.3' + versionCode 380 + versionName '2.1.4' 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,16 +41,10 @@ android { sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } - lintOptions { - disable 'MissingTranslation' - abortOnError false - } - testOptions { - unitTests.includeAndroidResources = true - unitTests.returnDefaultValues = false + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } -} -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += [ @@ -62,6 +52,14 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { '-Xopt-in=kotlin.contracts.ExperimentalContracts', ] } + lintOptions { + disable 'MissingTranslation' + abortOnError false + } + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = false + } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) @@ -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' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cc8e53e2..de52fe2fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,7 @@ + android:value="org.koitharu.kotatsu.ui.search.SearchActivity" /> - + ): 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() @@ -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 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt index c0af8d788..d0ec8a8cb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt @@ -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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt new file mode 100644 index 000000000..44f1ca040 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt @@ -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 { + + 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" +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 0062bc1d0..293bed94b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -29,6 +29,8 @@ abstract class RemoteMangaRepository( override suspend fun getTags(): Set = emptySet() + open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico" + open fun onCreatePreferences(map: MutableMap) { map[SourceSettings.KEY_DOMAIN] = defaultDomain } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt index 03853cd1f..e71378eec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt @@ -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?, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt index 8032d9783..148fa409d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt @@ -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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index d44204533..03712d889 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -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 diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index cd4f5ea83..4e2746cec 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -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 { - @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, diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt index b7c5f7b9f..fbc2637aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data import androidx.annotation.CheckResult import kotlinx.coroutines.* +import org.koitharu.kotatsu.utils.ext.deleteAwait import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -13,7 +14,6 @@ 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" @@ -45,11 +45,10 @@ 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 { runInterruptible { @@ -63,7 +62,7 @@ class WritableCbzFile(private val file: File) { tempFile.renameTo(file) } finally { if (tempFile.exists()) { - tempFile.delete() + tempFile.deleteAwait() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 3742f007e..822da1c24 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -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 { - 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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 8e40277c9..b1fc2493e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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") } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index c745d2071..4ed70ba02 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -238,8 +238,8 @@ class ReaderActivity : BaseFullscreenActivity(), ) { 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(), 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 1948b6585..ed2613fb8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -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 -> diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index 25893f670..dd9c4235a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -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(), - SourceConfigListener { + SourceConfigListener, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener { - private lateinit var reorderHelper: ItemTouchHelper + private var reorderHelper: ItemTouchHelper? = null private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - reorderHelper = ItemTouchHelper(SourcesReorderCallback()) setHasOptionsMenu(true) } @@ -42,12 +41,14 @@ class SourcesSettingsFragment : BaseFragment(), 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(), } 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(), } 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, diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt index 52125df63..a908ccf4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt @@ -21,6 +21,7 @@ class SourcesSettingsViewModel( val items = MutableLiveData>(emptyList()) private val expandedGroups = HashSet() + 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, ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt index d04d22fcc..d580684be 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -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( SourceConfigDiffCallback(), sourceConfigHeaderDelegate(), sourceConfigGroupDelegate(listener), - sourceConfigItemDelegate(listener), + sourceConfigItemDelegate(listener, coil, lifecycleOwner), + sourceConfigDraggableItemDelegate(listener), + sourceConfigEmptySearchDelegate(), ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index df7435bac..aa8c9fb35 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -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( { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } @@ -38,11 +43,44 @@ fun sourceConfigGroupDelegate( } } -@SuppressLint("ClickableViewAccessibility") fun sourceConfigItemDelegate( listener: SourceConfigListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, ) = adapterDelegateViewBinding( - { 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( + { 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, - ) } -} \ No newline at end of file +} + +fun sourceConfigEmptySearchDelegate() = adapterDelegate( + R.layout.item_sources_empty +) { } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt index 370cca88d..8bab50c2a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt @@ -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() { 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 } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt index 965ea1171..dd998ddac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt index a57bae29c..b785a62d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt @@ -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() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index fdfb8f20d..b06b18d16 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt index 74e24e159..ccad1811d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt index 3f590235a..89d0a8862 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_favicon_fallback.xml b/app/src/main/res/drawable/ic_favicon_fallback.xml new file mode 100644 index 000000000..24996b554 --- /dev/null +++ b/app/src/main/res/drawable/ic_favicon_fallback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml index ffa9a68e5..a13432590 100644 --- a/app/src/main/res/layout/item_source_config.xml +++ b/app/src/main/res/layout/item_source_config.xml @@ -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"> + 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" /> - - + android:layout_height="wrap_content" + android:layout_marginEnd="?listPreferredItemPaddingEnd" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_source_config_draggable.xml b/app/src/main/res/layout/item_source_config_draggable.xml new file mode 100644 index 000000000..ffa9a68e5 --- /dev/null +++ b/app/src/main/res/layout/item_source_config_draggable.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_sources_empty.xml b/app/src/main/res/layout/item_sources_empty.xml new file mode 100644 index 000000000..3aad1bbe4 --- /dev/null +++ b/app/src/main/res/layout/item_sources_empty.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_sources.xml b/app/src/main/res/menu/opt_sources.xml index 35c7034be..5128fbe66 100644 --- a/app/src/main/res/menu/opt_sources.xml +++ b/app/src/main/res/menu/opt_sources.xml @@ -4,9 +4,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + android:id="@+id/action_search" + android:icon="@drawable/ic_search" + android:title="@string/search" + app:actionViewClass="androidx.appcompat.widget.SearchView" + app:showAsAction="ifRoom|collapseActionView" /> \ No newline at end of file