diff --git a/README.md b/README.md index a97f2a74c..599df4214 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Download APK directly from GitHub: * Notifications about new chapters with updates feed * Shikimori integration (manga tracking) * Password/fingerprint protect access to the app -* History and favourites synchronization across devices (coming soon) +* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices ### Screenshots diff --git a/app/build.gradle b/app/build.gradle index a7c55a360..8e65f9d41 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 502 - versionName '4.0.2' + versionCode 503 + versionName '4.0.3' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -85,15 +85,15 @@ afterEvaluate { } } dependencies { - implementation('com.github.KotatsuApp:kotatsu-parsers:a1441e7ed7') { + implementation('com.github.KotatsuApp:kotatsu-parsers:bf8a1f3db2') { exclude group: 'org.json', module: 'json' } implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.activity:activity-ktx:1.6.0' - implementation 'androidx.fragment:fragment-ktx:1.5.3' + implementation 'androidx.activity:activity-ktx:1.6.1' + implementation 'androidx.fragment:fragment-ktx:1.5.4' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-service:2.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3faa30c69..4ca5eaf80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ android:fullBackupOnly="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" @@ -108,8 +109,7 @@ + android:label="@string/manga_shelf"> @@ -127,18 +127,18 @@ + android:launchMode="singleTop" /> - + + : EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this) val isAmoled = settings.isAmoledTheme val isDynamic = settings.isDynamicTheme - // TODO support DialogWhenLarge theme when { isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled) isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt index 5960cb5e5..467fcab9a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt @@ -11,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode fun TagEntity.toMangaTag() = MangaTag( key = this.key, title = this.title.toTitleCase(), - source = MangaSource(this.source) ?: MangaSource.DUMMY, + source = MangaSource(this.source), ) fun Collection.toMangaTags() = mapToSet(TagEntity::toMangaTag) @@ -28,7 +28,7 @@ fun MangaEntity.toManga(tags: Set) = Manga( coverUrl = this.coverUrl, largeCoverUrl = this.largeCoverUrl, author = this.author, - source = MangaSource(this.source) ?: MangaSource.DUMMY, + source = MangaSource(this.source), tags = tags, ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index 27f73e19f..8ae3565e5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -6,9 +6,6 @@ import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.suspendCancellableCoroutine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity @@ -20,6 +17,9 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.isSuccess +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine class ExceptionResolver private constructor( private val activity: FragmentActivity?, @@ -49,6 +49,7 @@ class ExceptionResolver private constructor( openInBrowser(e.url) false } + else -> false } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt index 22c2319bf..341796c1e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -1,18 +1,17 @@ package org.koitharu.kotatsu.core.model -import java.util.* import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.toTitleCase +import java.util.Locale fun MangaSource.getLocaleTitle(): String? { val lc = Locale(locale ?: return null) return lc.getDisplayLanguage(lc).toTitleCase(lc) } -@Suppress("FunctionName") -fun MangaSource(name: String): MangaSource? { +fun MangaSource(name: String): MangaSource { MangaSource.values().forEach { if (it.name == name) return it } - return null -} \ No newline at end of file + return MangaSource.DUMMY +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/NetworkStateObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/NetworkStateObserver.kt index 6baefd34d..8450028e9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/os/NetworkStateObserver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/os/NetworkStateObserver.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.isNetworkAvailable import javax.inject.Inject @@ -37,6 +38,13 @@ class NetworkStateObserver @Inject constructor( } } + suspend fun awaitForConnection(): Unit { + if (value) { + return + } + first { it } + } + private fun observeImpl() = callbackFlow { val request = NetworkRequest.Builder().build() val callback = FlowNetworkCallback(this) diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaParser.kt similarity index 100% rename from app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt rename to app/src/main/java/org/koitharu/kotatsu/core/parser/MangaParser.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index 957b1c846..ee052cfdd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -14,7 +14,6 @@ import coil.network.HttpException import coil.request.Options import coil.size.Size import coil.size.pxOrElse -import java.net.HttpURLConnection import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -27,6 +26,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await +import java.net.HttpURLConnection private const val FALLBACK_SIZE = 9999 // largest icon @@ -150,7 +150,7 @@ class FaviconFetcher( override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { return if (data.scheme == URI_SCHEME_FAVICON) { - val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null + val mangaSource = MangaSource(data.schemeSpecificPart) FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory) } else { null diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 04ea9092a..378968757 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.observe @@ -46,17 +45,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val remoteMangaSources: Set get() = Collections.unmodifiableSet(remoteSources) - var shelfSections: Set + var shelfSections: List get() { - val raw = prefs.getStringSet(KEY_SHELF_SECTIONS, null) - if (raw == null) { - return EnumSet.allOf(ShelfSection::class.java) + val raw = prefs.getString(KEY_SHELF_SECTIONS, null) + val values = enumValues() + if (raw.isNullOrEmpty()) { + return values.toList() } - return raw.mapTo(EnumSet.noneOf(ShelfSection::class.java)) { ShelfSection.valueOf(it) } + return raw.split('|') + .mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) } + .distinct() } set(value) { - val raw = value.mapToSet { it.name } - prefs.edit { putStringSet(KEY_SHELF_SECTIONS, raw) } + val raw = value.joinToString("|") { it.ordinal.toString() } + prefs.edit { putString(KEY_SHELF_SECTIONS, raw) } } var listMode: ListMode @@ -353,7 +355,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_WEBTOON_ZOOM = "webtoon_zoom" - const val KEY_SHELF_SECTIONS = "shelf_sections" + const val KEY_SHELF_SECTIONS = "shelf_sections_2" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index d34e753ab..4a3dd8ed5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -115,4 +115,4 @@ class ZipOutput( closeEntry() return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 1967bbb2a..931d09ae9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -36,6 +35,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.referer @@ -219,10 +219,8 @@ class DownloadManager @AssistedInject constructor( val call = okHttp.newCall(request) val file = File(destination, tempFileName) val response = call.clone().await() - runInterruptible(Dispatchers.IO) { - file.outputStream().use { out -> - checkNotNull(response.body).byteStream().copyTo(out) - } + file.outputStream().use { out -> + checkNotNull(response.body).byteStream().copyToSuspending(out) } return file } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index 82bbead60..f8321b597 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -3,15 +3,17 @@ package org.koitharu.kotatsu.local.data import android.content.Context import com.tomclaw.cache.DiskLruCache import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.copyToSuspending +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.subdir +import org.koitharu.kotatsu.utils.ext.takeIfReadable import java.io.File import java.io.InputStream import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.utils.FileSize -import org.koitharu.kotatsu.utils.ext.longHashCode -import org.koitharu.kotatsu.utils.ext.subdir -import org.koitharu.kotatsu.utils.ext.takeIfReadable @Singleton class PagesCache @Inject constructor(@ApplicationContext context: Context) { @@ -26,42 +28,15 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { return lruCache.get(url)?.takeIfReadable() } - fun put(url: String, inputStream: InputStream): File { + suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { val file = File(cacheDir, url.longHashCode().toString()) - file.outputStream().use { out -> - inputStream.copyTo(out) - } - val res = lruCache.put(url, file) - file.delete() - return res - } - - fun put( - url: String, - inputStream: InputStream, - contentLength: Long, - progress: MutableStateFlow, - ): File { - val file = File(cacheDir, url.longHashCode().toString()) - file.outputStream().use { out -> - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - publishProgress(contentLength, bytesCopied, progress) - bytes = inputStream.read(buffer) + try { + file.outputStream().use { out -> + inputStream.copyToSuspending(out) } - } - val res = lruCache.put(url, file) - file.delete() - return res - } - - private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow) { - if (contentLength > 0) { - progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() + lruCache.put(url, file) + } finally { + file.delete() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt index 956d19676..d569af6c3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/DirMangaImporter.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile -import java.io.File import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import org.koitharu.kotatsu.local.data.LocalStorageManager @@ -14,8 +13,10 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.longOf +import java.io.File // TODO: Add support for chapters in cbz // https://github.com/KotatsuApp/Kotatsu/issues/31 @@ -62,6 +63,7 @@ class DirMangaImporter( file.isDirectory -> { addPages(output, file, path + "/" + file.name, state) } + file.isFile -> { val tempFile = file.asTempFile() if (!state.hasCover) { @@ -86,7 +88,7 @@ class DirMangaImporter( "Cannot open input stream for $uri" }.use { input -> file.outputStream().use { output -> - input.copyTo(output) + input.copyToSuspending(output) } } return file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt index a60d8e39e..fdf24abd1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/importer/ZipMangaImporter.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.local.domain.importer import android.net.Uri -import java.io.File -import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext @@ -11,7 +9,10 @@ import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.resolveName +import java.io.File +import java.io.IOException class ZipMangaImporter( storageManager: LocalStorageManager, @@ -27,10 +28,10 @@ class ZipMangaImporter( } val dest = File(getOutputDir(), name) runInterruptible { - contentResolver.openInputStream(uri)?.use { source -> - dest.outputStream().use { output -> - source.copyTo(output) - } + contentResolver.openInputStream(uri) + }?.use { source -> + dest.outputStream().use { output -> + source.copyToSuspending(output) } } ?: throw IOException("Cannot open input stream: $uri") localMangaRepository.getFromFile(dest) diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 0a036c51c..72fbd1a0f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.core.graphics.Insets import androidx.core.util.size import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding @@ -24,6 +25,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenResumed import androidx.transition.TransitionManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS @@ -129,6 +131,7 @@ class MainActivity : viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.counters.observe(this, ::onCountersChanged) viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged) + searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -274,6 +277,16 @@ class MainActivity : navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable) } + private fun onIncognitoModeChanged(isIncognito: Boolean) { + var options = binding.searchView.imeOptions + options = if (isIncognito) { + options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + binding.searchView.imeOptions = options + } + private fun onLoadingStateChanged(isLoading: Boolean) { binding.fab?.isEnabled = !isLoading } @@ -309,8 +322,13 @@ class MainActivity : private fun onFirstStart() { lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher when { - !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) - settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) + !settings.isSourcesSelected -> whenResumed { + OnboardDialogFragment.showWelcome(supportFragmentManager) + } + + settings.newSources.isNotEmpty() -> whenResumed { + NewSourcesDialogFragment.show(supportFragmentManager) + } } withContext(Dispatchers.Default) { TrackWorker.setup(applicationContext) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index d32bc4fc4..0dd6af652 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.withProgress import org.koitharu.kotatsu.utils.progress.ProgressDeferred import java.io.File import java.util.LinkedList @@ -66,7 +67,9 @@ class PageLoader @Inject constructor( override fun close() { loaderScope.cancel() - tasks.clear() + synchronized(tasks) { + tasks.clear() + } } fun isPrefetchApplicable(): Boolean { @@ -103,7 +106,9 @@ class PageLoader @Inject constructor( return task } task = loadPageAsyncImpl(page) - tasks[page.id] = task + synchronized(tasks) { + tasks[page.id] = task + } return task } @@ -135,7 +140,9 @@ class PageLoader @Inject constructor( while (prefetchQueue.isNotEmpty()) { val page = prefetchQueue.pollFirst() ?: return if (cache[page.url] == null) { - tasks[page.id] = loadPageAsyncImpl(page) + synchronized(tasks) { + tasks[page.id] = loadPageAsyncImpl(page) + } return } } @@ -173,9 +180,12 @@ class PageLoader @Inject constructor( val uri = Uri.parse(pageUrl) return if (uri.scheme == "cbz") { runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { + ZipFile(uri.schemeSpecificPart) + }.use { zip -> + runInterruptible(Dispatchers.IO) { + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry) + }.use { cache.put(pageUrl, it) } } @@ -194,10 +204,8 @@ class PageLoader @Inject constructor( val body = checkNotNull(response.body) { "Null response" } - runInterruptible(Dispatchers.IO) { - body.byteStream().use { - cache.put(pageUrl, it, body.contentLength(), progress) - } + body.withProgress(progress).byteStream().use { + cache.put(pageUrl, it) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 17b95bf05..4fdeeab85 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -6,10 +6,6 @@ import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultLauncher import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine @@ -20,6 +16,11 @@ import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.utils.ext.copyToSuspending +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume private const val MAX_FILENAME_LENGTH = 10 private const val EXTENSION_FALLBACK = "png" @@ -48,12 +49,12 @@ class PageSaveHelper @Inject constructor( } } runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination)?.use { output -> - pageFile.inputStream().use { input -> - input.copyTo(output) - } - } ?: throw IOException("Output stream is null") - } + contentResolver.openOutputStream(destination) + }?.use { output -> + pageFile.inputStream().use { input -> + input.copyToSuspending(output) + } + } ?: throw IOException("Output stream is null") return destination } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt index e8c9fcc9e..03380ff51 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -5,6 +5,7 @@ import androidx.annotation.CallSuper import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -13,11 +14,12 @@ abstract class BasePageHolder( protected val binding: B, loader: PageLoader, settings: ReaderSettings, + networkStateObserver: NetworkStateObserver, exceptionResolver: ExceptionResolver, ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { @Suppress("LeakingThis") - protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver) + protected val delegate = PageHolderDelegate(loader, settings, this, networkStateObserver, exceptionResolver) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) val context: Context diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt index 33456c7d3..1a914b41e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt @@ -4,17 +4,19 @@ import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.utils.ext.resetTransformations +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine @Suppress("LeakingThis") abstract class BaseReaderAdapter>( private val loader: PageLoader, private val readerSettings: ReaderSettings, + private val networkState: NetworkStateObserver, private val exceptionResolver: ExceptionResolver, ) : RecyclerView.Adapter() { @@ -56,9 +58,9 @@ abstract class BaseReaderAdapter>( final override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ): H = onCreateViewHolder(parent, loader, readerSettings, exceptionResolver) + ): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver) - suspend fun setItems(items: List) = suspendCoroutine { cont -> + suspend fun setItems(items: List) = suspendCoroutine { cont -> differ.submitList(items) { cont.resume(Unit) } @@ -68,6 +70,7 @@ abstract class BaseReaderAdapter>( parent: ViewGroup, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, ): H diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index fa5aaaa22..27431e75b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn @@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -26,6 +28,7 @@ class PageHolderDelegate( private val loader: PageLoader, private val readerSettings: ReaderSettings, private val callback: Callback, + private val networkState: NetworkStateObserver, private val exceptionResolver: ExceptionResolver, ) : DefaultOnImageEventListener, Observer { @@ -118,29 +121,35 @@ class PageHolderDelegate( } } - private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) { + private suspend fun doLoad(data: MangaPage, force: Boolean) { state = State.LOADING error = null callback.onLoadingStarted() try { val task = loader.loadPageAsync(data, force) - val progressObserver = observeProgress(this, task.progressAsFlow()) - val file = task.await() - progressObserver.cancel() - this@PageHolderDelegate.file = file + file = coroutineScope { + val progressObserver = observeProgress(this, task.progressAsFlow()) + val file = task.await() + progressObserver.cancel() + file + } state = State.LOADED - callback.onImageReady(file.toUri()) + callback.onImageReady(checkNotNull(file).toUri()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { state = State.ERROR error = e callback.onError(e) + if (e is IOException && !networkState.value) { + networkState.awaitForConnection() + retry(data) + } } } private fun observeProgress(scope: CoroutineScope, progress: Flow) = progress - .debounce(500) + .debounce(250) .onEach { callback.onProgressChanged((100 * it).toInt()) } .launchIn(scope) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt index f89930ec0..8e686ae81 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt @@ -6,6 +6,7 @@ import android.widget.FrameLayout import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -15,8 +16,9 @@ class ReversedPageHolder( binding: ItemPageBinding, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : PageHolder(binding, loader, settings, exceptionResolver) { +) : PageHolder(binding, loader, settings, networkState, exceptionResolver) { init { (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) @@ -35,6 +37,7 @@ class ReversedPageHolder( minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE resetScaleAndCenter() } + ZoomMode.FIT_HEIGHT -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minScale = height / sHeight.toFloat() @@ -43,6 +46,7 @@ class ReversedPageHolder( PointF(sWidth.toFloat(), sHeight / 2f), ) } + ZoomMode.FIT_WIDTH -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minScale = width / sWidth.toFloat() @@ -51,6 +55,7 @@ class ReversedPageHolder( PointF(sWidth / 2f, 0f), ) } + ZoomMode.KEEP_START -> { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE setScaleAndCenter( diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt index 46c1f1690..d68f39334 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.view.LayoutInflater import android.view.ViewGroup import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class ReversedPagesAdapter( loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, exceptionResolver) { +) : BaseReaderAdapter(loader, settings, networkState, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, ) = ReversedPageHolder( binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, settings = settings, + networkState = networkState, exceptionResolver = exceptionResolver, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt index 9a1c297f2..df4a739ac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt @@ -7,8 +7,8 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.children import dagger.hilt.android.AndroidEntryPoint -import kotlin.math.absoluteValue import kotlinx.coroutines.async +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader @@ -19,10 +19,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged import org.koitharu.kotatsu.utils.ext.recyclerView import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import javax.inject.Inject +import kotlin.math.absoluteValue @AndroidEntryPoint class ReversedReaderFragment : BaseReader() { + @Inject + lateinit var networkStateObserver: NetworkStateObserver + private var pagerAdapter: ReversedPagesAdapter? = null override fun onInflateView( @@ -33,7 +38,12 @@ class ReversedReaderFragment : BaseReader() { @SuppressLint("NotifyDataSetChanged") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) + pagerAdapter = ReversedPagesAdapter( + viewModel.pageLoader, + viewModel.readerSettings, + networkStateObserver, + exceptionResolver, + ) with(binding.pager) { adapter = pagerAdapter offscreenPageLimit = 2 @@ -44,8 +54,8 @@ class ReversedReaderFragment : BaseReader() { val transformer = if (it) ReversedPageAnimTransformer() else null binding.pager.setPageTransformer(transformer) if (transformer == null) { - binding.pager.recyclerView?.children?.forEach { - it.resetTransformations() + binding.pager.recyclerView?.children?.forEach { v -> + v.resetTransformations() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 713bfe306..b56163f54 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -10,6 +10,7 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -21,8 +22,9 @@ open class PageHolder( binding: ItemPageBinding, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : BasePageHolder(binding, loader, settings, exceptionResolver), +) : BasePageHolder(binding, loader, settings, networkState, exceptionResolver), View.OnClickListener { init { @@ -74,6 +76,7 @@ open class PageHolder( binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.resetScaleAndCenter() } + ZoomMode.FIT_HEIGHT -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() @@ -82,6 +85,7 @@ open class PageHolder( PointF(0f, binding.ssiv.sHeight / 2f), ) } + ZoomMode.FIT_WIDTH -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() @@ -90,6 +94,7 @@ open class PageHolder( PointF(binding.ssiv.sWidth / 2f, 0f), ) } + ZoomMode.KEEP_START -> { binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.setScaleAndCenter( diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index a7d526d95..889f5189f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -7,8 +7,8 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.children import dagger.hilt.android.AndroidEntryPoint -import kotlin.math.absoluteValue import kotlinx.coroutines.async +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader @@ -18,10 +18,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged import org.koitharu.kotatsu.utils.ext.recyclerView import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import javax.inject.Inject +import kotlin.math.absoluteValue @AndroidEntryPoint class PagerReaderFragment : BaseReader() { + @Inject + lateinit var networkStateObserver: NetworkStateObserver + private var pagesAdapter: PagesAdapter? = null override fun onInflateView( @@ -32,7 +37,12 @@ class PagerReaderFragment : BaseReader() { @SuppressLint("NotifyDataSetChanged") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagesAdapter = PagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) + pagesAdapter = PagesAdapter( + viewModel.pageLoader, + viewModel.readerSettings, + networkStateObserver, + exceptionResolver, + ) with(binding.pager) { adapter = pagesAdapter offscreenPageLimit = 2 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt index 57badce92..293ca6273 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.LayoutInflater import android.view.ViewGroup import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class PagesAdapter( loader: PageLoader, settings: ReaderSettings, + networkStateObserver: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, exceptionResolver) { +) : BaseReaderAdapter(loader, settings, networkStateObserver, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, ) = PageHolder( binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, settings = settings, + networkState = networkState, exceptionResolver = exceptionResolver, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt index ea76b62f8..6d92ff321 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.view.LayoutInflater import android.view.ViewGroup import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings @@ -12,13 +12,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class WebtoonAdapter( loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, exceptionResolver) { +) : BaseReaderAdapter(loader, settings, networkState, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, ) = WebtoonHolder( binding = ItemPageWebtoonBinding.inflate( @@ -28,6 +30,7 @@ class WebtoonAdapter( ), loader = loader, settings = settings, + networkState = networkState, exceptionResolver = exceptionResolver, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 264f21fe7..086081097 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -8,20 +8,26 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.utils.GoneOnInvisibleListener -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.hideCompat +import org.koitharu.kotatsu.utils.ext.ifZero +import org.koitharu.kotatsu.utils.ext.setProgressCompat +import org.koitharu.kotatsu.utils.ext.showCompat class WebtoonHolder( binding: ItemPageWebtoonBinding, loader: PageLoader, settings: ReaderSettings, + networkState: NetworkStateObserver, exceptionResolver: ExceptionResolver, -) : BasePageHolder(binding, loader, settings, exceptionResolver), +) : BasePageHolder(binding, loader, settings, networkState, exceptionResolver), View.OnClickListener { private var scrollToRestore = 0 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index 79a4e8927..7fe5fb1f4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async +import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader @@ -15,10 +16,14 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.utils.ext.findCenterViewPosition import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import javax.inject.Inject @AndroidEntryPoint class WebtoonReaderFragment : BaseReader() { + @Inject + lateinit var networkStateObserver: NetworkStateObserver + private val scrollInterpolator = AccelerateDecelerateInterpolator() private var webtoonAdapter: WebtoonAdapter? = null @@ -29,7 +34,12 @@ class WebtoonReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) + webtoonAdapter = WebtoonAdapter( + viewModel.pageLoader, + viewModel.readerSettings, + networkStateObserver, + exceptionResolver, + ) with(binding.recyclerView) { setHasFixedSize(true) adapter = webtoonAdapter diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt index eb7242065..6d14265be 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets +import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.updatePadding import androidx.fragment.app.commit import dagger.hilt.android.AndroidEntryPoint @@ -15,7 +16,6 @@ import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.utils.ext.showKeyboard -import kotlin.text.Typography.dagger @AndroidEntryPoint class SearchActivity : BaseActivity(), SearchView.OnQueryTextListener { @@ -32,6 +32,7 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery } val query = intent.getStringExtra(EXTRA_QUERY) supportActionBar?.setDisplayHomeAsUpEnabled(true) + searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) with(binding.searchView) { queryHint = getString(R.string.search_on_s, source.title) setOnQueryTextListener(this@SearchActivity) @@ -72,6 +73,16 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery override fun onQueryTextChange(newText: String?): Boolean = false + private fun onIncognitoModeChanged(isIncognito: Boolean) { + var options = binding.searchView.imeOptions + options = if (isIncognito) { + options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + binding.searchView.imeOptions = options + } + companion object { private const val EXTRA_SOURCE = "source" diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index 377b2c17c..31edd3481 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -3,17 +3,28 @@ package org.koitharu.kotatsu.search.ui.suggestion import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem +import javax.inject.Inject private const val DEBOUNCE_TIMEOUT = 500L private const val MAX_MANGA_ITEMS = 6 @@ -30,6 +41,12 @@ class SearchSuggestionViewModel @Inject constructor( private val query = MutableStateFlow("") private var suggestionJob: Job? = null + val isIncognitoModeEnabled = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_INCOGNITO_MODE, + valueProducer = { isIncognitoModeEnabled }, + ) + val suggestion = MutableLiveData>() init { @@ -41,7 +58,11 @@ class SearchSuggestionViewModel @Inject constructor( } fun saveQuery(query: String) { - repository.saveSearchQuery(query) + launchJob(Dispatchers.Default) { + if (!settings.isIncognitoModeEnabled) { + repository.saveSearchQuery(query) + } + } } fun clearSearchHistory() { diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index 1672657fd..82a7400b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -30,7 +30,7 @@ class OnboardViewModel @Inject constructor( init { if (settings.isSourcesSelected) { - selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale }) + selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale }) } else { val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> x.language diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfMenuProvider.kt index 8b6f5dfeb..d00601406 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfMenuProvider.kt @@ -10,7 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.local.ui.ImportDialogFragment -import org.koitharu.kotatsu.shelf.ui.config.ShelfConfigSheet +import org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet import org.koitharu.kotatsu.utils.ext.startOfDay import java.util.Date @@ -45,7 +45,7 @@ class ShelfMenuProvider( } R.id.action_categories -> { - ShelfConfigSheet.show(fragmentManager) + context.startActivity(ShelfSettingsActivity.newIntent(context)) true } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt index 53ca50f88..a56d848ea 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt @@ -135,22 +135,18 @@ class ShelfViewModel @Inject constructor( private suspend fun mapList( content: ShelfContent, - sections: Set, + sections: List, isNetworkAvailable: Boolean, ): List { val result = ArrayList(content.favourites.keys.size + 3) if (isNetworkAvailable) { - if (content.history.isNotEmpty() && ShelfSection.HISTORY in sections) { - mapHistory(result, content.history) - } - if (content.local.isNotEmpty() && ShelfSection.LOCAL in sections) { - mapLocal(result, content.local) - } - if (content.updated.isNotEmpty() && ShelfSection.UPDATED in sections) { - mapUpdated(result, content.updated) - } - if (content.favourites.isNotEmpty() && ShelfSection.FAVORITES in sections) { - mapFavourites(result, content.favourites) + for (section in sections) { + when (section) { + ShelfSection.HISTORY -> mapHistory(result, content.history) + ShelfSection.LOCAL -> mapLocal(result, content.local) + ShelfSection.UPDATED -> mapUpdated(result, content.updated) + ShelfSection.FAVORITES -> mapFavourites(result, content.favourites) + } } } else { result += EmptyHint( @@ -159,12 +155,17 @@ class ShelfViewModel @Inject constructor( textSecondary = R.string.network_unavailable_hint, actionStringRes = R.string.manage, ) - val offlineHistory = content.history.filter { it.manga.source == MangaSource.LOCAL } - if (offlineHistory.isNotEmpty() && ShelfSection.HISTORY in sections) { - mapHistory(result, offlineHistory) - } - if (content.local.isNotEmpty() && ShelfSection.LOCAL in sections) { - mapLocal(result, content.local) + for (section in sections) { + when (section) { + ShelfSection.HISTORY -> mapHistory( + result, + content.history.filter { it.manga.source == MangaSource.LOCAL }, + ) + + ShelfSection.LOCAL -> mapLocal(result, content.local) + ShelfSection.UPDATED -> Unit + ShelfSection.FAVORITES -> Unit + } } } if (result.isEmpty()) { @@ -187,6 +188,9 @@ class ShelfViewModel @Inject constructor( destination: MutableList, list: List, ) { + if (list.isEmpty()) { + return + } val showPercent = settings.isReadingIndicatorsEnabled destination += ShelfSectionModel.History( items = list.map { (manga, history) -> @@ -202,6 +206,9 @@ class ShelfViewModel @Inject constructor( destination: MutableList, updated: Map, ) { + if (updated.isEmpty()) { + return + } val showPercent = settings.isReadingIndicatorsEnabled destination += ShelfSectionModel.Updated( items = updated.map { (manga, counter) -> @@ -216,6 +223,9 @@ class ShelfViewModel @Inject constructor( destination: MutableList, local: List, ) { + if (local.isEmpty()) { + return + } destination += ShelfSectionModel.Local( items = local.toUi(ListMode.GRID, this), showAllButtonText = R.string.show_all, @@ -226,6 +236,9 @@ class ShelfViewModel @Inject constructor( destination: MutableList, favourites: Map>, ) { + if (favourites.isEmpty()) { + return + } for ((category, list) in favourites) { if (list.isNotEmpty()) { destination += ShelfSectionModel.Favourites( diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAD.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAD.kt deleted file mode 100644 index fae0f67f0..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAD.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.shelf.ui.config - -import androidx.core.view.updatePaddingRelative -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding -import org.koitharu.kotatsu.shelf.domain.ShelfSection - -fun shelfSectionAD( - listener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, -) { - - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) - itemView.setOnClickListener(eventListener) - - bind { - binding.root.setText(item.section.titleResId) - binding.root.isChecked = item.isChecked - } -} - -fun shelfCategoryAD( - listener: OnListItemClickListener, -) = - adapterDelegateViewBinding( - { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, - ) { - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) - itemView.setOnClickListener(eventListener) - binding.root.updatePaddingRelative( - start = binding.root.paddingStart * 2, - end = binding.root.paddingStart, - ) - - bind { - binding.root.text = item.title - binding.root.isChecked = item.isChecked - } - } - -private val ShelfSection.titleResId: Int - get() = when (this) { - ShelfSection.HISTORY -> R.string.history - ShelfSection.LOCAL -> R.string.local_storage - ShelfSection.UPDATED -> R.string.updated - ShelfSection.FAVORITES -> R.string.favourites - } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAdapter.kt deleted file mode 100644 index 7b8417830..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigAdapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.shelf.ui.config - -import androidx.recyclerview.widget.DiffUtil -import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener - -class ShelfConfigAdapter( - listener: OnListItemClickListener, -) : AsyncListDifferDelegationAdapter(DiffCallback()) { - - init { - delegatesManager.addDelegate(shelfCategoryAD(listener)) - .addDelegate(shelfSectionAD(listener)) - } - - class DiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: ShelfConfigModel, newItem: ShelfConfigModel): Boolean { - return when { - oldItem is ShelfConfigModel.Section && newItem is ShelfConfigModel.Section -> { - oldItem.section == newItem.section - } - - oldItem is ShelfConfigModel.FavouriteCategory && newItem is ShelfConfigModel.FavouriteCategory -> { - oldItem.id == newItem.id - } - - else -> false - } - } - - override fun areContentsTheSame(oldItem: ShelfConfigModel, newItem: ShelfConfigModel): Boolean { - return oldItem == newItem - } - - override fun getChangePayload(oldItem: ShelfConfigModel, newItem: ShelfConfigModel): Any? { - return if (oldItem.isChecked == newItem.isChecked) { - super.getChangePayload(oldItem, newItem) - } else Unit - } - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigSheet.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigSheet.kt deleted file mode 100644 index 24fa59482..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigSheet.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.shelf.ui.config - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.BaseBottomSheet -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.SheetBaseBinding - -@AndroidEntryPoint -class ShelfConfigSheet : - BaseBottomSheet(), - OnListItemClickListener, - View.OnClickListener { - - private val viewModel by viewModels() - - override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding { - return SheetBaseBinding.inflate(inflater, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.headerBar.setTitle(R.string.settings) - binding.buttonDone.isVisible = true - binding.buttonDone.setOnClickListener(this) - val adapter = ShelfConfigAdapter(this) - binding.recyclerView.adapter = adapter - - viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } - } - - override fun onItemClick(item: ShelfConfigModel, view: View) { - viewModel.toggleItem(item) - } - - override fun onClick(v: View?) { - dismiss() - } - - companion object { - - private const val TAG = "ShelfCategoriesConfigSheet" - - fun show(fm: FragmentManager) = ShelfConfigSheet().show(fm, TAG) - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigViewModel.kt deleted file mode 100644 index bd80a7976..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.shelf.ui.config - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.combine -import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.shelf.domain.ShelfSection -import org.koitharu.kotatsu.utils.asFlowLiveData -import javax.inject.Inject - -@HiltViewModel -class ShelfConfigViewModel @Inject constructor( - private val favouritesRepository: FavouritesRepository, - private val settings: AppSettings, -) : BaseViewModel() { - - val content = combine( - settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections }, - favouritesRepository.observeCategories(), - ) { sections, categories -> - buildList(sections, categories) - }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) - - private var updateJob: Job? = null - - fun toggleItem(item: ShelfConfigModel) { - val prevJob = updateJob - updateJob = launchJob(Dispatchers.Default) { - prevJob?.join() - when (item) { - is ShelfConfigModel.FavouriteCategory -> { - favouritesRepository.updateCategory(item.id, !item.isChecked) - } - - is ShelfConfigModel.Section -> { - val sections = settings.shelfSections - settings.shelfSections = if (item.isChecked) { - if (sections.size > 1) { - sections - item.section - } else { - return@launchJob - } - } else { - sections + item.section - } - } - } - } - } - - private fun buildList(sections: Set, categories: List): List { - val result = ArrayList() - for (section in ShelfSection.values()) { - val isEnabled = section in sections - result.add(ShelfConfigModel.Section(section, isEnabled)) - if (section == ShelfSection.FAVORITES && isEnabled) { - categories.mapTo(result) { - ShelfConfigModel.FavouriteCategory( - id = it.id, - title = it.title, - isChecked = it.isVisibleInLibrary, - ) - } - } - } - return result - } -} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt new file mode 100644 index 000000000..ef28b06a5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsActivity.kt @@ -0,0 +1,101 @@ +package org.koitharu.kotatsu.shelf.ui.config + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding + +@AndroidEntryPoint +class ShelfSettingsActivity : + BaseActivity(), + View.OnClickListener, ShelfSettingsListener { + + private val viewModel by viewModels() + private lateinit var reorderHelper: ItemTouchHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityShelfSettingsBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material) + } + binding.buttonDone.setOnClickListener(this) + val settingsAdapter = ShelfSettingsAdapter(this) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = settingsAdapter + reorderHelper = ItemTouchHelper(SectionsReorderCallback()).also { + it.attachToRecyclerView(this) + } + } + + + viewModel.content.observe(this) { settingsAdapter.items = it } + } + + override fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean) { + viewModel.setItemChecked(item, isChecked) + } + + override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) { + reorderHelper.startDrag(holder) + } + + override fun onClick(v: View?) { + finishAfterTransition() + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + binding.recyclerView.updatePadding( + bottom = insets.bottom, + ) + binding.toolbar.updateLayoutParams { + topMargin = insets.top + } + } + + private inner class SectionsReorderCallback : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.DOWN or ItemTouchHelper.UP, + 0, + ) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSections( + viewHolder.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = current.itemViewType == target.itemViewType + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun isLongPressDragEnabled() = false + } + + companion object { + + fun newIntent(context: Context) = Intent(context, ShelfSettingsActivity::class.java) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapter.kt new file mode 100644 index 000000000..19aaa81cf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapter.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.shelf.ui.config + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter + +class ShelfSettingsAdapter( + listener: ShelfSettingsListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(shelfCategoryAD(listener)) + .addDelegate(shelfSectionAD(listener)) + } + + class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean { + return when { + oldItem is ShelfSettingsItemModel.Section && newItem is ShelfSettingsItemModel.Section -> { + oldItem.section == newItem.section + } + + oldItem is ShelfSettingsItemModel.FavouriteCategory && newItem is ShelfSettingsItemModel.FavouriteCategory -> { + oldItem.id == newItem.id + } + + else -> false + } + } + + override fun areContentsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Any? { + return if (oldItem.isChecked == newItem.isChecked) { + super.getChangePayload(oldItem, newItem) + } else Unit + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt new file mode 100644 index 000000000..973391190 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsAdapterDelegates.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.shelf.ui.config + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import android.widget.CompoundButton +import androidx.core.view.updatePaddingRelative +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding +import org.koitharu.kotatsu.databinding.ItemShelfSectionDraggableBinding +import org.koitharu.kotatsu.shelf.domain.ShelfSection + +@SuppressLint("ClickableViewAccessibility") +fun shelfSectionAD( + listener: ShelfSettingsListener, +) = + adapterDelegateViewBinding( + { layoutInflater, parent -> ItemShelfSectionDraggableBinding.inflate(layoutInflater, parent, false) }, + ) { + + val eventListener = object : + View.OnTouchListener, + CompoundButton.OnCheckedChangeListener { + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + return if (event.actionMasked == MotionEvent.ACTION_DOWN) { + listener.onDragHandleTouch(this@adapterDelegateViewBinding) + true + } else { + false + } + } + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + listener.onItemCheckedChanged(item, isChecked) + } + } + + binding.switchToggle.setOnCheckedChangeListener(eventListener) + binding.imageViewHandle.setOnTouchListener(eventListener) + + bind { + binding.textViewTitle.setText(item.section.titleResId) + binding.switchToggle.isChecked = item.isChecked + } + } + +fun shelfCategoryAD( + listener: ShelfSettingsListener, +) = + adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, + ) { + itemView.setOnClickListener { + listener.onItemCheckedChanged(item, !item.isChecked) + } + binding.root.updatePaddingRelative( + start = binding.root.paddingStart * 2, + end = binding.root.paddingStart, + ) + + bind { + binding.root.text = item.title + binding.root.isChecked = item.isChecked + } + } + +private val ShelfSection.titleResId: Int + get() = when (this) { + ShelfSection.HISTORY -> R.string.history + ShelfSection.LOCAL -> R.string.local_storage + ShelfSection.UPDATED -> R.string.updated + ShelfSection.FAVORITES -> R.string.favourites + } diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt similarity index 91% rename from app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigModel.kt rename to app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt index 996840a06..e75f329de 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfConfigModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsItemModel.kt @@ -3,14 +3,14 @@ package org.koitharu.kotatsu.shelf.ui.config import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.shelf.domain.ShelfSection -sealed interface ShelfConfigModel : ListModel { +sealed interface ShelfSettingsItemModel : ListModel { val isChecked: Boolean class Section( val section: ShelfSection, override val isChecked: Boolean, - ) : ShelfConfigModel { + ) : ShelfSettingsItemModel { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -35,7 +35,7 @@ sealed interface ShelfConfigModel : ListModel { val id: Long, val title: String, override val isChecked: Boolean, - ) : ShelfConfigModel { + ) : ShelfSettingsItemModel { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsListener.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsListener.kt new file mode 100644 index 000000000..b8fa749a1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsListener.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.shelf.ui.config + +import androidx.recyclerview.widget.RecyclerView + +interface ShelfSettingsListener { + + fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean) + + fun onDragHandleTouch(holder: RecyclerView.ViewHolder) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt new file mode 100644 index 000000000..15323d9b8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/config/ShelfSettingsViewModel.kt @@ -0,0 +1,101 @@ +package org.koitharu.kotatsu.shelf.ui.config + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.shelf.domain.ShelfSection +import org.koitharu.kotatsu.utils.asFlowLiveData +import org.koitharu.kotatsu.utils.ext.move +import javax.inject.Inject + +@HiltViewModel +class ShelfSettingsViewModel @Inject constructor( + private val favouritesRepository: FavouritesRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val content = combine( + settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections }, + favouritesRepository.observeCategories(), + ) { sections, categories -> + buildList(sections, categories) + }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + + private var updateJob: Job? = null + + fun setItemChecked(item: ShelfSettingsItemModel, isChecked: Boolean) { + val prevJob = updateJob + updateJob = launchJob(Dispatchers.Default) { + prevJob?.join() + when (item) { + is ShelfSettingsItemModel.FavouriteCategory -> { + favouritesRepository.updateCategory(item.id, isChecked) + } + + is ShelfSettingsItemModel.Section -> { + val sections = settings.shelfSections + settings.shelfSections = if (isChecked) { + sections + item.section + } else { + if (sections.size > 1) { + sections - item.section + } else { + return@launchJob + } + } + } + } + } + } + + fun reorderSections(oldPos: Int, newPos: Int): Boolean { + val snapshot = content.value?.toMutableList() ?: return false + snapshot.move(oldPos, newPos) + settings.shelfSections = snapshot.sections() + return true + } + + private fun buildList( + sections: List, + categories: List + ): List { + val result = ArrayList() + val sectionsList = ShelfSection.values().toMutableList() + for (section in sections) { + sectionsList.remove(section) + result.addSection(section, true, categories) + } + for (section in sectionsList) { + result.addSection(section, false, categories) + } + return result + } + + private fun MutableList.addSection( + section: ShelfSection, + isEnabled: Boolean, + favouriteCategories: List, + ) { + add(ShelfSettingsItemModel.Section(section, isEnabled)) + if (isEnabled && section == ShelfSection.FAVORITES) { + favouriteCategories.mapTo(this) { + ShelfSettingsItemModel.FavouriteCategory( + id = it.id, + title = it.title, + isChecked = it.isVisibleInLibrary, + ) + } + } + } + + private fun List.sections(): List { + return mapNotNull { (it as? ShelfSettingsItemModel.Section)?.takeIf { x -> x.isChecked }?.section } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt new file mode 100644 index 000000000..eb0e0de12 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IO.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.utils.ext + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import org.koitharu.kotatsu.utils.progress.ProgressResponseBody +import java.io.InputStream +import java.io.OutputStream + +suspend fun InputStream.copyToSuspending( + out: OutputStream, + bufferSize: Int = DEFAULT_BUFFER_SIZE +): Long = withContext(Dispatchers.IO) { + val job = currentCoroutineContext()[Job] + var bytesCopied: Long = 0 + val buffer = ByteArray(bufferSize) + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + job?.ensureActive() + bytes = read(buffer) + job?.ensureActive() + } + bytesCopied +} + +fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { + return ProgressResponseBody(this, progressState) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressResponseBody.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressResponseBody.kt new file mode 100644 index 000000000..20327a272 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressResponseBody.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.utils.progress + +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer + +class ProgressResponseBody( + private val delegate: ResponseBody, + private val progressState: MutableStateFlow, +) : ResponseBody() { + + private var bufferedSource: BufferedSource? = null + + override fun close() { + super.close() + delegate.close() + } + + override fun contentLength(): Long = delegate.contentLength() + + override fun contentType(): MediaType? = delegate.contentType() + + override fun source(): BufferedSource { + return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { + bufferedSource = it + } + } + + private class ProgressSource( + delegate: Source, + private val contentLength: Long, + private val progressState: MutableStateFlow, + ) : ForwardingSource(delegate) { + + private var totalBytesRead = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + if (contentLength > 0) { + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat() + } + return bytesRead + } + } +} diff --git a/app/src/main/res/layout/activity_shelf_settings.xml b/app/src/main/res/layout/activity_shelf_settings.xml new file mode 100644 index 000000000..a4c9781a8 --- /dev/null +++ b/app/src/main/res/layout/activity_shelf_settings.xml @@ -0,0 +1,36 @@ + + + + + +