diff --git a/app/build.gradle b/app/build.gradle index 01f101ce9..b65af9ece 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 34 - versionCode = 624 - versionName = '6.7.2' + versionCode = 626 + versionName = '6.7.4' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9') { + implementation('com.github.KotatsuApp:kotatsu-parsers:103f578c61') { exclude group: 'org.json', module: 'json' } @@ -126,13 +126,13 @@ dependencies { implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - implementation 'com.google.dagger:hilt-android:2.50' - kapt 'com.google.dagger:hilt-compiler:2.50' - implementation 'androidx.hilt:hilt-work:1.1.0' - kapt 'androidx.hilt:hilt-compiler:1.1.0' + implementation 'com.google.dagger:hilt-android:2.51' + kapt 'com.google.dagger:hilt-compiler:2.51' + implementation 'androidx.hilt:hilt-work:1.2.0' + kapt 'androidx.hilt:hilt-compiler:1.2.0' - implementation 'io.coil-kt:coil-base:2.5.0' - implementation 'io.coil-kt:coil-svg:2.5.0' + implementation 'io.coil-kt:coil-base:2.6.0' + implementation 'io.coil-kt:coil-svg:2.6.0' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'io.noties.markwon:core:4.6.2' @@ -160,6 +160,6 @@ dependencies { androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1' - androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50' - kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50' + androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51' + kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51' } diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt index bbc70b8db..24c33b620 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt +++ b/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt @@ -57,6 +57,7 @@ class AppShortcutManagerTest { page = 4, scroll = 2, percent = 0.3f, + force = false, ) awaitUpdate() diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt index 40e97ea7c..f01cc9521 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt +++ b/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt @@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,6 +63,7 @@ class AppBackupAgentTest { page = 3, scroll = 40, percent = 0.2f, + force = false, ) val history = checkNotNull(historyRepository.getOne(SampleData.manga)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt index af49d1dcd..15c03582d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -14,7 +14,6 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.network.UserAgents import com.google.android.material.R as materialR @@ -26,7 +25,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { + if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { return } supportActionBar?.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt index caf926f30..179ae74c8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.network.UserAgents import javax.inject.Inject @@ -45,13 +44,7 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { - setContentView( - ActivityBrowserBinding.inflate( - layoutInflater, - ), - ) - }) { + if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { return } supportActionBar?.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 49ce3cde9..525725ab6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -277,6 +277,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isDownloadsWiFiOnly: Boolean get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) + val preferredDownloadFormat: DownloadFormat + get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC) + var isSuggestionsEnabled: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS, false) set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } @@ -433,7 +436,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { fun getPagesSaveDir(context: Context): DocumentFile? = prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let { - DocumentFile.fromTreeUri(context, it) + DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() } } fun setPagesSaveDir(uri: Uri?) { @@ -555,6 +558,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_MAL = "mal" const val KEY_KITSU = "kitsu" const val KEY_DOWNLOADS_WIFI = "downloads_wifi" + const val KEY_DOWNLOADS_FORMAT = "downloads_format" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_DOH = "doh" const val KEY_EXIT_CONFIRM = "exit_confirm" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt new file mode 100644 index 000000000..72acf3640 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/DownloadFormat.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.prefs + +enum class DownloadFormat { + + AUTOMATIC, + SINGLE_CBZ, + MULTIPLE_CBZ, +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt index bb27f8a59..7e889dd46 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.view.KeyEvent import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode @@ -29,6 +30,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable @Suppress("LeakingThis") abstract class BaseActivity : @@ -164,6 +166,21 @@ abstract class BaseActivity : intent?.putExtra(EXTRA_DATA, intent.data) } + protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean { + return try { + setContentView(viewBindingProducer()) + true + } catch (e: Exception) { + if (e.isWebViewUnavailable()) { + Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show() + finishAfterTransition() + false + } else { + throw e + } + } + } + companion object { const val EXTRA_DATA = "data" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt index 3199138e4..f5acf5d36 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt @@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor( return this } + fun setNeutralButton( + @StringRes textId: Int, + listener: DialogInterface.OnClickListener, + ): Builder { + delegate.setNeutralButton(textId, listener) + return this + } + fun setCancelable(isCancelable: Boolean): Builder { delegate.setCancelable(isCancelable) return this diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt index 492ee9f97..24c4b9461 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt @@ -16,6 +16,7 @@ import androidx.core.graphics.ColorUtils import androidx.core.graphics.withClip import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.Colors import kotlin.math.absoluteValue class FaviconDrawable( @@ -44,7 +45,7 @@ class FaviconDrawable( } paint.textAlign = Paint.Align.CENTER paint.isFakeBoldText = true - colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground) + colorForeground = MaterialColors.harmonize(Colors.random(name), colorBackground) } override fun draw(canvas: Canvas) { @@ -104,9 +105,4 @@ class FaviconDrawable( paint.getTextBounds(text, 0, text.length, tempRect) return testTextSize * width / tempRect.width() } - - private fun colorOfString(str: String): Int { - val hue = (str.hashCode() % 360).absoluteValue.toFloat() - return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt new file mode 100644 index 000000000..3ad46e935 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Colors.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.core.util + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import com.google.android.material.R +import com.google.android.material.color.MaterialColors +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import kotlin.math.absoluteValue + +object Colors { + + @ColorInt + fun segmentColor(context: Context, @AttrRes resId: Int): Int { + val colorHex = String.format("%06x", context.getThemeColor(resId)) + val hue = getHue(colorHex) + val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) + val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) + return MaterialColors.harmonize(color, backgroundColor) + } + + fun random(seed: Any): Int { + val hue = (seed.hashCode() % 360).absoluteValue.toFloat() + return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) + } + + private fun getHue(hex: String): Float { + val r = (hex.substring(0, 2).toInt(16)).toFloat() + val g = (hex.substring(2, 4).toInt(16)).toFloat() + val b = (hex.substring(4, 6).toInt(16)).toFloat() + + var hue = 0F + if ((r >= g) && (g >= b)) { + hue = 60 * (g - b) / (r - b) + } else if ((g > r) && (r >= b)) { + hue = 60 * (2 - (r - b) / (g - b)) + } + return hue + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index b237755a0..3e1c622a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -27,7 +27,6 @@ import android.provider.Settings import android.view.View import android.view.ViewPropertyAnimator import android.view.Window -import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IntegerRes import androidx.annotation.WorkerThread @@ -216,21 +215,6 @@ fun Context.findActivity(): Activity? = when (this) { else -> null } -inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean { - return try { - block() - true - } catch (e: Exception) { - if (e.isWebViewUnavailable()) { - Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show() - finishAfterTransition() - false - } else { - throw e - } - } -} - fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index 77afae296..63518ad17 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -19,6 +19,7 @@ import java.nio.file.attribute.BasicFileAttributes import java.util.zip.ZipEntry import java.util.zip.ZipFile import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.PathWalkOption import kotlin.io.path.readAttributes import kotlin.io.path.walk @@ -72,7 +73,7 @@ fun ContentResolver.resolveName(uri: Uri): String? { } suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { - walkCompat().sumOf { it.length() } + walkCompat(includeDirectories = false).sumOf { it.length() } } fun File.children() = FileSequence(this) @@ -87,10 +88,16 @@ val File.creationTime } @OptIn(ExperimentalPathApi::class) -fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { +fun File.walkCompat(includeDirectories: Boolean): Sequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Use lazy loading on Android 8.0 and later - toPath().walk().map { it.toFile() } + val walk = if (includeDirectories) { + toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES) + } else { + toPath().walk() + } + walk.map { it.toFile() } } else { // Directories are excluded by default in Path.walk(), so do it here as well - walk().filter { it.isFile } + val walk = walk() + if (includeDirectories) walk else walk.filter { it.isFile } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 21e6e838e..aeb3de5c8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.util.ext import android.content.ActivityNotFoundException import android.content.res.Resources -import android.util.AndroidRuntimeException import androidx.annotation.DrawableRes import androidx.collection.arraySetOf import coil.network.HttpException @@ -115,8 +114,8 @@ private val reportableExceptions = arraySetOf>( ) fun Throwable.isWebViewUnavailable(): Boolean { - return (this is AndroidRuntimeException && message?.contains("WebView") == true) || - cause?.isWebViewUnavailable() == true + val trace = stackTraceToString() + return trace.contains("android.webkit.WebView.") } @Suppress("FunctionName") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 2533b0874..d1aa92a2b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -62,6 +62,7 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver +import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -102,6 +103,7 @@ class DetailsFragment : binding.buttonScrobblingMore.setOnClickListener(this) binding.buttonRelatedMore.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this) + binding.infoLayout.textViewSize.setOnClickListener(this) binding.textViewDescription.addOnLayoutChangeListener(this) binding.textViewDescription.viewTreeObserver.addOnDrawListener(this) binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() @@ -324,6 +326,10 @@ class DetailsFragment : ) } + R.id.textView_size -> { + LocalInfoDialog.show(parentFragmentManager, manga) + } + R.id.imageView_cover -> { startActivity( ImageActivity.newIntent( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index e9f6d59a2..d79a9fb73 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -320,6 +320,7 @@ class DetailsViewModel @Inject constructor( page = 0, scroll = 0, percent = percent, + force = true, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt index df456252f..112382d2f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD +import org.koitharu.kotatsu.settings.SettingsActivity class DownloadDialogHelper( private val host: View, @@ -57,6 +58,9 @@ class DownloadDialogHelper( .setCancelable(true) .setTitle(R.string.download) .setNegativeButton(android.R.string.cancel) + .setNeutralButton(R.string.settings) { _, _ -> + host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context)) + } .setItems(options) .create() .also { it.show() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 93573ab75..fb0fea709 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -91,6 +91,7 @@ class DownloadWorker @AssistedInject constructor( private val localMangaRepository: LocalMangaRepository, private val mangaDataRepository: MangaDataRepository, private val mangaRepositoryFactory: MangaRepository.Factory, + private val settings: AppSettings, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { @@ -182,7 +183,7 @@ class DownloadWorker @AssistedInject constructor( } val repo = mangaRepositoryFactory.create(manga.source) val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = LocalMangaOutput.getOrCreate(destination, mangaDetails) + output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat) val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (coverUrl.isNotEmpty()) { downloadFile(coverUrl, destination, repo.source).let { file -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 6ec61efcd..884689c9a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -57,7 +57,7 @@ class MangaSourcesRepository @Inject constructor( observeIsNsfwDisabled(), dao.observeEnabled(SourcesSortOrder.MANUAL), ) { skipNsfw, sources -> - sources.count { skipNsfw || !MangaSource(it.source).isNsfw() } + sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() } }.distinctUntilChanged() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index c103fb506..516d37e72 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -90,8 +90,8 @@ class HistoryRepository @Inject constructor( .distinctUntilChanged() } - suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) { - if (shouldSkip(manga)) { + suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float, force: Boolean) { + if (!force && shouldSkip(manga)) { return } assert(manga.chapters != null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt index a0af9d1a8..2af13e3fc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt @@ -24,6 +24,7 @@ class HistoryUpdateUseCase @Inject constructor( page = readerState.page, scroll = readerState.scroll, percent = percent, + force = false, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MarkAsReadUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MarkAsReadUseCase.kt index cd24dbfc0..49dbb548c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MarkAsReadUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/MarkAsReadUseCase.kt @@ -30,6 +30,7 @@ class MarkAsReadUseCase @Inject constructor( page = pages.lastIndex, scroll = 0, percent = 1f, + force = true, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt index b9f2eebb4..3539e8ca7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -18,3 +18,5 @@ fun File.hasCbzExtension() = isCbzExtension(extension) fun Uri.isZipUri() = scheme.let { it == URI_SCHEME_ZIP || it == "cbz" || it == "zip" } + +fun Uri.isFileUri() = scheme == "file" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt index dcf416efc..3781a7c4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt @@ -5,6 +5,7 @@ import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.util.AlphanumComparator +import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.toListSorted @@ -100,8 +101,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { val file = chapter.url.toUri().toFile() if (file.isDirectory) { - file.walkCompat() - .filter { hasImageExtension(it) } + file.children() + .filter { it.isFile && hasImageExtension(it) } .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .map { val pageUri = it.toUri().toString() @@ -129,14 +130,16 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { private fun String.toHumanReadable() = replace("_", " ").toCamelCase() - private fun getChaptersFiles() = root.walkCompat() - .filter { it.hasCbzExtension() } + private fun getChaptersFiles() = root.walkCompat(includeDirectories = true) + .filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() } .associateByTo(TreeMap(AlphanumComparator())) { it.name } private fun findFirstImageEntry(): String? { - return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() + return root.walkCompat(includeDirectories = false) + .firstOrNull { hasImageExtension(it) }?.toUri()?.toString() ?: run { - val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null + val cbz = root.walkCompat(includeDirectories = false) + .firstOrNull { it.hasCbzExtension() } ?: return null ZipFile(cbz).use { zip -> zip.entries().asSequence() .firstOrNull { !it.isDirectory && hasImageExtension(it.name) } @@ -148,4 +151,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { private fun fileUri(base: File, name: String): String { return File(base, name).toUri().toString() } + + private fun File.isChapterDirectory(): Boolean { + return isDirectory && children().any { hasImageExtension(it) } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index 1f6cacc8c..b94c04ddf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okhttp3.internal.format import okio.Closeable +import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.parsers.model.Manga @@ -35,22 +37,32 @@ sealed class LocalMangaOutput( const val SUFFIX_TMP = ".tmp" private val mutex = Mutex() - suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) { - val preferSingleCbz = manga.chapters.let { - it != null && it.size <= 3 + suspend fun getOrCreate( + root: File, + manga: Manga, + format: DownloadFormat, + ): LocalMangaOutput = withContext(Dispatchers.IO) { + val targetFormat = if (format == DownloadFormat.AUTOMATIC) { + if (manga.chapters.let { it != null && it.size <= 3 }) { + DownloadFormat.SINGLE_CBZ + } else { + DownloadFormat.MULTIPLE_CBZ + } + } else { + format } - checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz)) + checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat)) } suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) { - getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false) + getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC) } private suspend fun getImpl( root: File, manga: Manga, onlyIfExists: Boolean, - preferSingleCbz: Boolean, + format: DownloadFormat, ): LocalMangaOutput? { mutex.withLock { var i = 0 @@ -75,10 +87,10 @@ sealed class LocalMangaOutput( continue } - !onlyIfExists -> if (preferSingleCbz) { - LocalMangaZipOutput(zip, manga) - } else { - LocalMangaDirOutput(dir, manga) + !onlyIfExists -> when (format) { + DownloadFormat.AUTOMATIC -> null + DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga) + DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga) } else -> null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt new file mode 100644 index 000000000..95ae313f6 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt @@ -0,0 +1,100 @@ +package org.koitharu.kotatsu.local.ui.info + +import android.content.res.ColorStateList +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.core.graphics.ColorUtils +import androidx.core.widget.TextViewCompat +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView +import org.koitharu.kotatsu.core.util.Colors +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.combine +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.settings.userdata.StorageUsage +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class LocalInfoDialog : AlertDialogFragment() { + + private val viewModel: LocalInfoViewModel by viewModels() + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { + return super.onBuildDialog(builder) + .setTitle(R.string.saved_manga) + .setNegativeButton(R.string.close, null) + } + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding { + return DialogLocalInfoBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: DialogLocalInfoBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + viewModel.path.observe(this) { + binding.textViewPath.text = it + } + combine(viewModel.size, viewModel.availableSize, ::Pair).observe(this) { + if (it.first >= 0 && it.second >= 0) { + setSegments(it.first, it.second) + } else { + binding.barView.animateSegments(emptyList()) + } + } + } + + private fun setSegments(size: Long, available: Long) { + val view = viewBinding?.barView ?: return + val total = size + available + val segment = SegmentedBarView.Segment( + percent = (size.toDouble() / total.toDouble()).toFloat(), + color = Colors.segmentColor(view.context, materialR.attr.colorPrimary), + ) + requireViewBinding().labelUsed.text = view.context.getString( + R.string.memory_usage_pattern, + getString(R.string.this_manga), + FileSize.BYTES.format(view.context, size), + ) + requireViewBinding().labelAvailable.text = view.context.getString( + R.string.memory_usage_pattern, + getString(R.string.available), + FileSize.BYTES.format(view.context, available), + ) + TextViewCompat.setCompoundDrawableTintList( + requireViewBinding().labelUsed, + ColorStateList.valueOf(segment.color), + ) + view.animateSegments(listOf(segment)) + } + + companion object { + + const val ARG_MANGA = "manga" + private const val TAG = "LocalInfoDialog" + + fun show(fm: FragmentManager, manga: Manga) { + LocalInfoDialog().withArgs(1) { + putParcelable(ARG_MANGA, ParcelableManga(manga)) + }.showDistinct(fm, TAG) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt new file mode 100644 index 000000000..81436b7ed --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.local.ui.info + +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView +import org.koitharu.kotatsu.core.util.ext.computeSize +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.core.util.ext.toFileOrNull +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.LocalStorageManager +import javax.inject.Inject + +@HiltViewModel +class LocalInfoViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val localMangaRepository: LocalMangaRepository, + private val storageManager: LocalStorageManager, +) : BaseViewModel() { + + private val manga = savedStateHandle.require(LocalInfoDialog.ARG_MANGA).manga + + val path = MutableStateFlow(null) + val size = MutableStateFlow(-1L) + val availableSize = MutableStateFlow(-1L) + + init { + launchLoadingJob(Dispatchers.Default) { + val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file + requireNotNull(file) + path.value = file.path + size.value = file.computeSize() + availableSize.value = storageManager.computeAvailableSize() + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 42e5ee65a..013b0e883 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -47,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.data.isFileUri import org.koitharu.kotatsu.local.data.isZipUri import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource @@ -203,20 +204,23 @@ class PageLoader @Inject constructor( val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" } val uri = Uri.parse(pageUrl) - return if (uri.isZipUri()) { - if (uri.scheme == URI_SCHEME_ZIP) { + return when { + uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) { uri } else { // legacy uri uri.buildUpon().scheme(URI_SCHEME_ZIP).build() } - } else { - val request = createPageRequest(page, pageUrl) - imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> - val body = checkNotNull(response.body) { "Null response body" } - body.withProgress(progress).use { - cache.put(pageUrl, it.source()) - } - }.toUri() + + uri.isFileUri() -> uri + else -> { + val request = createPageRequest(page, pageUrl) + imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> + val body = checkNotNull(response.body) { "Null response body" } + body.withProgress(progress).use { + cache.put(pageUrl, it.source()) + } + }.toUri() + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 5c26eb2a7..e1e9d351d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -14,6 +14,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager +import androidx.activity.result.ActivityResultCallback import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener @@ -74,6 +75,7 @@ class ReaderActivity : ReaderControlDelegate.OnInteractionListener, OnApplyWindowInsetsListener, IdlingDetector.Callback, + ActivityResultCallback, ZoomControl.ZoomControlListener { @Inject @@ -83,6 +85,7 @@ class ReaderActivity : lateinit var tapGridSettings: TapGridSettings private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) + private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private val viewModel: ReaderViewModel by viewModels() @@ -158,6 +161,10 @@ class ReaderActivity : viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) } + override fun onActivityResult(result: Uri?) { + viewModel.onActivityResult(result) + } + override fun getParentActivityIntent(): Intent? { val manga = viewModel.manga?.toManga() ?: return null return DetailsActivity.newIntent(this, manga) @@ -376,6 +383,11 @@ class ReaderActivity : return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader } + override fun onSavePageClick() { + val page = viewModel.getCurrentPage() ?: return + viewModel.saveCurrentPage(page, savePageRequest) + } + private fun onReaderBarChanged(isBarEnabled: Boolean) { viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt index b543fc7b1..c3d3c8698 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt @@ -25,8 +25,7 @@ class ReaderManager( private val modeMap = EnumMap>>(ReaderMode::class.java) init { - val useDoublePages = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && settings.isReaderDoubleOnLandscape + val useDoublePages = isLandscape() && settings.isReaderDoubleOnLandscape invalidateTypesMap(useDoublePages) } @@ -49,7 +48,7 @@ class ReaderManager( fun setDoubleReaderMode(isEnabled: Boolean) { val prevMode = currentMode - invalidateTypesMap(isEnabled) + invalidateTypesMap(isEnabled && isLandscape()) val newMode = currentMode ?: return if (newMode != prevMode) { replace(newMode) @@ -70,4 +69,6 @@ class ReaderManager( modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java modeMap[ReaderMode.VERTICAL] = VerticalReaderFragment::class.java } + + private fun isLandscape() = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index d1f878a58..be0c929c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -39,14 +39,12 @@ import javax.inject.Inject @AndroidEntryPoint class ReaderConfigSheet : BaseAdaptiveSheet(), - ActivityResultCallback, View.OnClickListener, MaterialButtonToggleGroup.OnButtonCheckedListener, Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener { private val viewModel by activityViewModels() - private val savePageRequest = registerForActivityResult(PageSaveContract(), this) @Inject lateinit var orientationHelper: ScreenOrientationHelper @@ -115,8 +113,7 @@ class ReaderConfigSheet : } R.id.button_save_page -> { - val page = viewModel.getCurrentPage() ?: return - viewModel.saveCurrentPage(page, savePageRequest) + findCallback()?.onSavePageClick() ?: return dismissAllowingStateLoss() } @@ -181,11 +178,6 @@ class ReaderConfigSheet : (viewBinding ?: return).labelTimerValue.text = getString(R.string.speed_value, value * 10f) } - override fun onActivityResult(result: Uri?) { - viewModel.onActivityResult(result) - dismissAllowingStateLoss() - } - private fun observeScreenOrientation() { orientationHelper.observeAutoOrientation() .onEach { @@ -215,6 +207,8 @@ class ReaderConfigSheet : fun onReaderModeChanged(mode: ReaderMode) fun onDoubleModeChanged(isEnabled: Boolean) + + fun onSavePageClick() } companion object { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapGridDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapGridDispatcher.kt index 96ec3d580..9987cd4cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapGridDispatcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/tapgrid/TapGridDispatcher.kt @@ -32,7 +32,8 @@ class TapGridDispatcher( if (!isDispatching) { return true } - return listener.onGridTouch(getArea(event.rawX, event.rawY)) + val area = getArea(event.rawX, event.rawY) ?: return false + return listener.onGridTouch(area) } override fun onDoubleTapEvent(e: MotionEvent): Boolean { @@ -42,11 +43,12 @@ class TapGridDispatcher( override fun onLongPress(event: MotionEvent) { if (isDispatching) { - listener.onGridLongTouch(getArea(event.rawX, event.rawY)) + val area = getArea(event.rawX, event.rawY) ?: return + listener.onGridLongTouch(area) } } - private fun getArea(x: Float, y: Float): TapGridArea { + private fun getArea(x: Float, y: Float): TapGridArea? { val xIndex = (x * 2f / width).roundToInt() val yIndex = (y * 2f / height).roundToInt() val area = when (xIndex) { @@ -73,7 +75,8 @@ class TapGridDispatcher( else -> null } - return checkNotNull(area) { "Invalid area ($xIndex, $yIndex)" } + assert(area != null) { "Invalid area ($xIndex, $yIndex)" } + return area } interface OnGridTouchListener { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt index 2861cd870..0a693d701 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.reader.ui.thumbnails import android.content.Context +import android.webkit.MimeTypeMap +import androidx.core.net.toFile import androidx.core.net.toUri import coil.ImageLoader import coil.decode.DataSource @@ -20,6 +22,7 @@ import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.data.isFileUri import org.koitharu.kotatsu.local.data.isZipUri import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.parsers.model.MangaPage @@ -56,8 +59,8 @@ class MangaPageFetcher( private suspend fun loadPage(pageUrl: String): SourceResult { val uri = pageUrl.toUri() - return if (uri.isZipUri()) { - runInterruptible(Dispatchers.IO) { + return when { + uri.isZipUri() -> runInterruptible(Dispatchers.IO) { val zip = ZipFile(uri.schemeSpecificPart) val entry = zip.getEntry(uri.fragment) SourceResult( @@ -66,32 +69,48 @@ class MangaPageFetcher( context = context, metadata = MangaPageMetadata(page), ), - mimeType = null, + mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")), dataSource = DataSource.DISK, ) } - } else { - val request = PageLoader.createPageRequest(page, pageUrl) - imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message} at $pageUrl" - } - val body = checkNotNull(response.body) { - "Null response" - } - val mimeType = response.mimeType - val file = body.use { - pagesCache.put(pageUrl, it.source()) - } + + uri.isFileUri() -> runInterruptible(Dispatchers.IO) { + val file = uri.toFile() SourceResult( source = ImageSource( - file = file.toOkioPath(), + source = file.source().buffer(), + context = context, metadata = MangaPageMetadata(page), ), - mimeType = mimeType, - dataSource = DataSource.NETWORK, + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension), + dataSource = DataSource.DISK, ) } + + else -> { + val request = PageLoader.createPageRequest(page, pageUrl) + imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> + check(response.isSuccessful) { + "Invalid response: ${response.code} ${response.message} at $pageUrl" + } + val body = checkNotNull(response.body) { + "Null response" + } + val mimeType = response.mimeType + val file = body.use { + pagesCache.put(pageUrl, it.source()) + } + SourceResult( + source = ImageSource( + file = file.toOkioPath(), + metadata = MangaPageMetadata(page), + ), + mimeType = mimeType, + dataSource = DataSource.NETWORK, + ) + } + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt index 25375fae8..e0c96fb31 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -54,7 +54,7 @@ class AppearanceSettingsFragment : } summaryProvider = Preference.SummaryProvider { val locale = AppCompatDelegate.getApplicationLocales().get(0) - locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic) + locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system) } setDefaultValueCompat("") } @@ -105,7 +105,7 @@ class AppearanceSettingsFragment : .sortedWithSafe(LocaleComparator()) preference.entries = Array(locales.size + 1) { i -> if (i == 0) { - getString(R.string.automatic) + getString(R.string.follow_system) } else { val lc = locales[i - 1] lc.getDisplayName(lc).toTitleCase(lc) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index b9631cc14..1a2922728 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -7,6 +7,7 @@ import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile +import androidx.preference.ListPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -15,13 +16,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.DownloadFormat +import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.resolveFile +import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.utils.DozeHelper @@ -46,6 +51,10 @@ class DownloadsSettingsFragment : override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_downloads) + findPreference(AppSettings.KEY_DOWNLOADS_FORMAT)?.run { + entryValues = DownloadFormat.entries.names() + setDefaultValueCompat(DownloadFormat.AUTOMATIC.name) + } dozeHelper.updatePreference() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt index 717938193..3d714db4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt @@ -27,6 +27,8 @@ import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity import javax.inject.Inject @AndroidEntryPoint @@ -112,7 +114,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), AppSettings.KEY_KITSU -> { if (!kitsuRepository.isAuthorized) { - launchScrobblerAuth(kitsuRepository) + startActivity(Intent(preference.context, KitsuAuthActivity::class.java)) } else { startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.KITSU)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt index 7dd9ba8a2..20fe6d072 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.MangaParserAuthProvider @@ -43,7 +42,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { + if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { return } val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt index 20f114981..bb072537a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt @@ -14,6 +14,7 @@ import com.google.android.material.color.MaterialColors import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView +import org.koitharu.kotatsu.core.util.Colors import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding @@ -38,15 +39,15 @@ class StorageUsagePreference @JvmOverloads constructor( val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) val storageSegment = SegmentedBarView.Segment( usage?.savedManga?.percent ?: 0f, - segmentColor(materialR.attr.colorPrimary), + Colors.segmentColor(context, materialR.attr.colorPrimary), ) val pagesSegment = SegmentedBarView.Segment( usage?.pagesCache?.percent ?: 0f, - segmentColor(materialR.attr.colorSecondary), + Colors.segmentColor(context, materialR.attr.colorSecondary), ) val otherSegment = SegmentedBarView.Segment( usage?.otherCache?.percent ?: 0f, - segmentColor(materialR.attr.colorTertiary), + Colors.segmentColor(context, materialR.attr.colorTertiary), ) with(binding) { @@ -81,27 +82,4 @@ class StorageUsagePreference @JvmOverloads constructor( context.getString(emptyResId) } } - - private fun getHue(hex: String): Float { - val r = (hex.substring(0, 2).toInt(16)).toFloat() - val g = (hex.substring(2, 4).toInt(16)).toFloat() - val b = (hex.substring(4, 6).toInt(16)).toFloat() - - var hue = 0F - if ((r >= g) && (g >= b)) { - hue = 60 * (g - b) / (r - b) - } else if ((g > r) && (r >= b)) { - hue = 60 * (2 - (r - b) / (g - b)) - } - return hue - } - - @ColorInt - private fun segmentColor(@AttrRes resId: Int): Int { - val colorHex = String.format("%06x", context.getThemeColor(resId)) - val hue = getHue(colorHex) - val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) - val backgroundColor = context.getThemeColor(materialR.attr.colorSurfaceContainerHigh) - return MaterialColors.harmonize(color, backgroundColor) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index c868eb610..41dccf6c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -293,6 +293,7 @@ class TrackWorker @AssistedInject constructor( setCategory(NotificationCompat.CATEGORY_SERVICE) setDefaults(0) setOngoing(false) + setOnlyAlertOnce(true) setSilent(true) setContentIntent( PendingIntentCompat.getActivity( diff --git a/app/src/main/res/layout/activity_kitsu_auth.xml b/app/src/main/res/layout/activity_kitsu_auth.xml index f6166696e..b2d1eecb4 100644 --- a/app/src/main/res/layout/activity_kitsu_auth.xml +++ b/app/src/main/res/layout/activity_kitsu_auth.xml @@ -58,6 +58,7 @@ android:autofillHints="emailAddress" android:imeOptions="actionDone" android:inputType="textEmailAddress" + android:maxLength="512" android:singleLine="true" android:textSize="16sp" tools:hint="Email" /> @@ -84,7 +85,7 @@ android:autofillHints="password" android:imeOptions="actionDone" android:inputType="textPassword" - android:maxLength="24" + android:maxLength="512" android:singleLine="true" android:textSize="16sp" tools:hint="Password" /> diff --git a/app/src/main/res/layout/dialog_local_info.xml b/app/src/main/res/layout/dialog_local_info.xml new file mode 100644 index 000000000..eecb46c07 --- /dev/null +++ b/app/src/main/res/layout/dialog_local_info.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_filter_header.xml b/app/src/main/res/layout/fragment_filter_header.xml index 6436d3c94..308ca6991 100644 --- a/app/src/main/res/layout/fragment_filter_header.xml +++ b/app/src/main/res/layout/fragment_filter_header.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/scrollView" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" android:clipToPadding="false" android:paddingHorizontal="12dp" @@ -13,6 +13,8 @@ android:id="@+id/chips_tags" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:clipChildren="false" + android:clipToPadding="false" android:paddingVertical="@dimen/margin_small" app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" app:selectionRequired="false" diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 7743b2c19..0c5b27ce4 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -61,7 +61,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:barrierDirection="bottom" - app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand" /> + app:constraint_referenced_ids="imageView_cover,textView_status,imageView_expand,textView_details" /> + tools:text="@tools:sample/lorem[10]" />