Compare commits

...

21 Commits

Author SHA1 Message Date
5c86de555a Merge branch 'master' into devel 2022-08-23 09:35:16 +03:00
c4e7807d18 Update version 2022-08-23 09:23:34 +03:00
78dd0588ce Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2022-08-22 12:09:16 +03:00
2d73894880 Fix details ui 2022-08-22 12:08:45 +03:00
8ac19e557b Support rounded corners for reader info bar 2022-08-21 15:11:36 +03:00
f677a75ad1 Restore state of ssiv 2022-08-20 09:55:50 +03:00
8e55a4d824 Fix Shikimori authToken refreshing 2022-08-19 11:46:05 +03:00
6b5d8ff0f1 Fix Shikimori authToken refreshing 2022-08-19 11:45:43 +03:00
0c7da53349 Improve details ui 2022-08-19 11:25:36 +03:00
2a3cc11728 Save reader state on idle 2022-08-19 10:22:16 +03:00
a806634bc0 Add text outline to reader info bar 2022-08-19 10:03:13 +03:00
6879d046f8 Adjust reader info bar insets 2022-08-18 17:00:59 +03:00
0248f84ca0 Fix in-app update checking 2022-08-18 16:13:35 +03:00
ff0706dae5 Change acra sender to http 2022-08-18 15:43:12 +03:00
b73fe0398f Highlight new records in feed 2022-08-18 12:33:17 +03:00
f78ae4a818 Make BottomSheet`s handle fit top window inset 2022-08-18 11:35:21 +03:00
072f6d8c69 Show dialog on manga loading error 2022-08-18 11:07:20 +03:00
68b68eb4c5 Handle unknown manga sources 2022-08-18 10:15:34 +03:00
7f95eead50 Merge branch 'master' into devel 2022-08-18 09:18:17 +03:00
Dpper
1935de8f20 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (376 of 376 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-08-18 09:15:50 +03:00
c1e9fde6e8 Update parsers and version 2022-08-17 16:51:03 +03:00
66 changed files with 737 additions and 428 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 490
versionName '4.0-a1'
versionCode 491
versionName '4.0-a2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -29,6 +29,8 @@ android {
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
}
buildTypes {
debug {
@@ -80,7 +82,7 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') {
implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') {
exclude group: 'org.json', module: 'json'
}
@@ -120,13 +122,13 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.1.0'
implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'io.coil-kt:coil-svg:2.1.0'
// implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d3aa1d2beb'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:2942b797a2'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.5'
implementation 'ch.acra:acra-http:5.9.5'
implementation 'ch.acra:acra-dialog:5.9.5'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'

View File

@@ -15,9 +15,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
@@ -59,16 +60,24 @@ class KotatsuApp : Application(), Configuration.Provider {
super.attachBaseContext(base)
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST
reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
)
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
basicAuthPassword = getString(R.string.acra_password)
httpMethod = HttpSender.Method.POST
}
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES,
)
dialog {
@@ -78,11 +87,6 @@ class KotatsuApp : Application(), Configuration.Provider {
resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
}
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
}
}

View File

@@ -21,14 +21,14 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
viewBinding = binding
return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(binding.root)
.also(::onBuildDialog)
.run(::onBuildDialog)
.create()
}
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
savedInstanceState: Bundle?,
) = viewBinding?.root
@CallSuper
@@ -37,9 +37,9 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
super.onDestroyView()
}
open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit
open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
protected fun bindingOrNull(): B? = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
}
}

View File

@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.base.ui.widgets
import android.animation.LayoutTransition
import android.content.Context
import android.transition.AutoTransition
import android.transition.TransitionManager
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
@@ -43,7 +39,10 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
private val locationBuffer = IntArray(2)
private val expansionListeners = LinkedList<OnExpansionChangeListener>()
private var fitStatusBar = false
private var transition: AutoTransition? = null
private val minHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_min)
private val maxHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_max)
private var isLayoutSuppressedCompat = false
private var isLayoutCalledWhileSuppressed = false
@Deprecated("")
val toolbar: MaterialToolbar
@@ -156,6 +155,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.setSubtitle(resId)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (isLayoutSuppressedCompat) {
isLayoutCalledWhileSuppressed = true
} else {
super.onLayout(changed, l, t, r, b)
}
}
private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
bottomSheetBehavior = behavior
@@ -170,11 +177,21 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
if (isExpanded == binding.dragHandle.isGone) {
return
}
TransitionManager.beginDelayedTransition(this, getTransition())
suppressLayoutCompat(true)
binding.toolbar.navigationIcon = (if (isExpanded) closeDrawable else null)
binding.dragHandle.isGone = isExpanded
expansionListeners.forEach { it.onExpansionStateChanged(this, isExpanded) }
dispatchInsets(ViewCompat.getRootWindowInsets(this))
suppressLayoutCompat(false)
}
private fun suppressLayoutCompat(suppress: Boolean) {
if (suppress == isLayoutSuppressedCompat) return
isLayoutSuppressedCompat = suppress
if (!suppress && isLayoutCalledWhileSuppressed) {
requestLayout()
}
isLayoutCalledWhileSuppressed = false
}
private fun dispatchInsets(insets: WindowInsetsCompat?) {
@@ -182,11 +199,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return
}
val isExpanded = binding.dragHandle.isGone
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
if (isExpanded) {
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
updatePadding(top = topInset)
} else {
updatePadding(top = 0)
binding.dragHandle.updateLayoutParams {
height = topInset.coerceIn(minHandleHeight, maxHandleHeight)
}
}
}
@@ -225,7 +245,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return true
}
val viewId = child.id
return viewId == R.id.dragHandle || viewId == R.id.toolbar || viewId == R.id.frame
return viewId == R.id.dragHandle || viewId == R.id.toolbar
}
private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
@@ -242,15 +262,6 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
}
}
private fun getTransition(): AutoTransition {
transition?.let { return it }
val t = AutoTransition()
t.duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
t.addTarget(binding.dragHandle)
transition = t
return t
}
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener {
override fun onStateChanged(bottomSheet: View, newState: Int) {

View File

@@ -42,7 +42,7 @@ class BookmarksViewModel @Inject constructor(
BookmarksGroup(manga, bookmarks)
}
}
.catch { e -> e.toErrorState(canRetry = false) }
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {

View File

@@ -58,8 +58,8 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
super.onDestroyView()
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setNegativeButton(android.R.string.cancel, null)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onResume() {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@@ -8,8 +9,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
interface TrackLogsDao {
@Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga>
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
@Query("DELETE FROM track_logs")
suspend fun clear()
@@ -25,4 +26,4 @@ interface TrackLogsDao {
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -10,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source),
source = MangaSource(this.source) ?: MangaSource.DUMMY,
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@@ -19,7 +20,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
state = this.state?.let { MangaState(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
@@ -27,8 +28,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
source = MangaSource(this.source) ?: MangaSource.DUMMY,
tags = tags,
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
@@ -54,14 +55,18 @@ fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode()
id = "${key}_${source.name}".longHashCode(),
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)
}.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

View File

@@ -11,8 +11,10 @@ import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig
@@ -53,11 +55,11 @@ class AppUpdateRepository @Inject constructor(
}
}
suspend fun fetchUpdate(): AppVersion? {
suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) {
if (!isUpdateSupported()) {
return null
return@withContext null
}
return runCatching {
runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }

View File

@@ -11,6 +11,12 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
@@ -19,12 +25,6 @@ import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@@ -73,13 +73,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
var lastUpdateCheckTimestamp: Long
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@@ -327,7 +320,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0

View File

@@ -0,0 +1,70 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.DialogMangaErrorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.withArgs
class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() {
private lateinit var error: Throwable
private lateinit var manga: Manga
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
manga = requireNotNull(args.getParcelable<ParcelableManga>(ARG_MANGA)?.manga)
error = args.getSerializable(ARG_ERROR) as Throwable
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding {
return DialogMangaErrorBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding.textViewMessage) {
movementMethod = LinkMovementMethod.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(),
manga.publicUrl,
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.report) { _, _ ->
dismiss()
error.report(TAG)
}.setTitle(R.string.error_occurred)
}
companion object {
private const val TAG = "MangaErrorDialog"
private const val ARG_ERROR = "error"
private const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) {
putParcelable(ARG_MANGA, ParcelableManga(manga, false))
putSerializable(ARG_ERROR, error)
}.show(fm, TAG)
}
}

View File

@@ -5,8 +5,13 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
@@ -16,6 +21,7 @@ import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -27,6 +33,7 @@ import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService
@@ -93,6 +100,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranchName.observe(this) {
@@ -158,8 +166,11 @@ class DetailsActivity :
private fun onMangaUpdated(manga: Manga) {
title = manga.title
binding.buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
val hasChapters = !manga.chapters.isNullOrEmpty()
binding.buttonRead.isEnabled = hasChapters
invalidateOptionsMenu()
showBottomSheet(manga.chapters != null)
binding.groupHeader?.isVisible = hasChapters
}
private fun onMangaRemoved(manga: Manga) {
@@ -172,17 +183,17 @@ class DetailsActivity :
}
private fun onError(e: Throwable) {
val manga = viewModel.manga.value
when {
ExceptionResolver.canResolve(e) -> {
resolveError(e)
}
viewModel.manga.value == null -> {
manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
else -> {
val snackbar = Snackbar.make(
binding.containerDetails,
val snackbar = makeSnackbar(
e.getDisplayMessage(resources),
if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE
@@ -190,10 +201,9 @@ class DetailsActivity :
Snackbar.LENGTH_LONG
},
)
snackbar.anchorView = binding.headerChapters
if (e.isReportable()) {
snackbar.setAction(R.string.report) {
e.report("DetailsActivity::onError")
snackbar.setAction(R.string.details) {
MangaErrorDialog.show(supportFragmentManager, manga, e)
}
}
snackbar.show()
@@ -238,8 +248,7 @@ class DetailsActivity :
fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) {
val snackbar = Snackbar.make(binding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
snackbar.anchorView = binding.headerChapters
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show()
return
}
@@ -291,6 +300,24 @@ class DetailsActivity :
private fun isTabletLayout() = binding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) {
val view = binding.layoutBottom ?: return
if (view.isVisible == isVisible) return
val transition = Slide(Gravity.BOTTOM)
transition.addTarget(view)
transition.interpolator = AccelerateDecelerateInterpolator()
TransitionManager.beginDelayedTransition(binding.root as ViewGroup, transition)
view.isVisible = isVisible
}
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(binding.containerDetails, text, duration)
if (binding.layoutBottom?.isVisible == true) {
sb.anchorView = binding.headerChapters
}
return sb
}
companion object {
fun newIntent(context: Context, manga: Manga): Intent {

View File

@@ -70,6 +70,7 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -228,6 +229,14 @@ class DetailsFragment :
),
)
}
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),

View File

@@ -28,4 +28,4 @@ fun MangaChapter.toListItem(
uploadDateMs = uploadDate,
dateFormat = dateFormat,
)
}
}

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel @AssistedInject constructor(
@@ -53,7 +54,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else {
repository.observeAll(categoryId)
},
createListModeFlow()
createListModeFlow(),
) { list, mode ->
when {
list.isEmpty() -> listOf(
@@ -66,13 +67,13 @@ class FavouritesListViewModel @AssistedInject constructor(
R.string.favourites_category_empty
},
actionStringRes = 0,
)
),
)
else -> list.toUi(mode, this)
}
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
if (categoryId != NO_ID) {

View File

@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
@@ -60,8 +60,8 @@ class HistoryListViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit

View File

@@ -27,7 +27,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
private const val HISTORY_MAX_SEGMENTS = 2
@@ -49,8 +49,8 @@ class LibraryViewModel @Inject constructor(
) { history, favourites ->
mapList(history, favourites)
}.catch { e ->
e.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint
class ListModeSelectDialog :
@@ -33,8 +33,9 @@ class ListModeSelectDialog :
container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setTitle(R.string.list_mode)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}

View File

@@ -27,8 +27,9 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
return DialogImportBinding.inflate(inflater, container, false)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setTitle(R.string._import)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string._import)
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(true)
}

View File

@@ -4,6 +4,7 @@ import android.util.SparseIntArray
import androidx.core.util.set
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
@@ -16,12 +17,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository,
) : BaseViewModel() {
@@ -43,7 +42,7 @@ class MainViewModel @Inject constructor(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init {
launchJob(Dispatchers.Default) {
launchJob {
appUpdateRepository.fetchUpdate()
}
}

View File

@@ -40,6 +40,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
@@ -51,11 +52,14 @@ class ReaderActivity :
OnPageSelectListener,
ReaderConfigBottomSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener {
OnApplyWindowInsetsListener,
IdlingDetector.Callback {
@Inject
lateinit var viewModelFactory: ReaderViewModel.Factory
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
val viewModel by assistedViewModels {
viewModelFactory.create(
intent = MangaIntent(intent),
@@ -89,6 +93,7 @@ class ReaderActivity :
binding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
viewModel.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader)
@@ -111,6 +116,11 @@ class ReaderActivity :
override fun onUserInteraction() {
super.onUserInteraction()
pageSwitchTimer.onUserInteraction()
idlingDetector.onUserInteraction()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
}
private fun onInitReader(mode: ReaderMode) {

View File

@@ -15,14 +15,15 @@ import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding
import org.koitharu.kotatsu.utils.ext.withArgs
@Deprecated("Not in use")
class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
class ReaderConfigDialog :
AlertDialogFragment<DialogReaderConfigBinding>(),
CheckableButtonGroup.OnCheckedChangeListener {
private lateinit var mode: ReaderMode
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = DialogReaderConfigBinding.inflate(inflater, container, false)
override fun onCreate(savedInstanceState: Bundle?) {
@@ -32,8 +33,9 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
?: ReaderMode.STANDARD
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setTitle(R.string.read_mode)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.read_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
@@ -48,8 +50,10 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
}
override fun onDismiss(dialog: DialogInterface) {
((parentFragment as? Callback)
?: (activity as? Callback))?.onReaderModeChanged(mode)
(
(parentFragment as? Callback)
?: (activity as? Callback)
)?.onReaderModeChanged(mode)
super.onDismiss(dialog)
}

View File

@@ -8,17 +8,19 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.icu.text.SimpleDateFormat
import android.util.AttributeSet
import android.view.View
import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureDimension
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.resolveDp
class ReaderInfoBarView @JvmOverloads constructor(
@@ -29,23 +31,46 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect()
private val inset = context.resources.resolveDp(2f)
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
private var insetTop: Int = 0
private val colorText = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
200,
)
private val colorOutline = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorSurface, Color.WHITE),
200,
)
private var timeText = timeFormat.format(Date())
private var text: String = ""
private val innerHeight
get() = height - inset - inset - paddingTop - paddingBottom
get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth
get() = width - inset - inset - paddingLeft - paddingRight
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init {
paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
160,
paint.strokeWidth = context.resources.resolveDp(2f)
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start") + insetCorner
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end") + insetCorner
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
insetLeft = if (isRtl) insetEnd else insetStart
insetRight = if (isRtl) insetStart else insetEnd
insetTop = minOf(insetLeft, insetRight)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight
val desiredHeight = suggestedMinimumHeight + paddingTop + paddingBottom + insetTop
setMeasuredDimension(
measureDimension(desiredWidth, widthMeasureSpec),
measureDimension(desiredHeight, heightMeasureSpec),
)
}
@@ -53,9 +78,9 @@ class ReaderInfoBarView @JvmOverloads constructor(
super.onDraw(canvas)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
paint.textAlign = Paint.Align.LEFT
canvas.drawText(text, paddingLeft + inset, paddingTop + inset + ty, paint)
canvas.drawTextOutline(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty)
paint.textAlign = Paint.Align.RIGHT
canvas.drawText(timeText, width - paddingRight - inset, paddingTop + inset + ty, paint)
canvas.drawTextOutline(timeText, (width - paddingRight - insetRight).toFloat(), paddingTop + insetTop + ty)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -103,6 +128,15 @@ class ReaderInfoBarView @JvmOverloads constructor(
paint.getTextBounds(str, 0, str.length, textBounds)
}
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
paint.color = colorOutline
paint.style = Paint.Style.STROKE
drawText(text, x, y, paint)
paint.color = colorText
paint.style = Paint.Style.FILL
drawText(text, x, y, paint)
}
private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -110,4 +144,13 @@ class ReaderInfoBarView @JvmOverloads constructor(
invalidate()
}
}
private fun getSystemUiDimensionOffset(name: String): Int = runCatching {
val manager = context.packageManager
val resources = manager.getResourcesForApplication("com.android.systemui")
val resId = resources.getIdentifier(name, "dimen", "com.android.systemui")
resources.getDimensionPixelOffset(resId)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(0)
}

View File

@@ -69,7 +69,7 @@ open class PageHolder(
override fun onImageShowing(zoom: ZoomMode) {
binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
)
when (zoom) {
ZoomMode.FIT_CENTER -> {
@@ -81,7 +81,7 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(0f, binding.ssiv.sHeight / 2f)
PointF(0f, binding.ssiv.sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
@@ -89,14 +89,14 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(binding.ssiv.sWidth / 2f, 0f)
PointF(binding.ssiv.sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.setScaleAndCenter(
binding.ssiv.maxScale,
PointF(0f, 0f)
PointF(0f, 0f),
)
}
}
@@ -115,9 +115,9 @@ open class PageHolder(
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat()
}
}
}

View File

@@ -10,10 +10,13 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
@@ -21,4 +24,4 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
}
return response
}
}
}

View File

@@ -40,13 +40,14 @@ class ShikimoriRepository(
suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()

View File

@@ -1,110 +0,0 @@
package org.koitharu.kotatsu.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
@Deprecated("")
class AppUpdateChecker(private val activity: ComponentActivity) {
private val settings: AppSettings = TODO()
private val repo: AppUpdateRepository = TODO()
suspend fun checkIfNeeded(): Boolean? = if (
settings.isUpdateCheckingEnabled &&
settings.lastUpdateCheckTimestamp + PERIOD < System.currentTimeMillis()
) {
checkNow()
} else {
null
}
suspend fun checkNow() = runCatching {
val version = repo.fetchUpdate() ?: return@runCatching false
val newVersionId = VersionId(version.name)
val currentVersionId = VersionId(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId
if (result) {
withContext(Dispatchers.Main) {
showUpdateDialog(version)
}
}
settings.lastUpdateCheckTimestamp = System.currentTimeMillis()
result
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@MainThread
private fun showUpdateDialog(version: AppVersion) {
val message = buildString {
append(activity.getString(R.string.new_version_s, version.name))
appendLine()
append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(activity, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun isUpdateSupported(context: Context): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint(context) == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(context: Context): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
}

View File

@@ -2,34 +2,41 @@ package org.koitharu.kotatsu.settings.about
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import kotlinx.coroutines.launch
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about)
val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = isUpdateSupported
}
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = isUpdateSupported
isEnabled = viewModel.isUpdateSupported
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(viewLifecycleOwner) {
findPreference<Preference>(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it
}
viewModel.onUpdateAvailable.observe(viewLifecycleOwner, ::onUpdateAvailable)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_APP_VERSION -> {
checkForUpdates()
viewModel.checkForUpdates()
true
}
AppSettings.KEY_APP_TRANSLATION -> {
@@ -40,24 +47,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
}
}
private fun checkForUpdates() {
viewLifecycleScope.launch {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(R.string.checking_for_updates)
isSelectable = false
}
val result = AppUpdateChecker(activity ?: return@launch).checkNow()
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(
when (result) {
true -> R.string.check_for_updates
false -> R.string.no_update_available
null -> R.string.update_check_failed
}
)
isSelectable = true
}
private fun onUpdateAvailable(version: AppVersion?) {
if (version == null) {
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
return
}
AppUpdateDialog(context ?: return).show(version)
}
private fun openLink(url: String, title: CharSequence?) {
@@ -68,7 +63,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Intent.createChooser(intent, title)
} else {
intent
}
},
)
}
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.settings.about
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.SingleLiveEvent
@HiltViewModel
class AboutSettingsViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
) : BaseViewModel() {
val isUpdateSupported = appUpdateRepository.isUpdateSupported()
val onUpdateAvailable = SingleLiveEvent<AppVersion?>()
fun checkForUpdates() {
launchLoadingJob {
val update = appUpdateRepository.fetchUpdate()
onUpdateAvailable.call(update)
}
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.settings.about
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.FileSize
class AppUpdateDialog(private val context: Context) {
fun show(version: AppVersion) {
val message = buildString {
append(context.getString(R.string.new_version_s, version.name))
appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(
context,
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
context.startActivity(Intent.createChooser(intent, context.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
}

View File

@@ -51,8 +51,9 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setCancelable(false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null)
}

View File

@@ -44,8 +44,9 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setCancelable(false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(false)
}
private fun onError(e: Throwable) {

View File

@@ -43,8 +43,8 @@ class NewSourcesDialogFragment :
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this)
.setCancelable(true)
.setTitle(R.string.remote_sources)

View File

@@ -39,8 +39,8 @@ class OnboardDialogFragment :
container: ViewGroup?,
) = DialogOnboardBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this)
.setCancelable(true)
if (isWelcome) {
@@ -50,6 +50,7 @@ class OnboardDialogFragment :
.setTitle(R.string.remote_sources)
.setNegativeButton(android.R.string.cancel, this)
}
return builder
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.tools.model.StorageUsage
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor
@@ -68,6 +69,10 @@ class ToolsFragment :
intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
}
R.id.card_update -> {
val version = viewModel.appUpdate.value ?: return
AppUpdateDialog(v.context).show(version)
}
}
}

View File

@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst
@HiltViewModel
@@ -44,8 +44,8 @@ class SuggestionsViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState),
)

View File

@@ -5,9 +5,26 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
return TrackingLogItem(
id = trackLog.id,
chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt),
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
)
}
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean {
val counter = get(key)
if (counter == null || counter <= 0) {
return false
}
if (counter < count) {
remove(key)
} else {
put(key, counter - count)
}
return true
}

View File

@@ -18,6 +18,10 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int?
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
@Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>>

View File

@@ -5,6 +5,8 @@ import androidx.room.withTransaction
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@@ -73,9 +75,15 @@ class TrackingRepository @Inject constructor(
db.tracksDao.delete(mangaId)
}
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return limit.flatMapLatest { limitValue ->
combine(
db.tracksDao.observeNewChaptersMap(),
db.trackLogsDao.observeAll(limitValue),
) { counters, entities ->
val countersMap = counters.toMutableMap()
entities.map { x -> x.toTrackingLogItem(countersMap) }
}
}
}

View File

@@ -7,5 +7,6 @@ data class TrackingLogItem(
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date
)
val createdAt: Date,
val isNew: Boolean,
)

View File

@@ -137,7 +137,7 @@ class FeedFragment :
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
viewModel.requestMoreItems()
}
override fun onItemClick(item: Manga, view: View) {

View File

@@ -4,44 +4,39 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
private const val PAGE_SIZE = 20
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repository: TrackingRepository,
) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false)
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
logList.filterNotNull(),
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
val content = repository.observeTrackingLog(limit)
.map { list ->
if (list.isEmpty()) {
add(
listOf(
EmptyState(
icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary,
@@ -50,48 +45,26 @@ class FeedViewModel @Inject constructor(
),
)
} else {
list.mapListTo(this)
if (isHasNextPage) {
add(LoadingFooter)
}
isReady.set(true)
list.mapList()
}
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
if (append && !hasNextPage.value) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) logList.value?.size ?: 0 else 0
val list = repository.getTrackingLog(offset, 20)
if (!append) {
logList.value = list
} else if (list.isNotEmpty()) {
logList.value = logList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed() {
val lastJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
lastJob?.cancelAndJoin()
launchLoadingJob(Dispatchers.Default) {
repository.clearLogs()
logList.value = emptyList()
onFeedCleared.postCall(Unit)
}
}
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun List<TrackingLogItem>.mapList(): List<ListModel> {
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.createdAt)
@@ -101,6 +74,7 @@ class FeedViewModel @Inject constructor(
prevDate = date
destination += item.toFeedItem()
}
return destination
}
private fun timeAgo(date: Date): DateTimeAgo {

View File

@@ -11,22 +11,23 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.isBold
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun feedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
) {
itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it)
}
bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

View File

@@ -9,4 +9,5 @@ data class FeedItem(
val title: String,
val manga: Manga,
val count: Int,
) : ListModel
val isNew: Boolean,
) : ListModel

View File

@@ -8,4 +8,5 @@ fun TrackingLogItem.toFeedItem() = FeedItem(
title = manga.title,
count = chapters.size,
manga = manga,
)
isNew = isNew,
)

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.utils
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class IdlingDetector(
private val timeoutMs: Long,
private val callback: Callback,
) : DefaultLifecycleObserver {
private val handler = Handler(Looper.getMainLooper())
private val idleRunnable = Runnable {
callback.onIdle()
}
fun bindToLifecycle(owner: LifecycleOwner) {
owner.lifecycle.addObserver(this)
}
fun onUserInteraction() {
handler.removeCallbacks(idleRunnable)
handler.postDelayed(idleRunnable, timeoutMs)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
owner.lifecycle.removeObserver(this)
handler.removeCallbacks(idleRunnable)
}
fun interface Callback {
fun onIdle()
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.TextView
@@ -48,3 +49,15 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
setTextColor(context.getThemeColorStateList(attrResId))
}
var TextView.isBold: Boolean
get() = typeface.isBold
set(value) {
var style = typeface.style
style = if (value) {
style or Typeface.BOLD
} else {
style and Typeface.BOLD.inv()
}
setTypeface(typeface, style)
}

View File

@@ -2,11 +2,14 @@ package org.koitharu.kotatsu.utils.ext
import android.content.Context
import android.graphics.Color
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.StyleRes
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
fun Context.getThemeDrawable(
@AttrRes resId: Int,
@@ -43,3 +46,9 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0)
}
fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) {
context.obtainStyledAttributes(intArrayOf(resId)).use {
TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback))
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
@@ -167,3 +168,19 @@ val View.parents: Sequence<ViewParent>
p = p.parent
}
}
@Suppress("unused")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
if (specMode == MeasureSpec.EXACTLY) {
result = specSize
} else {
result = desiredSize
if (specMode == MeasureSpec.AT_MOST) {
result = result.coerceAtMost(specSize)
}
}
return result
}

View File

@@ -181,11 +181,11 @@
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -63,56 +63,66 @@
app:layout_constraintWidth_percent="0.5"
tools:layout="@layout/fragment_details" />
<ImageView
android:id="@+id/button_dropdown"
android:layout_width="wrap_content"
<RelativeLayout
android:id="@+id/group_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more"
app:layout_constraintBottom_toTopOf="@id/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@id/textView_subtitle"
app:layout_constraintEnd_toStartOf="@id/button_dropdown"
app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@string/chapter_d_of_d" />
app:layout_constraintTop_toBottomOf="@id/appbar">
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleSmall"
android:textColor="?android:textColorSecondary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/divider"
app:layout_constraintEnd_toStartOf="@id/button_dropdown"
app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="English"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_marginTop="4dp"
android:layout_toStartOf="@id/button_dropdown"
android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleMedium"
tools:text="@string/chapter_d_of_d" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="48dp"
android:background="?colorSecondaryContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container_details"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_below="@id/textView_title"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/button_dropdown"
android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleSmall"
android:textColor="?android:textColorSecondary"
android:visibility="gone"
tools:text="English"
tools:visibility="visible" />
<ImageView
android:id="@+id/button_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginBottom="1dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_below="@id/textView_subtitle"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="4dp"
android:background="?colorSecondaryContainer" />
</RelativeLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/container_chapters"
@@ -122,7 +132,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container_details"
app:layout_constraintTop_toBottomOf="@id/divider"
app:layout_constraintTop_toBottomOf="@id/group_header"
tools:layout="@layout/fragment_chapters" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -42,9 +42,11 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="false"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet">
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet"
tools:visibility="visible">
<org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
android:id="@+id/header_chapters"

View File

@@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
@@ -21,4 +23,4 @@
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</FrameLayout>

View File

@@ -28,9 +28,9 @@
<org.koitharu.kotatsu.reader.ui.ReaderInfoBarView
android:id="@+id/infoBar"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:paddingHorizontal="4dp"
android:minHeight="12sp"
android:visibility="gone"
tools:visibility="visible" />

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_normal"
android:paddingTop="@dimen/margin_normal">
<TextView
android:id="@+id/textView_message"
style="@style/MaterialAlertDialog.Material3.Body.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:linksClickable="true"
tools:text="@tools:sample/lorem[20]" />
</LinearLayout>
</ScrollView>

View File

@@ -193,11 +193,11 @@
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -9,7 +10,8 @@
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<TextView
android:id="@+id/textView_number"
@@ -24,4 +26,4 @@
<include layout="@layout/layout_page_info" />
</FrameLayout>
</FrameLayout>

View File

@@ -2,7 +2,6 @@
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/card_update"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
@@ -53,7 +52,7 @@
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="12dp"
android:text="@string/download"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textSecondary" />
@@ -61,4 +60,4 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>

View File

@@ -62,6 +62,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"

View File

@@ -6,27 +6,20 @@
android:layout_height="wrap_content"
tools:parentTag="com.google.android.material.appbar.AppBarLayout">
<FrameLayout
android:id="@+id/frame"
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
android:layout_height="@dimen/bottom_sheet_handle_size_min"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
tools:navigationIcon="?actionModeCloseDrawable"
tools:title="@string/options" />
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
</FrameLayout>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
tools:navigationIcon="?actionModeCloseDrawable"
tools:title="@string/options" />
</merge>

View File

@@ -9,7 +9,7 @@
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp"
android:paddingBottom="8dp"
app:cardBackgroundColor="?colorOnPrimary"
app:cardBackgroundColor="?colorPrimaryContainer"
app:cardCornerRadius="24dp">
<LinearLayout

View File

@@ -320,4 +320,57 @@
<string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</string>
<string name="not_found_404">Вміст не знайдено або видалено</string>
<string name="text_shelf_holder_primary">Ваша манга буде відображатися тут</string>
<string name="text_shelf_holder_secondary">Знайдіть, що почитати у розділі «Огляд»</string>
<string name="back">Назад</string>
<string name="email_enter_hint">Введіть свою електронну пошту, щоб продовжити</string>
<string name="history_cleared">Історія очищена</string>
<string name="manage">Керувати</string>
<string name="no_bookmarks_yet">Закладок ще немає</string>
<string name="no_bookmarks_summary">Ви можете створювати закладки під час читання манґи</string>
<string name="bookmarks_removed">Закладки видалено</string>
<string name="no_manga_sources">Немає джерел манґи</string>
<string name="no_manga_sources_text">Увімкніть джерела манґи, щоб читати онлайн</string>
<string name="random">Випадкова</string>
<string name="categories_delete_confirm">Ви впевнені, що бажаєте видалити вибрані улюблені категорії\?
\nУсю манґу в них буде втрачено, і це неможливо скасувати.</string>
<string name="reorder">Впорядкувати</string>
<string name="empty">Порожньо</string>
<string name="changelog">Журнал змін</string>
<string name="explore">Огляд</string>
<string name="saved_manga">Збережена манґа</string>
<string name="pages_cache">Кеш сторінок</string>
<string name="storage_usage">Використання сховища</string>
<string name="available">Доступні</string>
<string name="removed_from_s">Видалено з \"%s\"</string>
<string name="options">Параметри</string>
<string name="downloading_manga">Завантаження манґи</string>
<string name="incognito_mode">Режим інкогніто</string>
<string name="app_update_available_s">Доступне оновлення програми: %s</string>
<string name="no_chapters">Немає розділів</string>
<string name="automatic_scroll">Автоматична прокрутка</string>
<string name="off_short">Викл.</string>
<string name="reader_info_bar">Показувати інформаційну панель у режимі читання</string>
<string name="comics_archive">Архів коміксів</string>
<string name="folder_with_images">Папка із зображеннями</string>
<string name="importing_manga">Імпорт манґи</string>
<string name="import_completed">Імпорт завершено</string>
<string name="import_completed_hint">Ви можете видалити оригінальний файл зі сховища, щоб заощадити місце</string>
<string name="import_will_start_soon">Імпорт почнеться незабаром</string>
<string name="feed">Стрічка</string>
<string name="reader_info_pattern">Розд. %1$d/%2$d Стор. %3$d/%4$d</string>
<string name="seconds_pattern">%s с</string>
<string name="account_already_exists">Обліковий запис уже існує</string>
<string name="sync_title">Синхронізуйте ваші дані</string>
<string name="canceled">Скасовано</string>
<string name="confirm_exit">Натисніть Назад ще раз, щоб вийти</string>
<string name="sync">Синхронізація</string>
<string name="clear_all_history">Очистити всю історію</string>
<string name="last_2_hours">Останні 2 години</string>
<string name="exit_confirmation_summary">Двічі натисніть Назад, щоб вийти з програми</string>
<string name="exit_confirmation">Підтвердження виходу</string>
<string name="enter_email_text">Введіть електронну пошту, щоб продовжити</string>
<string name="removed_from_favourites">Видалено з уподобань</string>
<string name="other_cache">Інший кеш</string>
<string name="memory_usage_pattern">%s - %s</string>
</resources>

View File

@@ -6,7 +6,7 @@
<string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string>
<string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string>
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string>
<string name="url_error_report" translatable="false">http://86.57.183.214:8082/report</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string>
<string-array name="values_theme" translatable="false">

View File

@@ -35,6 +35,8 @@
<dimen name="search_suggestions_manga_spacing">4dp</dimen>
<dimen name="bottom_sheet_width">0dp</dimen>
<dimen name="bottom_sheet_handle_size_min">16dp</dimen>
<dimen name="bottom_sheet_handle_size_max">24dp</dimen>
<dimen name="dialog_radius">8dp</dimen>
<dimen name="appwidget_corner_radius_inner">8dp</dimen>

View File

@@ -376,4 +376,5 @@
<string name="import_completed_hint">You can delete the original file from storage to save space</string>
<string name="import_will_start_soon">Import will start soon</string>
<string name="feed">Feed</string>
<string name="manga_error_description_pattern">Error details:&lt;br>&lt;tt>%1$s&lt;/tt>&lt;br>&lt;br>1. Try to &lt;a href="%2$s">open manga in a web browser&lt;/a> to ensure it is available on its source&lt;br>2. If it is available, send an error report to the developers.</string>
</resources>

View File

@@ -9,12 +9,6 @@
android:persistent="false"
android:summary="@string/check_for_updates" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="app_update_auto"
android:summary="@string/show_notification_app_update"
android:title="@string/application_update" />
<Preference
android:key="about_app_translation"
android:summary="@string/about_app_translation_summary"