Compare commits
21 Commits
861c21faea
...
d0ed1fb85f
| Author | SHA1 | Date | |
|---|---|---|---|
|
d0ed1fb85f
|
|||
|
9e5664da3a
|
|||
|
35c158d35a
|
|||
|
464f24e9f0
|
|||
|
c8a8203c39
|
|||
|
b414758f32
|
|||
|
1181860e41
|
|||
|
e35521f16f
|
|||
|
|
5fb8ff53f9 | ||
|
|
a66283d035 | ||
|
|
a1ba0b8c21 | ||
|
f3b42b9a42
|
|||
|
|
aa2f2c17fc | ||
|
ebc17b645b
|
|||
|
cc14e1abcf
|
|||
|
b1b474e2e7
|
|||
|
8ca3bece5d
|
|||
|
|
90bd9023d5 | ||
|
|
986627f24d | ||
|
|
cf2b8e2481 | ||
|
b9435de5cd
|
@@ -4,7 +4,7 @@ root = true
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/.idea/dictionaries
|
||||
/.idea/modules.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/markdown.xml
|
||||
/.idea/discord.xml
|
||||
/.idea/compiler.xml
|
||||
/.idea/workspace.xml
|
||||
|
||||
74
.idea/codeStyles/Project.xml
generated
74
.idea/codeStyles/Project.xml
generated
@@ -1,9 +1,7 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="OTHER_INDENT_OPTIONS">
|
||||
<value>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</value>
|
||||
<value />
|
||||
</option>
|
||||
<AndroidXmlCodeStyleSettings>
|
||||
<option name="LAYOUT_SETTINGS">
|
||||
@@ -22,40 +20,46 @@
|
||||
</value>
|
||||
</option>
|
||||
</AndroidXmlCodeStyleSettings>
|
||||
<JavaCodeStyleSettings>
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="CMake">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Groovy">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JAVA">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JSON">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="ObjectiveC">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
@@ -64,7 +68,6 @@
|
||||
<codeStyleSettings language="XML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
@@ -179,9 +182,6 @@
|
||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
@@ -35,7 +35,7 @@
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password / fingerprint-protected access to the app
|
||||
* Automatically sync app data with other devices on the same account
|
||||
* Support for older devices running Android 5.0+
|
||||
* Support for older devices running Android 6.0+
|
||||
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,6 @@ You may copy, distribute and modify the software as long as you track changes/da
|
||||
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1030
|
||||
versionName = '9.2'
|
||||
versionCode = 1031
|
||||
versionName = '9.3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
|
||||
@@ -26,7 +26,9 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -109,6 +111,18 @@ class BackupRepository @Inject constructor(
|
||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.SCROBBLING -> output.writeJsonArray(
|
||||
section = BackupSection.SCROBBLING,
|
||||
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.STATS -> output.writeJsonArray(
|
||||
section = BackupSection.STATS,
|
||||
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
@@ -163,6 +177,14 @@ class BackupRepository @Inject constructor(
|
||||
getSourcesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
||||
getScrobblingDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
||||
getStatsDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
null -> CompositeResult.EMPTY // skip unknown entries
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
|
||||
@Serializable
|
||||
class ScrobblingBackup(
|
||||
@SerialName("scrobbler") val scrobbler: Int,
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("target_id") val targetId: Long,
|
||||
@SerialName("status") val status: String?,
|
||||
@SerialName("chapter") val chapter: Int,
|
||||
@SerialName("comment") val comment: String?,
|
||||
@SerialName("rating") val rating: Float,
|
||||
) {
|
||||
|
||||
constructor(entity: ScrobblingEntity) : this(
|
||||
scrobbler = entity.scrobbler,
|
||||
id = entity.id,
|
||||
mangaId = entity.mangaId,
|
||||
targetId = entity.targetId,
|
||||
status = entity.status,
|
||||
chapter = entity.chapter,
|
||||
comment = entity.comment,
|
||||
rating = entity.rating,
|
||||
)
|
||||
|
||||
fun toEntity() = ScrobblingEntity(
|
||||
scrobbler = scrobbler,
|
||||
id = id,
|
||||
mangaId = mangaId,
|
||||
targetId = targetId,
|
||||
status = status,
|
||||
chapter = chapter,
|
||||
comment = comment,
|
||||
rating = rating,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
|
||||
@Serializable
|
||||
class StatisticBackup(
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("started_at") val startedAt: Long,
|
||||
@SerialName("duration") val duration: Long,
|
||||
@SerialName("pages") val pages: Int,
|
||||
) {
|
||||
|
||||
constructor(entity: StatsEntity) : this(
|
||||
mangaId = entity.mangaId,
|
||||
startedAt = entity.startedAt,
|
||||
duration = entity.duration,
|
||||
pages = entity.pages,
|
||||
)
|
||||
|
||||
fun toEntity() = StatsEntity(
|
||||
mangaId = mangaId,
|
||||
startedAt = startedAt,
|
||||
duration = duration,
|
||||
pages = pages,
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ enum class BackupSection(
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
SCROBBLING("scrobbling"),
|
||||
STATS("statistics"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -23,6 +23,8 @@ data class BackupSectionModel(
|
||||
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||
BackupSection.SOURCES -> R.string.remote_sources
|
||||
BackupSection.SCROBBLING -> R.string.tracking
|
||||
BackupSection.STATS -> R.string.statistics
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -34,6 +34,9 @@ abstract class MangaDao {
|
||||
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
||||
|
||||
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.serialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
object MangaSourceSerializer : KSerializer<MangaSource> {
|
||||
|
||||
override val descriptor: SerialDescriptor = serialDescriptor<String>()
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: MangaSource
|
||||
) = encoder.encodeString(value.name)
|
||||
|
||||
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AndroidRuntimeException
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
@@ -41,7 +42,13 @@ class WebViewExecutor @Inject constructor(
|
||||
private val mutex = Mutex()
|
||||
|
||||
val defaultUserAgent: String? by lazy {
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
e.printStackTraceDebug()
|
||||
// Probably WebView is not available
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||
|
||||
@@ -409,6 +409,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderBarTransparent: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
||||
|
||||
val isReaderChapterToastEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
|
||||
|
||||
val isReaderKeepScreenOn: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||
|
||||
@@ -747,6 +750,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||
const val KEY_READER_BAR = "reader_bar"
|
||||
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
|
||||
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
|
||||
const val KEY_READER_BACKGROUND = "reader_background"
|
||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||
|
||||
@@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
import java.io.File
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||
private val prefs = context.getSharedPreferences(
|
||||
source.name.replace(File.separatorChar, '$'),
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
var defaultSortOrder: SortOrder?
|
||||
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
||||
|
||||
@@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -15,6 +22,7 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
inline fun buildAlertDialog(
|
||||
@@ -66,3 +74,51 @@ fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapte
|
||||
recyclerView.adapter = adapter
|
||||
setView(recyclerView)
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
val editText = AppCompatEditText(context)
|
||||
editText.inputType = inputType
|
||||
if (singleLine) {
|
||||
editText.setSingleLine()
|
||||
editText.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
val layout = FrameLayout(context)
|
||||
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
lp.setMargins(
|
||||
horizontalMargin,
|
||||
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
|
||||
horizontalMargin,
|
||||
0,
|
||||
)
|
||||
layout.addView(editText, lp)
|
||||
setView(layout)
|
||||
return editText
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
entries: List<CharSequence>,
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
if (entries.isEmpty()) {
|
||||
return setEditText(inputType, singleLine)
|
||||
}
|
||||
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
|
||||
binding.autoCompleteTextView.setAdapter(
|
||||
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
|
||||
)
|
||||
binding.dropdown.setOnClickListener {
|
||||
binding.autoCompleteTextView.showDropDown()
|
||||
}
|
||||
binding.autoCompleteTextView.inputType = inputType
|
||||
if (singleLine) {
|
||||
binding.autoCompleteTextView.setSingleLine()
|
||||
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
setView(binding.root)
|
||||
return binding.autoCompleteTextView
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipOnLongClickListener = OnLongClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
|
||||
}
|
||||
private val chipStyle: Int
|
||||
private val iconsVisible: Boolean
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
|
||||
var onChipLongClickListener: OnChipLongClickListener? = null
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
setOnCloseIconClickListener(chipOnCloseListener)
|
||||
setEnsureMinTouchTargetSize(false)
|
||||
setOnClickListener(chipOnClickListener)
|
||||
setOnLongClickListener(chipOnLongClickListener)
|
||||
isElegantTextHeight = false
|
||||
}
|
||||
|
||||
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
||||
}
|
||||
|
||||
fun interface OnChipLongClickListener {
|
||||
|
||||
fun onChipLongClick(chip: Chip, data: Any?): Boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class TouchBlockLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
var isTouchEventsAllowed = true
|
||||
|
||||
override fun onInterceptTouchEvent(
|
||||
ev: MotionEvent?
|
||||
): Boolean = if (isTouchEventsAllowed) {
|
||||
super.onInterceptTouchEvent(ev)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -99,10 +99,11 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
val binding = viewBinding ?: return
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
}
|
||||
val binding = viewBinding ?: return
|
||||
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
|
||||
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
|
||||
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -11,6 +12,7 @@ import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||
import org.koitharu.kotatsu.core.util.ext.toSet
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
@@ -78,11 +80,20 @@ class ChaptersSelectionCallback(
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
|
||||
try {
|
||||
Snackbar.make(
|
||||
recyclerView,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG,
|
||||
).show()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTraceDebug()
|
||||
Toast.makeText(
|
||||
recyclerView.context,
|
||||
R.string.chapters_will_removed_background,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
mode?.finish()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.SetSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.element
|
||||
import kotlinx.serialization.encoding.CompositeDecoder
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.encoding.encodeStructure
|
||||
import kotlinx.serialization.serializer
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import java.util.Locale
|
||||
|
||||
object MangaListFilterSerializer : KSerializer<MangaListFilter> {
|
||||
|
||||
override val descriptor: SerialDescriptor =
|
||||
buildClassSerialDescriptor(MangaListFilter::class.java.name) {
|
||||
element<String?>("query", isOptional = true)
|
||||
element(
|
||||
elementName = "tags",
|
||||
descriptor = SetSerializer(MangaTagSerializer).descriptor,
|
||||
isOptional = true,
|
||||
)
|
||||
element(
|
||||
elementName = "tagsExclude",
|
||||
descriptor = SetSerializer(MangaTagSerializer).descriptor,
|
||||
isOptional = true,
|
||||
)
|
||||
element<String?>("locale", isOptional = true)
|
||||
element<String?>("originalLocale", isOptional = true)
|
||||
element<Set<MangaState>>("states", isOptional = true)
|
||||
element<Set<ContentRating>>("contentRating", isOptional = true)
|
||||
element<Set<ContentType>>("types", isOptional = true)
|
||||
element<Set<Demographic>>("demographics", isOptional = true)
|
||||
element<Int>("year", isOptional = true)
|
||||
element<Int>("yearFrom", isOptional = true)
|
||||
element<Int>("yearTo", isOptional = true)
|
||||
element<String?>("author", isOptional = true)
|
||||
}
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: MangaListFilter
|
||||
) = encoder.encodeStructure(descriptor) {
|
||||
encodeNullableSerializableElement(descriptor, 0, String.serializer(), value.query)
|
||||
encodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer), value.tags)
|
||||
encodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer), value.tagsExclude)
|
||||
encodeNullableSerializableElement(descriptor, 3, String.serializer(), value.locale?.toLanguageTag())
|
||||
encodeNullableSerializableElement(descriptor, 4, String.serializer(), value.originalLocale?.toLanguageTag())
|
||||
encodeSerializableElement(descriptor, 5, SetSerializer(serializer()), value.states)
|
||||
encodeSerializableElement(descriptor, 6, SetSerializer(serializer()), value.contentRating)
|
||||
encodeSerializableElement(descriptor, 7, SetSerializer(serializer()), value.types)
|
||||
encodeSerializableElement(descriptor, 8, SetSerializer(serializer()), value.demographics)
|
||||
encodeIntElement(descriptor, 9, value.year)
|
||||
encodeIntElement(descriptor, 10, value.yearFrom)
|
||||
encodeIntElement(descriptor, 11, value.yearTo)
|
||||
encodeNullableSerializableElement(descriptor, 12, String.serializer(), value.author)
|
||||
}
|
||||
|
||||
override fun deserialize(
|
||||
decoder: Decoder
|
||||
): MangaListFilter = decoder.decodeStructure(descriptor) {
|
||||
var query: String? = MangaListFilter.EMPTY.query
|
||||
var tags: Set<MangaTag> = MangaListFilter.EMPTY.tags
|
||||
var tagsExclude: Set<MangaTag> = MangaListFilter.EMPTY.tagsExclude
|
||||
var locale: Locale? = MangaListFilter.EMPTY.locale
|
||||
var originalLocale: Locale? = MangaListFilter.EMPTY.originalLocale
|
||||
var states: Set<MangaState> = MangaListFilter.EMPTY.states
|
||||
var contentRating: Set<ContentRating> = MangaListFilter.EMPTY.contentRating
|
||||
var types: Set<ContentType> = MangaListFilter.EMPTY.types
|
||||
var demographics: Set<Demographic> = MangaListFilter.EMPTY.demographics
|
||||
var year: Int = MangaListFilter.EMPTY.year
|
||||
var yearFrom: Int = MangaListFilter.EMPTY.yearFrom
|
||||
var yearTo: Int = MangaListFilter.EMPTY.yearTo
|
||||
var author: String? = MangaListFilter.EMPTY.author
|
||||
|
||||
while (true) {
|
||||
when (decodeElementIndex(descriptor)) {
|
||||
0 -> query = decodeNullableSerializableElement(descriptor, 0, serializer<String>())
|
||||
1 -> tags = decodeSerializableElement(descriptor, 1, SetSerializer(MangaTagSerializer))
|
||||
2 -> tagsExclude = decodeSerializableElement(descriptor, 2, SetSerializer(MangaTagSerializer))
|
||||
3 -> locale = decodeNullableSerializableElement(descriptor, 3, serializer<String>())?.toLocaleOrNull()
|
||||
4 -> originalLocale =
|
||||
decodeNullableSerializableElement(descriptor, 4, serializer<String>())?.toLocaleOrNull()
|
||||
|
||||
5 -> states = decodeSerializableElement(descriptor, 5, SetSerializer(serializer()))
|
||||
6 -> contentRating = decodeSerializableElement(descriptor, 6, SetSerializer(serializer()))
|
||||
7 -> types = decodeSerializableElement(descriptor, 7, SetSerializer(serializer()))
|
||||
8 -> demographics = decodeSerializableElement(descriptor, 8, SetSerializer(serializer()))
|
||||
9 -> year = decodeIntElement(descriptor, 9)
|
||||
10 -> yearFrom = decodeIntElement(descriptor, 10)
|
||||
11 -> yearTo = decodeIntElement(descriptor, 11)
|
||||
12 -> author = decodeNullableSerializableElement(descriptor, 12, serializer<String>())
|
||||
CompositeDecoder.DECODE_DONE -> break
|
||||
}
|
||||
}
|
||||
|
||||
MangaListFilter(
|
||||
query = query,
|
||||
tags = tags,
|
||||
tagsExclude = tagsExclude,
|
||||
locale = locale,
|
||||
originalLocale = originalLocale,
|
||||
states = states,
|
||||
contentRating = contentRating,
|
||||
types = types,
|
||||
demographics = demographics,
|
||||
year = year,
|
||||
yearFrom = yearFrom,
|
||||
yearTo = yearTo,
|
||||
author = author,
|
||||
)
|
||||
}
|
||||
|
||||
private object MangaTagSerializer : KSerializer<MangaTag> {
|
||||
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(MangaTag::class.java.name) {
|
||||
element<String>("title")
|
||||
element<String>("key")
|
||||
element<String>("source")
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: MangaTag) = encoder.encodeStructure(descriptor) {
|
||||
encodeStringElement(descriptor, 0, value.title)
|
||||
encodeStringElement(descriptor, 1, value.key)
|
||||
encodeStringElement(descriptor, 2, value.source.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): MangaTag = decoder.decodeStructure(descriptor) {
|
||||
var title: String? = null
|
||||
var key: String? = null
|
||||
var source: String? = null
|
||||
|
||||
while (true) {
|
||||
when (decodeElementIndex(descriptor)) {
|
||||
0 -> title = decodeStringElement(descriptor, 0)
|
||||
1 -> key = decodeStringElement(descriptor, 1)
|
||||
2 -> source = decodeStringElement(descriptor, 2)
|
||||
CompositeDecoder.DECODE_DONE -> break
|
||||
}
|
||||
}
|
||||
|
||||
MangaTag(
|
||||
title = title ?: error("Missing 'title' field"),
|
||||
key = key ?: error("Missing 'key' field"),
|
||||
source = MangaSource(source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceSerializer
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@Serializable
|
||||
@JsonIgnoreUnknownKeys
|
||||
data class PersistableFilter(
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
@Serializable(with = MangaSourceSerializer::class)
|
||||
@SerialName("source")
|
||||
val source: MangaSource,
|
||||
@Serializable(with = MangaListFilterSerializer::class)
|
||||
@SerialName("filter")
|
||||
val filter: MangaListFilter,
|
||||
) {
|
||||
|
||||
val id: Int
|
||||
get() = name.hashCode()
|
||||
|
||||
companion object {
|
||||
|
||||
const val MAX_TITLE_LENGTH = 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.koitharu.kotatsu.filter.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koitharu.kotatsu.core.util.ext.observeChanges
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class SavedFiltersRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun observeAll(source: MangaSource): Flow<List<PersistableFilter>> = getPrefs(source).observeChanges()
|
||||
.onStart { emit(null) }
|
||||
.map {
|
||||
getAll(source)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
suspend fun getAll(source: MangaSource): List<PersistableFilter> = withContext(Dispatchers.Default) {
|
||||
val prefs = getPrefs(source)
|
||||
val keys = prefs.all.keys.filter { it.startsWith(FILTER_PREFIX) }
|
||||
keys.mapNotNull { key ->
|
||||
val value = prefs.getString(key, null) ?: return@mapNotNull null
|
||||
try {
|
||||
Json.decodeFromString(value)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(
|
||||
source: MangaSource,
|
||||
name: String,
|
||||
filter: MangaListFilter,
|
||||
): PersistableFilter = withContext(Dispatchers.Default) {
|
||||
val persistableFilter = PersistableFilter(
|
||||
name = name,
|
||||
source = source,
|
||||
filter = filter,
|
||||
)
|
||||
persist(source, persistableFilter)
|
||||
persistableFilter
|
||||
}
|
||||
|
||||
suspend fun rename(source: MangaSource, id: Int, newName: String) = withContext(Dispatchers.Default) {
|
||||
val filter = load(source, id) ?: return@withContext
|
||||
val newFilter = filter.copy(name = newName)
|
||||
val prefs = getPrefs(source)
|
||||
prefs.edit(commit = true) {
|
||||
remove(key(id))
|
||||
putString(key(newFilter.id), Json.encodeToString(newFilter))
|
||||
}
|
||||
newFilter
|
||||
}
|
||||
|
||||
suspend fun delete(source: MangaSource, id: Int) = withContext(Dispatchers.Default) {
|
||||
val prefs = getPrefs(source)
|
||||
prefs.edit(commit = true) {
|
||||
remove(key(id))
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(source: MangaSource, persistableFilter: PersistableFilter) {
|
||||
val prefs = getPrefs(source)
|
||||
val json = Json.encodeToString(persistableFilter)
|
||||
prefs.edit(commit = true) {
|
||||
putString(key(persistableFilter.id), json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(source: MangaSource, id: Int): PersistableFilter? {
|
||||
val prefs = getPrefs(source)
|
||||
val json = prefs.getString(key(id), null) ?: return null
|
||||
return try {
|
||||
Json.decodeFromString<PersistableFilter>(json)
|
||||
} catch (e: SerializationException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPrefs(source: MangaSource): SharedPreferences {
|
||||
val key = source.name.replace(File.separatorChar, '$')
|
||||
return context.getSharedPreferences(key, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val FILTER_PREFIX = "__pf_"
|
||||
|
||||
fun key(id: Int) = FILTER_PREFIX + id
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -25,6 +26,8 @@ import org.koitharu.kotatsu.core.util.ext.asFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.filter.ui.tags.TagTitleComparator
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -51,6 +54,7 @@ class FilterCoordinator @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val savedFiltersRepository: SavedFiltersRepository,
|
||||
lifecycle: ViewModelLifecycle,
|
||||
) {
|
||||
|
||||
@@ -63,6 +67,7 @@ class FilterCoordinator @Inject constructor(
|
||||
|
||||
private val availableSortOrders = repository.sortOrders
|
||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||
|
||||
val capabilities = repository.filterCapabilities
|
||||
|
||||
val mangaSource: MangaSource
|
||||
@@ -119,6 +124,20 @@ class FilterCoordinator @Inject constructor(
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val authors: StateFlow<FilterProperty<String>> = if (capabilities.isAuthorSearchSupported) {
|
||||
combine(
|
||||
flow { emit(searchRepository.getAuthors(repository.source, TAGS_LIMIT)) },
|
||||
currentListFilter.distinctUntilChangedBy { it.author },
|
||||
) { available, selected ->
|
||||
FilterProperty(
|
||||
availableItems = available,
|
||||
selectedItems = setOfNotNull(selected.author),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.LOADING)
|
||||
} else {
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val states: StateFlow<FilterProperty<MangaState>> = combine(
|
||||
filterOptions.asFlow(),
|
||||
currentListFilter.distinctUntilChangedBy { it.states },
|
||||
@@ -249,6 +268,16 @@ class FilterCoordinator @Inject constructor(
|
||||
MutableStateFlow(FilterProperty.EMPTY)
|
||||
}
|
||||
|
||||
val savedFilters: StateFlow<FilterProperty<PersistableFilter>> = combine(
|
||||
savedFiltersRepository.observeAll(repository.source),
|
||||
currentListFilter,
|
||||
) { available, applied ->
|
||||
FilterProperty(
|
||||
availableItems = available,
|
||||
selectedItems = setOfNotNull(available.find { it.filter == applied }),
|
||||
)
|
||||
}.stateIn(coroutineScope, SharingStarted.Lazily, FilterProperty.EMPTY)
|
||||
|
||||
fun reset() {
|
||||
currentListFilter.value = MangaListFilter.EMPTY
|
||||
}
|
||||
@@ -277,17 +306,24 @@ class FilterCoordinator @Inject constructor(
|
||||
author = null,
|
||||
)
|
||||
}
|
||||
if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) {
|
||||
newFilter = newFilter.copy(
|
||||
query = null,
|
||||
)
|
||||
}
|
||||
if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) {
|
||||
newFilter = MangaListFilter(query = newFilter.query)
|
||||
}
|
||||
set(newFilter)
|
||||
}
|
||||
|
||||
fun saveCurrentFilter(name: String) = coroutineScope.launch {
|
||||
savedFiltersRepository.save(repository.source, name, currentListFilter.value)
|
||||
}
|
||||
|
||||
fun renameSavedFilter(id: Int, newName: String) = coroutineScope.launch {
|
||||
savedFiltersRepository.rename(repository.source, id, newName)
|
||||
}
|
||||
|
||||
fun deleteSavedFilter(id: Int) = coroutineScope.launch {
|
||||
savedFiltersRepository.delete(repository.source, id)
|
||||
}
|
||||
|
||||
fun setQuery(value: String?) {
|
||||
val newQuery = value?.trim()?.nullIfEmpty()
|
||||
currentListFilter.update { oldValue ->
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
@@ -54,6 +55,12 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
|
||||
is PersistableFilter -> if (chip.isChecked) {
|
||||
filter.reset()
|
||||
} else {
|
||||
filter.setAdjusted(data.filter)
|
||||
}
|
||||
|
||||
is String -> Unit
|
||||
null -> router.showTagsCatalogSheet(excludeMode = false)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
@@ -21,10 +22,15 @@ class FilterHeaderProducer @Inject constructor(
|
||||
) {
|
||||
|
||||
fun observeHeader(filterCoordinator: FilterCoordinator): Flow<FilterHeaderModel> {
|
||||
return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot ->
|
||||
return combine(
|
||||
filterCoordinator.savedFilters,
|
||||
filterCoordinator.tags,
|
||||
filterCoordinator.observe(),
|
||||
) { saved, tags, snapshot ->
|
||||
val chipList = createChipsList(
|
||||
source = filterCoordinator.mangaSource,
|
||||
capabilities = filterCoordinator.capabilities,
|
||||
savedFilters = saved,
|
||||
tagsProperty = tags,
|
||||
snapshot = snapshot.listFilter,
|
||||
limit = 12,
|
||||
@@ -40,11 +46,12 @@ class FilterHeaderProducer @Inject constructor(
|
||||
private suspend fun createChipsList(
|
||||
source: MangaSource,
|
||||
capabilities: MangaListFilterCapabilities,
|
||||
savedFilters: FilterProperty<PersistableFilter>,
|
||||
tagsProperty: FilterProperty<MangaTag>,
|
||||
snapshot: MangaListFilter,
|
||||
limit: Int,
|
||||
): List<ChipsView.ChipModel> {
|
||||
val result = ArrayDeque<ChipsView.ChipModel>(limit + 3)
|
||||
val result = ArrayDeque<ChipsView.ChipModel>(savedFilters.availableItems.size + limit + 3)
|
||||
if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) {
|
||||
val selectedTags = tagsProperty.selectedItems.toMutableSet()
|
||||
var tags = if (selectedTags.isEmpty()) {
|
||||
@@ -58,6 +65,19 @@ class FilterHeaderProducer @Inject constructor(
|
||||
if (tags.isEmpty() && selectedTags.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
for (saved in savedFilters.availableItems) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = saved.name,
|
||||
isChecked = saved in savedFilters.selectedItems,
|
||||
data = saved,
|
||||
)
|
||||
if (model.isChecked) {
|
||||
selectedTags.removeAll(saved.filter.tags)
|
||||
result.addFirst(model)
|
||||
} else {
|
||||
result.addLast(model)
|
||||
}
|
||||
}
|
||||
for (tag in tags) {
|
||||
val model = ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
package org.koitharu.kotatsu.filter.ui.sheet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.setEditText
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||
@@ -26,6 +39,8 @@ import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter.Companion.MAX_TITLE_LENGTH
|
||||
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
@@ -35,12 +50,17 @@ import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
import java.util.Locale
|
||||
import java.util.TreeSet
|
||||
|
||||
class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
AdapterView.OnItemSelectedListener,
|
||||
ChipsView.OnChipClickListener {
|
||||
View.OnClickListener,
|
||||
ChipsView.OnChipClickListener,
|
||||
ChipsView.OnChipLongClickListener,
|
||||
ChipsView.OnChipCloseClickListener {
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||
return SheetFilterBinding.inflate(inflater, container, false)
|
||||
@@ -58,12 +78,14 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
|
||||
filter.tags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||
filter.tagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged)
|
||||
filter.authors.observe(viewLifecycleOwner, this::onAuthorsChanged)
|
||||
filter.states.observe(viewLifecycleOwner, this::onStateChanged)
|
||||
filter.contentTypes.observe(viewLifecycleOwner, this::onContentTypesChanged)
|
||||
filter.contentRating.observe(viewLifecycleOwner, this::onContentRatingChanged)
|
||||
filter.demographics.observe(viewLifecycleOwner, this::onDemographicsChanged)
|
||||
filter.year.observe(viewLifecycleOwner, this::onYearChanged)
|
||||
filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged)
|
||||
filter.savedFilters.observe(viewLifecycleOwner, ::onSavedPresetsChanged)
|
||||
|
||||
binding.layoutGenres.setTitle(
|
||||
if (filter.capabilities.isMultipleTagsSupported) {
|
||||
@@ -75,12 +97,16 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
binding.spinnerLocale.onItemSelectedListener = this
|
||||
binding.spinnerOriginalLocale.onItemSelectedListener = this
|
||||
binding.spinnerOrder.onItemSelectedListener = this
|
||||
binding.chipsSavedFilters.onChipClickListener = this
|
||||
binding.chipsState.onChipClickListener = this
|
||||
binding.chipsTypes.onChipClickListener = this
|
||||
binding.chipsContentRating.onChipClickListener = this
|
||||
binding.chipsDemographics.onChipClickListener = this
|
||||
binding.chipsGenres.onChipClickListener = this
|
||||
binding.chipsGenresExclude.onChipClickListener = this
|
||||
binding.chipsAuthor.onChipClickListener = this
|
||||
binding.chipsSavedFilters.onChipLongClickListener = this
|
||||
binding.chipsSavedFilters.onChipCloseClickListener = this
|
||||
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
|
||||
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
|
||||
binding.layoutGenres.setOnMoreButtonClickListener {
|
||||
@@ -89,16 +115,33 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
binding.layoutGenresExclude.setOnMoreButtonClickListener {
|
||||
router.showTagsCatalogSheet(excludeMode = true)
|
||||
}
|
||||
combine(
|
||||
filter.observe().map { it.listFilter.isNotEmpty() }.distinctUntilChanged(),
|
||||
filter.savedFilters.map { it.selectedItems.isEmpty() }.distinctUntilChanged(),
|
||||
Boolean::and,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(viewLifecycleOwner) {
|
||||
binding.buttonSave.isEnabled = it
|
||||
}
|
||||
binding.buttonSave.setOnClickListener(this)
|
||||
binding.buttonDone.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
viewBinding?.layoutBottom?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.getInsets(typeMask).bottom
|
||||
}
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_done -> dismiss()
|
||||
R.id.button_save -> onSaveFilterClick("")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
when (parent.id) {
|
||||
@@ -157,10 +200,35 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
|
||||
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
|
||||
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
|
||||
is PersistableFilter -> filter.setAdjusted(data.filter)
|
||||
is String -> if (chip.isChecked) {
|
||||
filter.setAuthor(null)
|
||||
} else {
|
||||
filter.setAuthor(data)
|
||||
}
|
||||
null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipLongClick(chip: Chip, data: Any?): Boolean {
|
||||
return when (data) {
|
||||
is PersistableFilter -> {
|
||||
showSavedFilterMenu(chip, data)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipCloseClick(chip: Chip, data: Any?) {
|
||||
when (data) {
|
||||
is PersistableFilter -> {
|
||||
showSavedFilterMenu(chip, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutOrder.isGone = value.isEmpty()
|
||||
@@ -251,6 +319,22 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
b.chipsGenresExclude.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onAuthorsChanged(value: FilterProperty<String>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutAuthor.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { author ->
|
||||
ChipsView.ChipModel(
|
||||
title = author,
|
||||
isChecked = author in value.selectedItems,
|
||||
data = author,
|
||||
)
|
||||
}
|
||||
b.chipsAuthor.setChips(chips)
|
||||
}
|
||||
|
||||
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutState.isGone = value.isEmpty()
|
||||
@@ -353,4 +437,101 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
)
|
||||
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
|
||||
}
|
||||
|
||||
private fun onSavedPresetsChanged(value: FilterProperty<PersistableFilter>) {
|
||||
val b = viewBinding ?: return
|
||||
b.layoutSavedFilters.isGone = value.isEmpty()
|
||||
if (value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val chips = value.availableItems.map { f ->
|
||||
ChipsView.ChipModel(
|
||||
title = f.name,
|
||||
isChecked = f in value.selectedItems,
|
||||
data = f,
|
||||
isDropdown = true,
|
||||
)
|
||||
}
|
||||
b.chipsSavedFilters.setChips(chips)
|
||||
}
|
||||
|
||||
private fun showSavedFilterMenu(anchor: View, preset: PersistableFilter) {
|
||||
val menu = PopupMenu(context ?: return, anchor)
|
||||
val filter = FilterCoordinator.require(this)
|
||||
menu.inflate(R.menu.popup_saved_filter)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_delete -> filter.deleteSavedFilter(preset.id)
|
||||
R.id.action_rename -> onRenameFilterClick(preset)
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun onSaveFilterClick(name: String) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
val existingNames = filter.savedFilters.value.availableItems
|
||||
.mapTo(TreeSet(AlphanumComparator()), PersistableFilter::name)
|
||||
buildAlertDialog(context ?: return) {
|
||||
val input = setEditText(
|
||||
entries = existingNames.toList(),
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
|
||||
singleLine = true,
|
||||
)
|
||||
input.setHint(R.string.enter_name)
|
||||
input.setText(name)
|
||||
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
|
||||
setTitle(R.string.save_filter)
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (text.isNullOrEmpty()) {
|
||||
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
|
||||
onSaveFilterClick("")
|
||||
} else if (text in existingNames) {
|
||||
askForFilterOverwrite(filter, text)
|
||||
} else {
|
||||
filter.saveCurrentFilter(text)
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun onRenameFilterClick(preset: PersistableFilter) {
|
||||
val filter = FilterCoordinator.require(this)
|
||||
val existingNames = filter.savedFilters.value.availableItems.mapToSet { it.name }
|
||||
buildAlertDialog(context ?: return) {
|
||||
val input = setEditText(
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES,
|
||||
singleLine = true,
|
||||
)
|
||||
input.filters += InputFilter.LengthFilter(MAX_TITLE_LENGTH)
|
||||
input.setHint(R.string.enter_name)
|
||||
input.setText(preset.name)
|
||||
setTitle(R.string.rename)
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
val text = input.text?.toString()?.trim()
|
||||
if (text.isNullOrEmpty() || text in existingNames) {
|
||||
Toast.makeText(context, R.string.invalid_value_message, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
filter.renameSavedFilter(preset.id, text)
|
||||
}
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun askForFilterOverwrite(filter: FilterCoordinator, name: String) {
|
||||
buildAlertDialog(context ?: return) {
|
||||
setTitle(R.string.save_filter)
|
||||
setMessage(getString(R.string.filter_overwrite_confirm, name))
|
||||
setPositiveButton(R.string.overwrite) { _, _ ->
|
||||
filter.saveCurrentFilter(name)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
onSaveFilterClick(name)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.BackgroundServiceStartNotAllowedException
|
||||
import android.app.ServiceStartNotAllowedException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
@@ -58,6 +60,7 @@ import org.koitharu.kotatsu.core.util.ext.consume
|
||||
import org.koitharu.kotatsu.core.util.ext.end
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.databinding.ActivityMainBinding
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
@@ -288,7 +291,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
adjustFabVisibility(isResumeEnabled = isEnabled)
|
||||
}
|
||||
|
||||
private fun onFirstStart() {
|
||||
private fun onFirstStart() = try {
|
||||
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
|
||||
withContext(Dispatchers.Default) {
|
||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||
@@ -303,6 +306,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
private fun adjustAppbar(topFragment: Fragment) {
|
||||
|
||||
@@ -488,7 +488,11 @@ class ReaderActivity :
|
||||
uiState.incognito -> getString(R.string.incognito_mode)
|
||||
else -> chapterTitle
|
||||
}
|
||||
if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) {
|
||||
if (
|
||||
settings.isReaderChapterToastEnabled &&
|
||||
chapterTitle != previous?.getChapterTitle(resources) &&
|
||||
chapterTitle.isNotEmpty()
|
||||
) {
|
||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner, View.OnClickListener {
|
||||
|
||||
override val viewModel by viewModels<RemoteListViewModel>()
|
||||
|
||||
@@ -42,6 +42,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
||||
viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||
viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) }
|
||||
viewModel.onSourceBroken.observeEvent(viewLifecycleOwner) { showSourceBrokenWarning() }
|
||||
filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() }
|
||||
.drop(1)
|
||||
.observe(viewLifecycleOwner) {
|
||||
@@ -87,6 +88,8 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
openInBrowser(error.getCauseUrl())
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) = Unit // from Snackbar, do nothing
|
||||
|
||||
private fun openInBrowser(url: String?) {
|
||||
if (url?.isHttpUrl() == true) {
|
||||
router.openBrowser(
|
||||
@@ -100,6 +103,16 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSourceBrokenWarning() {
|
||||
val snackbar = Snackbar.make(
|
||||
viewBinding?.recyclerView ?: return,
|
||||
R.string.source_broken_warning,
|
||||
Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
snackbar.setAction(R.string.got_it, this)
|
||||
snackbar.show()
|
||||
}
|
||||
|
||||
private inner class RemoteListMenuProvider : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -65,6 +66,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
|
||||
val isRandomLoading = MutableStateFlow(false)
|
||||
val onOpenManga = MutableEventFlow<Manga>()
|
||||
val onSourceBroken = MutableEventFlow<Unit>()
|
||||
|
||||
protected val repository = mangaRepositoryFactory.create(source)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
@@ -117,6 +119,11 @@ open class RemoteListViewModel @Inject constructor(
|
||||
launchJob(Dispatchers.Default) {
|
||||
sourcesRepository.trackUsage(source)
|
||||
}
|
||||
|
||||
if (source is MangaParserSource && source.isBroken) {
|
||||
// Just notify one. Will show reason in future
|
||||
onSourceBroken.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.koitharu.kotatsu.scrobbling.common.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Dao
|
||||
abstract class ScrobblingDao {
|
||||
@@ -20,4 +23,20 @@ abstract class ScrobblingDao {
|
||||
|
||||
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
|
||||
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
|
||||
|
||||
@Query("SELECT * FROM scrobblings ORDER BY scrobbler LIMIT :limit OFFSET :offset")
|
||||
protected abstract suspend fun findAll(offset: Int, limit: Int): List<ScrobblingEntity>
|
||||
|
||||
fun dumpEnabled(): Flow<ScrobblingEntity> = flow {
|
||||
val window = 10
|
||||
var offset = 0
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val list = findAll(offset, window)
|
||||
if (list.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += window
|
||||
list.forEach { emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,4 +169,8 @@ class MangaSearchRepository @Inject constructor(
|
||||
null,
|
||||
)?.use { cursor -> cursor.count } ?: 0
|
||||
}
|
||||
|
||||
suspend fun getAuthors(source: MangaSource, limit: Int): List<String> {
|
||||
return db.getMangaDao().findAuthorsBySource(source.name, limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.TwoStatePreference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||
@@ -24,8 +29,10 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
|
||||
@@ -40,6 +47,9 @@ class AppearanceSettingsFragment :
|
||||
@Inject
|
||||
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
||||
|
||||
@Inject
|
||||
lateinit var appShortcutManager: AppShortcutManager
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_appearance)
|
||||
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
|
||||
@@ -68,6 +78,20 @@ class AppearanceSettingsFragment :
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
|
||||
appShortcutManager.isDynamicShortcutsAvailable()
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
|
||||
entryValues = ScreenshotsPolicy.entries.names()
|
||||
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref ->
|
||||
pref.entryValues = SearchSuggestionType.entries.names()
|
||||
pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray()
|
||||
pref.summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
pref.values = settings.searchSuggestionTypes.mapToSet { it.name }
|
||||
}
|
||||
bindNavSummary()
|
||||
}
|
||||
|
||||
@@ -100,6 +124,28 @@ class AppearanceSettingsFragment :
|
||||
AppSettings.KEY_NAV_MAIN -> {
|
||||
bindNavSummary()
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_PROTECT_APP -> {
|
||||
val pref = (preference as? TwoStatePreference ?: return false)
|
||||
if (pref.isChecked) {
|
||||
pref.isChecked = false
|
||||
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
|
||||
} else {
|
||||
settings.appPassword = null
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import java.net.Proxy
|
||||
|
||||
class NetworkSettingsFragment :
|
||||
BasePreferenceFragment(R.string.network),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_network)
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = DoHProvider.entries.names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
bindProxySummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_SSL_BYPASS -> {
|
||||
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
AppSettings.KEY_PROXY_TYPE,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PORT -> {
|
||||
bindProxySummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProxySummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
summary = when {
|
||||
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
|
||||
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
|
||||
else -> "$address:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class RootSettingsFragment : BasePreferenceFragment(0) {
|
||||
addPreferencesFromResource(R.xml.pref_root_debug)
|
||||
bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language)
|
||||
bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages)
|
||||
bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content)
|
||||
bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion)
|
||||
bindPreferenceSummary("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content)
|
||||
bindPreferenceSummary("userdata", R.string.create_or_restore_backup, R.string.periodic_backups)
|
||||
bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only)
|
||||
bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings)
|
||||
bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking)
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SettingsActivity :
|
||||
@@ -146,7 +146,7 @@ class SettingsActivity :
|
||||
val fragment = when (intent?.action) {
|
||||
AppRouter.ACTION_READER -> ReaderSettingsFragment()
|
||||
AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||
AppRouter.ACTION_HISTORY -> UserDataSettingsFragment()
|
||||
AppRouter.ACTION_HISTORY -> BackupsSettingsFragment()
|
||||
AppRouter.ACTION_TRACKER -> TrackerSettingsFragment()
|
||||
AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment()
|
||||
AppRouter.ACTION_SOURCES -> SourcesSettingsFragment()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference
|
||||
import java.net.Proxy
|
||||
|
||||
class StorageAndNetworkSettingsFragment :
|
||||
BasePreferenceFragment(R.string.storage_and_network),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val viewModel by viewModels<StorageAndNetworkSettingsViewModel>()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_network_storage)
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = DoHProvider.entries.names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
bindProxySummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
settings.subscribe(this)
|
||||
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
|
||||
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_SSL_BYPASS -> {
|
||||
Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show()
|
||||
}
|
||||
|
||||
AppSettings.KEY_PROXY_TYPE,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PORT -> {
|
||||
bindProxySummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProxySummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
summary = when {
|
||||
type == Proxy.Type.DIRECT -> context.getString(R.string.disabled)
|
||||
address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration)
|
||||
else -> "$address:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageUsage
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StorageAndNetworkSettingsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val storageUsage: StateFlow<StorageUsage?> = flow {
|
||||
emit(loadStorageUsage())
|
||||
}.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(1000), null)
|
||||
|
||||
private suspend fun loadStorageUsage(): StorageUsage {
|
||||
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
|
||||
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
|
||||
val storageSize = storageManager.computeStorageSize()
|
||||
val availableSpace = storageManager.computeAvailableSize()
|
||||
val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace
|
||||
return StorageUsage(
|
||||
savedManga = StorageUsage.Item(
|
||||
bytes = storageSize,
|
||||
percent = (storageSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
pagesCache = StorageUsage.Item(
|
||||
bytes = pagesCacheSize,
|
||||
percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
otherCache = StorageUsage.Item(
|
||||
bytes = otherCacheSize,
|
||||
percent = (otherCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
available = StorageUsage.Item(
|
||||
bytes = availableSpace,
|
||||
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class AppUpdateActivity : BaseActivity<ActivityAppUpdateBinding>(), View.OnClick
|
||||
viewModel.installIntent.value?.let { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
} catch (e: Exception) {
|
||||
onError(e)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -11,6 +11,6 @@ data class SettingsItem(
|
||||
) : ListModel {
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is SettingsItem && other.key == key
|
||||
return other is SettingsItem && other.key == key && other.fragmentClass == fragmentClass
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,17 @@ import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragme
|
||||
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||
import org.koitharu.kotatsu.settings.AppearanceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.DownloadsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.NetworkSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ProxySettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ReaderSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.ServicesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -37,13 +38,18 @@ class SettingsSearchHelper @Inject constructor(
|
||||
preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_storage,
|
||||
listOf(context.getString(R.string.data_and_privacy)),
|
||||
StorageManageSettingsFragment::class.java,
|
||||
R.xml.pref_network_storage,
|
||||
emptyList(),
|
||||
StorageAndNetworkSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_data_cleanup,
|
||||
listOf(context.getString(R.string.storage_and_network)),
|
||||
DataCleanupSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java)
|
||||
preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java)
|
||||
@@ -52,25 +58,31 @@ class SettingsSearchHelper @Inject constructor(
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_backup_periodic,
|
||||
listOf(context.getString(R.string.data_and_privacy)),
|
||||
listOf(context.getString(R.string.backup_restore)),
|
||||
PeriodicalBackupSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_proxy,
|
||||
listOf(context.getString(R.string.proxy)),
|
||||
listOf(context.getString(R.string.storage_and_network)),
|
||||
ProxySettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_suggestions,
|
||||
listOf(context.getString(R.string.suggestions)),
|
||||
listOf(context.getString(R.string.services)),
|
||||
SuggestionsSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_discord,
|
||||
listOf(context.getString(R.string.services)),
|
||||
DiscordSettingsFragment::class.java,
|
||||
)
|
||||
preferenceManager.inflateTo(
|
||||
result,
|
||||
R.xml.pref_sources,
|
||||
listOf(context.getString(R.string.remote_sources)),
|
||||
listOf(),
|
||||
SourcesSettingsFragment::class.java,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -11,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
@@ -31,6 +32,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources),
|
||||
entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray()
|
||||
setDefaultValueCompat(SourcesSortOrder.MANUAL.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
|
||||
entryValues = TriStateOption.entries.names()
|
||||
setDefaultValueCompat(TriStateOption.ASK.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.koitharu.kotatsu.settings.userdata
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||
import org.koitharu.kotatsu.backups.ui.backup.BackupService
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BackupsSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
|
||||
ActivityResultCallback<Uri?> {
|
||||
|
||||
private val viewModel: BackupsSettingsViewModel by viewModels()
|
||||
|
||||
private val backupSelectCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
this,
|
||||
)
|
||||
|
||||
private val backupCreateCall = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/zip"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
if (!BackupService.start(requireContext(), uri)) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_backups)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindPeriodicalBackupSummary()
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_BACKUP -> {
|
||||
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_RESTORE -> {
|
||||
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
if (result != null) {
|
||||
router.showBackupRestoreDialog(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPeriodicalBackupSummary() {
|
||||
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
|
||||
val entries = resources.getStringArray(R.array.backup_frequency)
|
||||
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
|
||||
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
|
||||
preference.summary = if (freq == 0L) {
|
||||
getString(R.string.disabled)
|
||||
} else {
|
||||
val index = entryValues.indexOf(freq.toString())
|
||||
entries.getOrNull(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.settings.userdata
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BackupsSettingsViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val periodicalBackupFrequency = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
|
||||
valueProducer = { isPeriodicalBackupEnabled },
|
||||
).flatMapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
settings.observeAsFlow(
|
||||
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
|
||||
valueProducer = { periodicalBackupFrequency },
|
||||
)
|
||||
} else {
|
||||
flowOf(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.userdata
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.TwoStatePreference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||
import org.koitharu.kotatsu.backups.ui.backup.BackupService
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.SearchSuggestionType
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
ActivityResultCallback<Uri?> {
|
||||
|
||||
@Inject
|
||||
lateinit var appShortcutManager: AppShortcutManager
|
||||
|
||||
private val viewModel: UserDataSettingsViewModel by viewModels()
|
||||
|
||||
private val backupSelectCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
this,
|
||||
)
|
||||
|
||||
private val backupCreateCall = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/zip"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
if (!BackupService.start(requireContext(), uri)) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_user_data)
|
||||
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
|
||||
appShortcutManager.isDynamicShortcutsAvailable()
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
|
||||
entryValues = ScreenshotsPolicy.entries.names()
|
||||
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
|
||||
entryValues = TriStateOption.entries.names()
|
||||
setDefaultValueCompat(TriStateOption.ASK.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindPeriodicalBackupSummary()
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref ->
|
||||
pref.entryValues = SearchSuggestionType.entries.names()
|
||||
pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray()
|
||||
pref.summaryProvider = MultiSummaryProvider(R.string.none)
|
||||
pref.values = settings.searchSuggestionTypes.mapToSet { it.name }
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
|
||||
viewModel.storageUsage.observe(viewLifecycleOwner) { size ->
|
||||
pref.summary = if (size < 0L) {
|
||||
pref.context.getString(R.string.computing_)
|
||||
} else {
|
||||
FileSize.BYTES.format(pref.context, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_BACKUP -> {
|
||||
if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_RESTORE -> {
|
||||
if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) {
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_PROTECT_APP -> {
|
||||
val pref = (preference as? TwoStatePreference ?: return false)
|
||||
if (pref.isChecked) {
|
||||
pref.isChecked = false
|
||||
startActivity(Intent(preference.context, ProtectSetupActivity::class.java))
|
||||
} else {
|
||||
settings.appPassword = null
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
if (result != null) {
|
||||
router.showBackupRestoreDialog(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPeriodicalBackupSummary() {
|
||||
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
|
||||
val entries = resources.getStringArray(R.array.backup_frequency)
|
||||
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
|
||||
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
|
||||
preference.summary = if (freq == 0L) {
|
||||
getString(R.string.disabled)
|
||||
} else {
|
||||
val index = entryValues.indexOf(freq.toString())
|
||||
entries.getOrNull(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.userdata
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UserDataSettingsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val storageUsage = MutableStateFlow(-1L)
|
||||
|
||||
val periodicalBackupFrequency = settings.observeAsFlow(
|
||||
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
|
||||
valueProducer = { isPeriodicalBackupEnabled },
|
||||
).flatMapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
settings.observeAsFlow(
|
||||
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
|
||||
valueProducer = { periodicalBackupFrequency },
|
||||
)
|
||||
} else {
|
||||
flowOf(0)
|
||||
}
|
||||
}
|
||||
|
||||
private var storageUsageJob: Job? = null
|
||||
|
||||
init {
|
||||
loadStorageUsage()
|
||||
}
|
||||
|
||||
private fun loadStorageUsage(): Job {
|
||||
val prevJob = storageUsageJob
|
||||
return launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize()
|
||||
storageUsage.value = totalBytes
|
||||
}.also {
|
||||
storageUsageJob = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package org.koitharu.kotatsu.settings.userdata.storage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) {
|
||||
|
||||
private val viewModel by viewModels<DataCleanupSettingsViewModel>()
|
||||
private val loadingPrefs = HashSet<String>()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_data_cleanup)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
|
||||
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
|
||||
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
|
||||
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
|
||||
pref.summary = if (it < 0) {
|
||||
view.context.getString(R.string.loading_)
|
||||
} else {
|
||||
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
|
||||
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
|
||||
pref.summary = if (it < 0) {
|
||||
view.context.getString(R.string.loading_)
|
||||
} else {
|
||||
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
|
||||
|
||||
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
|
||||
loadingPrefs.addAll(keys)
|
||||
loadingPrefs.forEach { prefKey ->
|
||||
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
|
||||
}
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
clearCookies()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
|
||||
clearSearchHistory()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
|
||||
viewModel.clearCache(preference.key, CacheDir.PAGES)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
|
||||
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
|
||||
viewModel.clearHttpCache()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_CHAPTERS_CLEAR -> {
|
||||
cleanupChapters()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_WEBVIEW_CLEAR -> {
|
||||
viewModel.clearBrowserData()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_CLEAR_MANGA_DATA -> {
|
||||
viewModel.clearMangaData()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
|
||||
viewModel.clearUpdatesFeed()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
|
||||
val c = context ?: return
|
||||
val text = if (result.first == 0 && result.second == 0L) {
|
||||
c.getString(R.string.no_chapters_deleted)
|
||||
} else {
|
||||
c.getString(
|
||||
R.string.chapters_deleted_pattern,
|
||||
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
|
||||
FileSize.BYTES.format(c, result.second),
|
||||
)
|
||||
}
|
||||
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
|
||||
stateFlow.observe(viewLifecycleOwner) { size ->
|
||||
summary = if (size < 0) {
|
||||
context.getString(R.string.computing_)
|
||||
} else {
|
||||
FileSize.BYTES.format(context, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearSearchHistory() {
|
||||
buildAlertDialog(context ?: return) {
|
||||
setTitle(R.string.clear_search_history)
|
||||
setMessage(R.string.text_clear_search_history_prompt)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.clearSearchHistory()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun clearCookies() {
|
||||
buildAlertDialog(context ?: return) {
|
||||
setTitle(R.string.clear_cookies)
|
||||
setMessage(R.string.text_clear_cookies_prompt)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.clearCookies()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun cleanupChapters() {
|
||||
buildAlertDialog(context ?: return) {
|
||||
setTitle(R.string.delete_read_chapters)
|
||||
setMessage(R.string.delete_read_chapters_prompt)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.cleanupChapters()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.koitharu.kotatsu.settings.userdata.storage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.webkit.WebStorage
|
||||
import androidx.webkit.WebStorageCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import java.util.EnumMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@HiltViewModel
|
||||
class DataCleanupSettingsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val httpCache: Cache,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
|
||||
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
|
||||
private val coil: ImageLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val loadingKeys = MutableStateFlow(emptySet<String>())
|
||||
|
||||
val searchHistoryCount = MutableStateFlow(-1)
|
||||
val feedItemsCount = MutableStateFlow(-1)
|
||||
val httpCacheSize = MutableStateFlow(-1L)
|
||||
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
|
||||
|
||||
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
|
||||
|
||||
val isBrowserDataCleanupEnabled: Boolean
|
||||
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
|
||||
|
||||
init {
|
||||
CacheDir.entries.forEach {
|
||||
cacheSizes[it] = MutableStateFlow(-1L)
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
feedItemsCount.value = trackingRepository.getLogsCount()
|
||||
}
|
||||
CacheDir.entries.forEach { cache ->
|
||||
launchJob(Dispatchers.Default) {
|
||||
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
|
||||
}
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
httpCacheSize.value = runInterruptible { httpCache.size() }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache(key: String, vararg caches: CacheDir) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + key }
|
||||
for (cache in caches) {
|
||||
storageManager.clearCache(cache)
|
||||
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
|
||||
if (cache == CacheDir.THUMBS) {
|
||||
coil.memoryCache?.clear()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loadingKeys.update { it - key }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearHttpCache() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
|
||||
val size = runInterruptible(Dispatchers.IO) {
|
||||
httpCache.evictAll()
|
||||
httpCache.size()
|
||||
}
|
||||
httpCacheSize.value = size
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearchHistory() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
searchRepository.clearSearchHistory()
|
||||
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
|
||||
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCookies() {
|
||||
launchJob {
|
||||
cookieJar.clear()
|
||||
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RequiresFeature")
|
||||
fun clearBrowserData() {
|
||||
launchJob {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
|
||||
val storage = WebStorage.getInstance()
|
||||
suspendCoroutine { cont ->
|
||||
WebStorageCompat.deleteBrowsingData(storage) {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearUpdatesFeed() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
|
||||
trackingRepository.clearLogs()
|
||||
feedItemsCount.value = trackingRepository.getLogsCount()
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMangaData() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
|
||||
trackingRepository.gc()
|
||||
val repository = mangaDataRepositoryProvider.get()
|
||||
repository.cleanupLocalManga()
|
||||
repository.cleanupDatabase()
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanupChapters() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
val oldSize = storageManager.computeStorageSize()
|
||||
val chaptersCount = deleteReadChaptersUseCase.invoke()
|
||||
val newSize = storageManager.computeStorageSize()
|
||||
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.userdata.storage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
|
||||
@AndroidEntryPoint
|
||||
class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) {
|
||||
|
||||
private val viewModel by viewModels<StorageManageSettingsViewModel>()
|
||||
private val loadingPrefs = HashSet<String>()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_storage)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
|
||||
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
|
||||
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
|
||||
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||
viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
|
||||
pref.summary = if (it < 0) {
|
||||
view.context.getString(R.string.loading_)
|
||||
} else {
|
||||
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
|
||||
viewModel.feedItemsCount.observe(viewLifecycleOwner) {
|
||||
pref.summary = if (it < 0) {
|
||||
view.context.getString(R.string.loading_)
|
||||
} else {
|
||||
pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
|
||||
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
|
||||
|
||||
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
|
||||
loadingPrefs.addAll(keys)
|
||||
loadingPrefs.forEach { prefKey ->
|
||||
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
|
||||
}
|
||||
}
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
|
||||
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
clearCookies()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
|
||||
clearSearchHistory()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
|
||||
viewModel.clearCache(preference.key, CacheDir.PAGES)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
|
||||
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
|
||||
viewModel.clearHttpCache()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_CHAPTERS_CLEAR -> {
|
||||
cleanupChapters()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_WEBVIEW_CLEAR -> {
|
||||
viewModel.clearBrowserData()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_CLEAR_MANGA_DATA -> {
|
||||
viewModel.clearMangaData()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
|
||||
viewModel.clearUpdatesFeed()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
private fun onChaptersCleanedUp(result: Pair<Int, Long>) {
|
||||
val c = context ?: return
|
||||
val text = if (result.first == 0 && result.second == 0L) {
|
||||
c.getString(R.string.no_chapters_deleted)
|
||||
} else {
|
||||
c.getString(
|
||||
R.string.chapters_deleted_pattern,
|
||||
c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first),
|
||||
FileSize.BYTES.format(c, result.second),
|
||||
)
|
||||
}
|
||||
Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow<Long>) {
|
||||
stateFlow.observe(viewLifecycleOwner) { size ->
|
||||
summary = if (size < 0) {
|
||||
context.getString(R.string.computing_)
|
||||
} else {
|
||||
FileSize.BYTES.format(context, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearSearchHistory() {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.clear_search_history)
|
||||
.setMessage(R.string.text_clear_search_history_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.clearSearchHistory()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun clearCookies() {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.clear_cookies)
|
||||
.setMessage(R.string.text_clear_cookies_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.clear) { _, _ ->
|
||||
viewModel.clearCookies()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun cleanupChapters() {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setTitle(R.string.delete_read_chapters)
|
||||
.setMessage(R.string.delete_read_chapters_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.cleanupChapters()
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings.userdata.storage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.webkit.WebStorage
|
||||
import androidx.webkit.WebStorageCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.firstNotNull
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import java.util.EnumMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@HiltViewModel
|
||||
class StorageManageSettingsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val httpCache: Cache,
|
||||
private val searchRepository: MangaSearchRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
|
||||
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
|
||||
private val coil: ImageLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||
val loadingKeys = MutableStateFlow(emptySet<String>())
|
||||
|
||||
val searchHistoryCount = MutableStateFlow(-1)
|
||||
val feedItemsCount = MutableStateFlow(-1)
|
||||
val httpCacheSize = MutableStateFlow(-1L)
|
||||
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
|
||||
val storageUsage = MutableStateFlow<StorageUsage?>(null)
|
||||
|
||||
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
|
||||
|
||||
val isBrowserDataCleanupEnabled: Boolean
|
||||
get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)
|
||||
|
||||
private var storageUsageJob: Job? = null
|
||||
|
||||
init {
|
||||
CacheDir.entries.forEach {
|
||||
cacheSizes[it] = MutableStateFlow(-1L)
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
feedItemsCount.value = trackingRepository.getLogsCount()
|
||||
}
|
||||
CacheDir.entries.forEach { cache ->
|
||||
launchJob(Dispatchers.Default) {
|
||||
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
|
||||
}
|
||||
}
|
||||
launchJob(Dispatchers.Default) {
|
||||
httpCacheSize.value = runInterruptible { httpCache.size() }
|
||||
}
|
||||
loadStorageUsage()
|
||||
}
|
||||
|
||||
fun clearCache(key: String, vararg caches: CacheDir) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + key }
|
||||
for (cache in caches) {
|
||||
storageManager.clearCache(cache)
|
||||
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
|
||||
if (cache == CacheDir.THUMBS) {
|
||||
coil.memoryCache?.clear()
|
||||
}
|
||||
}
|
||||
loadStorageUsage()
|
||||
} finally {
|
||||
loadingKeys.update { it - key }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearHttpCache() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR }
|
||||
val size = runInterruptible(Dispatchers.IO) {
|
||||
httpCache.evictAll()
|
||||
httpCache.size()
|
||||
}
|
||||
httpCacheSize.value = size
|
||||
loadStorageUsage()
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearchHistory() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
searchRepository.clearSearchHistory()
|
||||
searchHistoryCount.value = searchRepository.getSearchHistoryCount()
|
||||
onActionDone.call(ReversibleAction(R.string.search_history_cleared, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCookies() {
|
||||
launchJob {
|
||||
cookieJar.clear()
|
||||
onActionDone.call(ReversibleAction(R.string.cookies_cleared, null))
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RequiresFeature")
|
||||
fun clearBrowserData() {
|
||||
launchJob {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR }
|
||||
val storage = WebStorage.getInstance()
|
||||
suspendCoroutine { cont ->
|
||||
WebStorageCompat.deleteBrowsingData(storage) {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearUpdatesFeed() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR }
|
||||
trackingRepository.clearLogs()
|
||||
feedItemsCount.value = trackingRepository.getLogsCount()
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMangaData() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA }
|
||||
trackingRepository.gc()
|
||||
val repository = mangaDataRepositoryProvider.get()
|
||||
repository.cleanupLocalManga()
|
||||
repository.cleanupDatabase()
|
||||
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanupChapters() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
val oldSize = storageUsage.firstNotNull().savedManga.bytes
|
||||
val chaptersCount = deleteReadChaptersUseCase.invoke()
|
||||
loadStorageUsage().join()
|
||||
val newSize = storageUsage.firstNotNull().savedManga.bytes
|
||||
onChaptersCleanedUp.call(chaptersCount to oldSize - newSize)
|
||||
} finally {
|
||||
loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStorageUsage(): Job {
|
||||
val prevJob = storageUsageJob
|
||||
return launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES)
|
||||
val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize
|
||||
val storageSize = storageManager.computeStorageSize()
|
||||
val availableSpace = storageManager.computeAvailableSize()
|
||||
val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace
|
||||
storageUsage.value = StorageUsage(
|
||||
savedManga = StorageUsage.Item(
|
||||
bytes = storageSize,
|
||||
percent = (storageSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
pagesCache = StorageUsage.Item(
|
||||
bytes = pagesCacheSize,
|
||||
percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
otherCache = StorageUsage.Item(
|
||||
bytes = otherCacheSize,
|
||||
percent = (otherCacheSize.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
available = StorageUsage.Item(
|
||||
bytes = availableSpace,
|
||||
percent = (availableSpace.toDouble() / totalBytes).toFloat(),
|
||||
),
|
||||
)
|
||||
}.also {
|
||||
storageUsageJob = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import androidx.room.RawQuery
|
||||
import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import kotlin.collections.forEach
|
||||
|
||||
@Dao
|
||||
abstract class StatsDao {
|
||||
@@ -61,4 +65,19 @@ abstract class StatsDao {
|
||||
protected abstract suspend fun getDurationStatsImpl(
|
||||
query: SupportSQLiteQuery
|
||||
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
|
||||
|
||||
@Query("SELECT * FROM stats ORDER BY started_at LIMIT :limit OFFSET :offset")
|
||||
protected abstract suspend fun findAll(offset: Int, limit: Int): List<StatsEntity>
|
||||
fun dumpEnabled(): Flow<StatsEntity> = flow {
|
||||
val window = 10
|
||||
var offset = 0
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val list = findAll(offset, window)
|
||||
if (list.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += window
|
||||
list.forEach { emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
app/src/main/res/drawable/ic_usage.xml
Normal file
12
app/src/main/res/drawable/ic_usage.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M13,2.05V5.08C16.39,5.57 19,8.47 19,12C19,12.9 18.82,13.75 18.5,14.54L21.12,16.07C21.68,14.83 22,13.45 22,12C22,6.82 18.05,2.55 13,2.05M12,19A7,7 0 0,1 5,12C5,8.47 7.61,5.57 11,5.08V2.05C5.94,2.55 2,6.81 2,12A10,10 0 0,0 12,22C15.3,22 18.23,20.39 20.05,17.91L17.45,16.38C16.17,18 14.21,19 12,19Z" />
|
||||
</vector>
|
||||
@@ -42,7 +42,7 @@
|
||||
android:importantForAutofill="no"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<ImageView
|
||||
<ImageButton
|
||||
android:id="@+id/dropdown"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
|
||||
@@ -74,9 +74,15 @@
|
||||
|
||||
</com.google.android.material.appbar.MaterialToolbar>
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout
|
||||
android:id="@+id/layout_touchBlock"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</org.koitharu.kotatsu.core.ui.widgets.TouchBlockLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:scrollIndicators="top"
|
||||
android:scrollIndicators="top|bottom"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<LinearLayout
|
||||
@@ -54,6 +55,23 @@
|
||||
|
||||
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_saved_filters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:title="@string/saved_filters">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
android:id="@+id/chips_saved_filters"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_small"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
|
||||
|
||||
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_locale"
|
||||
android:layout_width="match_parent"
|
||||
@@ -144,6 +162,24 @@
|
||||
|
||||
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_author"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:showMoreButton="false"
|
||||
app:title="@string/author">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
android:id="@+id/chips_author"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_small"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter" />
|
||||
|
||||
</org.koitharu.kotatsu.filter.ui.FilterFieldLayout>
|
||||
|
||||
<org.koitharu.kotatsu.filter.ui.FilterFieldLayout
|
||||
android:id="@+id/layout_types"
|
||||
android:layout_width="match_parent"
|
||||
@@ -254,4 +290,40 @@
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
<com.google.android.material.dockedtoolbar.DockedToolbarLayout
|
||||
android:id="@+id/docked_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="false">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/m3_comp_toolbar_docked_container_height"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_save"
|
||||
style="?materialButtonOutlinedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="@dimen/margin_small"
|
||||
android:layout_weight="1"
|
||||
android:enabled="false"
|
||||
android:text="@string/save"
|
||||
tools:enabled="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/button_done"
|
||||
style="?materialButtonTonalStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_small"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/done" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.dockedtoolbar.DockedToolbarLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
33
app/src/main/res/layout/view_dialog_autocomplete.xml
Normal file
33
app/src/main/res/layout/view_dialog_autocomplete.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
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"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="@dimen/screen_padding"
|
||||
android:paddingTop="@dimen/margin_small">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/autoCompleteTextView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@id/dropdown"
|
||||
tools:ignore="LabelFor" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/dropdown"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:paddingBottom="2dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_expand_more"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
</RelativeLayout>
|
||||
12
app/src/main/res/menu/popup_saved_filter.xml
Normal file
12
app/src/main/res/menu/popup_saved_filter.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_rename"
|
||||
android:title="@string/rename" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_delete"
|
||||
android:title="@string/delete" />
|
||||
</menu>
|
||||
@@ -667,4 +667,8 @@
|
||||
<string name="error_not_image">無効な形式です: 画像が期待されましたが %s が入力されました</string>
|
||||
<string name="start_download">ダウンロード開始</string>
|
||||
<string name="save_manga_confirm">選択したマンガを保存しますか?通信量とディスク容量を消費する可能性があります</string>
|
||||
<string name="save_manga">マンガを保存</string>
|
||||
<string name="genre">ジャンル</string>
|
||||
<string name="download_added">ダウンロードに追加</string>
|
||||
<string name="chapter_selection_hint">チャプターリストの項目を長押しすると、ダウンロードするチャプターを選択できます。</string>
|
||||
</resources>
|
||||
|
||||
@@ -866,4 +866,5 @@
|
||||
<string name="pull_bottom_no_next">Sonraki bölüm yok</string>
|
||||
<string name="enable_pull_gesture_title">Çekme hareketini etkinleştir</string>
|
||||
<string name="enable_pull_gesture_summary">Webtoon modunda bölüm değiştirmek için çekme hareketi kullan</string>
|
||||
<string name="two_page_scroll_sensitivity">İki Sayfa Kaydırma Hassaslığı</string>
|
||||
</resources>
|
||||
|
||||
@@ -866,4 +866,5 @@
|
||||
<string name="pull_bottom_no_next">Немає наступного розділу</string>
|
||||
<string name="enable_pull_gesture_title">Увімкнути жест потягування</string>
|
||||
<string name="enable_pull_gesture_summary">Використовуйте жест потягування для перемикання розділів у вебтуні</string>
|
||||
<string name="two_page_scroll_sensitivity">Чутливість прокручування на дві сторінки</string>
|
||||
</resources>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<string name="by_rating">Rating</string>
|
||||
<string name="sort_order">Sorting order</string>
|
||||
<string name="filter">Filter</string>
|
||||
<string name="saved_filters">Saved filters</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="light">Light</string>
|
||||
<string name="dark">Dark</string>
|
||||
@@ -208,6 +209,7 @@
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="reset_filter">Reset filter</string>
|
||||
<string name="enter_name">Enter name</string>
|
||||
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
|
||||
<string name="never">Never</string>
|
||||
<string name="only_using_wifi">Only on Wi-Fi</string>
|
||||
@@ -803,6 +805,8 @@
|
||||
<string name="enable_all_sources">Enable all manga sources</string>
|
||||
<string name="enable_all_sources_summary">All available manga sources will be enabled permanently</string>
|
||||
<string name="all_sources_enabled">All sources are enabled</string>
|
||||
<string name="reader_chapter_toast">Show chapter change popup</string>
|
||||
<string name="reader_chapter_toast_summary">Show a pop-up message with a chapter title when it is changed</string>
|
||||
<string name="reader_info_bar_transparent">Transparent reader information bar</string>
|
||||
<string name="backup_restored_background">The backup will be restored in the background</string>
|
||||
<string name="restoring_backup">Restoring backup</string>
|
||||
@@ -887,4 +891,13 @@
|
||||
<string name="chapters_load_failed">Failed to load chapter list</string>
|
||||
<string name="telegram_integration">Telegram integration</string>
|
||||
<string name="test_parser">Test manga source</string>
|
||||
<string name="rename">Rename</string>
|
||||
<string name="save_filter">Save filter</string>
|
||||
<string name="overwrite">Overwrite</string>
|
||||
<string name="filter_overwrite_confirm">A filter named \"%s\" already exists. Do you want to overwrite it?</string>
|
||||
<string name="storage_and_network">Storage and network</string>
|
||||
<string name="create_or_restore_backup">Create or restore a backup</string>
|
||||
<string name="data_removal">Data removal</string>
|
||||
<string name="privacy">Privacy</string>
|
||||
<string name="source_broken_warning">This manga source has been marked as broken. Some features may not work</string>
|
||||
</resources>
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
|
||||
<PreferenceCategory android:title="@string/main_screen">
|
||||
|
||||
<MultiSelectListPreference
|
||||
android:key="search_suggest_types"
|
||||
android:title="@string/search_suggestions" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment"
|
||||
android:key="nav_main"
|
||||
@@ -117,6 +121,29 @@
|
||||
android:summary="@string/exit_confirmation_summary"
|
||||
android:title="@string/exit_confirmation" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="dynamic_shortcuts"
|
||||
android:summary="@string/history_shortcuts_summary"
|
||||
android:title="@string/history_shortcuts" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="@string/privacy">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="protect_app"
|
||||
android:persistent="false"
|
||||
android:summary="@string/protect_application_summary"
|
||||
android:title="@string/protect_application" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="allow"
|
||||
android:entries="@array/screenshots_policy"
|
||||
android:key="screenshots_policy"
|
||||
android:title="@string/screenshots_policy"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
|
||||
25
app/src/main/res/xml/pref_backups.xml
Normal file
25
app/src/main/res/xml/pref_backups.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:title="@string/backup_restore">
|
||||
|
||||
<Preference
|
||||
android:key="backup"
|
||||
android:persistent="false"
|
||||
android:summary="@string/backup_information"
|
||||
android:title="@string/create_backup" />
|
||||
|
||||
<Preference
|
||||
android:key="restore"
|
||||
android:persistent="false"
|
||||
android:summary="@string/restore_summary"
|
||||
android:title="@string/restore_backup" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
|
||||
android:key="backup_periodic"
|
||||
android:persistent="false"
|
||||
android:title="@string/periodic_backups" />
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -2,9 +2,7 @@
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:title="@string/storage_usage">
|
||||
|
||||
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
|
||||
android:title="@string/data_removal">
|
||||
|
||||
<Preference
|
||||
android:key="search_history_clear"
|
||||
@@ -1,67 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
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:title="@string/network">
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="0"
|
||||
android:entries="@array/network_policy"
|
||||
android:entryValues="@array/values_network_policy"
|
||||
android:key="prefetch_content"
|
||||
android:title="@string/prefetch_content"
|
||||
app:useSimpleSummaryProvider="true"
|
||||
tools:isPreferenceVisible="true" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="2"
|
||||
android:entries="@array/network_policy"
|
||||
android:entryValues="@array/values_network_policy"
|
||||
android:key="pages_preload"
|
||||
android:title="@string/preload_pages"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
|
||||
android:key="proxy"
|
||||
android:title="@string/proxy"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/doh_providers"
|
||||
android:key="doh"
|
||||
android:title="@string/dns_over_https"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="adblock"
|
||||
android:summary="@string/adblock_summary"
|
||||
android:title="@string/adblock" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="-1"
|
||||
android:entries="@array/image_proxies"
|
||||
android:entryValues="@array/values_image_proxies"
|
||||
android:key="images_proxy_2"
|
||||
android:title="@string/images_proxy_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="mirror_switching"
|
||||
android:summary="@string/mirror_switching_summary"
|
||||
android:title="@string/mirror_switching" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="ssl_bypass"
|
||||
android:summary="@string/ignore_ssl_errors_summary"
|
||||
android:title="@string/ignore_ssl_errors" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="no_offline"
|
||||
android:summary="@string/disable_connectivity_check_summary"
|
||||
android:title="@string/disable_connectivity_check" />
|
||||
|
||||
</PreferenceScreen>
|
||||
73
app/src/main/res/xml/pref_network_storage.xml
Normal file
73
app/src/main/res/xml/pref_network_storage.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
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:title="@string/network">
|
||||
|
||||
<PreferenceCategory android:title="@string/storage_usage">
|
||||
|
||||
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
|
||||
|
||||
<Preference
|
||||
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment"
|
||||
android:persistent="false"
|
||||
android:title="@string/data_removal" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="0"
|
||||
android:entries="@array/network_policy"
|
||||
android:entryValues="@array/values_network_policy"
|
||||
android:key="prefetch_content"
|
||||
android:title="@string/prefetch_content"
|
||||
app:allowDividerAbove="true"
|
||||
app:useSimpleSummaryProvider="true"
|
||||
tools:isPreferenceVisible="true" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="2"
|
||||
android:entries="@array/network_policy"
|
||||
android:entryValues="@array/values_network_policy"
|
||||
android:key="pages_preload"
|
||||
android:title="@string/preload_pages"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
|
||||
android:key="proxy"
|
||||
android:title="@string/proxy"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/doh_providers"
|
||||
android:key="doh"
|
||||
android:title="@string/dns_over_https"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="-1"
|
||||
android:entries="@array/image_proxies"
|
||||
android:entryValues="@array/values_image_proxies"
|
||||
android:key="images_proxy_2"
|
||||
android:title="@string/images_proxy_title"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="ssl_bypass"
|
||||
android:summary="@string/ignore_ssl_errors_summary"
|
||||
android:title="@string/ignore_ssl_errors" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="no_offline"
|
||||
android:summary="@string/disable_connectivity_check_summary"
|
||||
android:title="@string/disable_connectivity_check" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="adblock"
|
||||
android:summary="@string/adblock_summary"
|
||||
android:title="@string/adblock" />
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -144,6 +144,12 @@
|
||||
android:key="reader_bar_transparent"
|
||||
android:title="@string/reader_info_bar_transparent" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="reader_chapter_toast"
|
||||
android:summary="@string/reader_chapter_toast_summary"
|
||||
android:title="@string/reader_chapter_toast" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/reader_backgrounds"
|
||||
android:key="reader_background"
|
||||
|
||||
@@ -21,16 +21,10 @@
|
||||
android:title="@string/reader_settings" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.NetworkSettingsFragment"
|
||||
android:icon="@drawable/ic_web"
|
||||
android:fragment="org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment"
|
||||
android:icon="@drawable/ic_usage"
|
||||
android:key="network"
|
||||
android:title="@string/network" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment"
|
||||
android:icon="@drawable/ic_data_privacy"
|
||||
android:key="userdata"
|
||||
android:title="@string/data_and_privacy" />
|
||||
android:title="@string/storage_and_network" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.DownloadsSettingsFragment"
|
||||
@@ -50,6 +44,12 @@
|
||||
android:key="services"
|
||||
android:title="@string/services" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment"
|
||||
android:icon="@drawable/ic_backup_restore"
|
||||
android:key="userdata"
|
||||
android:title="@string/backup_restore" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.about.AboutSettingsFragment"
|
||||
android:icon="@drawable/ic_info_outline"
|
||||
|
||||
@@ -38,17 +38,29 @@
|
||||
android:summary="@string/disable_nsfw_summary"
|
||||
android:title="@string/disable_nsfw" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/incognito_nsfw_options"
|
||||
android:key="incognito_nsfw"
|
||||
android:title="@string/incognito_for_nsfw"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="tags_warnings"
|
||||
android:summary="@string/tags_warnings_summary"
|
||||
android:title="@string/tags_warnings" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="false"
|
||||
android:key="mirror_switching"
|
||||
android:summary="@string/mirror_switching_summary"
|
||||
android:title="@string/mirror_switching"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="handle_links"
|
||||
android:persistent="false"
|
||||
android:summary="@string/handle_links_summary"
|
||||
android:title="@string/handle_links"
|
||||
app:allowDividerAbove="true" />
|
||||
android:title="@string/handle_links" />
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:title="@string/data_and_privacy">
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:key="protect_app"
|
||||
android:persistent="false"
|
||||
android:summary="@string/protect_application_summary"
|
||||
android:title="@string/protect_application" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="allow"
|
||||
android:entries="@array/screenshots_policy"
|
||||
android:key="screenshots_policy"
|
||||
android:title="@string/screenshots_policy"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<ListPreference
|
||||
android:entries="@array/incognito_nsfw_options"
|
||||
android:key="incognito_nsfw"
|
||||
android:title="@string/incognito_for_nsfw"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
android:defaultValue="true"
|
||||
android:key="dynamic_shortcuts"
|
||||
android:summary="@string/history_shortcuts_summary"
|
||||
android:title="@string/history_shortcuts" />
|
||||
|
||||
<MultiSelectListPreference
|
||||
android:key="search_suggest_types"
|
||||
android:title="@string/search_suggestions" />
|
||||
|
||||
<PreferenceCategory android:title="@string/backup_restore">
|
||||
|
||||
<Preference
|
||||
android:key="backup"
|
||||
android:persistent="false"
|
||||
android:summary="@string/backup_information"
|
||||
android:title="@string/create_backup" />
|
||||
|
||||
<Preference
|
||||
android:key="restore"
|
||||
android:persistent="false"
|
||||
android:summary="@string/restore_summary"
|
||||
android:title="@string/restore_backup" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
|
||||
android:key="backup_periodic"
|
||||
android:persistent="false"
|
||||
android:title="@string/periodic_backups" />
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment"
|
||||
android:key="storage_usage"
|
||||
android:title="@string/storage_usage"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
</PreferenceScreen>
|
||||
@@ -35,7 +35,7 @@ material = "1.14.0-alpha05"
|
||||
moshi = "1.15.2"
|
||||
okhttp = "5.2.1"
|
||||
okio = "3.16.1"
|
||||
parsers = "8908031eee"
|
||||
parsers = "df1cab3f9d"
|
||||
preference = "1.2.1"
|
||||
recyclerview = "1.4.0"
|
||||
room = "2.7.2"
|
||||
|
||||
Reference in New Issue
Block a user