Move sources from java to kotlin dir
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.LocaleManagerCompat
|
||||
import androidx.core.view.postDelayed
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.TwoStatePreference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.getLocalesConfig
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
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.SliderPreference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppearanceSettingsFragment :
|
||||
BasePreferenceFragment(R.string.appearance),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var activityRecreationHandle: ActivityRecreationHandle
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_appearance)
|
||||
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.run {
|
||||
val pattern = context.getString(R.string.percent_string_pattern)
|
||||
summary = pattern.format(value.toString())
|
||||
setOnPreferenceChangeListener { preference, newValue ->
|
||||
preference.summary = pattern.format(newValue.toString())
|
||||
true
|
||||
}
|
||||
}
|
||||
preferenceScreen?.findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
|
||||
entryValues = ListMode.values().names()
|
||||
setDefaultValueCompat(ListMode.GRID.name)
|
||||
}
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
findPreference<ActivityListPreference>(AppSettings.KEY_APP_LOCALE)?.run {
|
||||
initLocalePicker(this)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activityIntent = Intent(
|
||||
Settings.ACTION_APP_LOCALE_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
}
|
||||
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
|
||||
val locale = AppCompatDelegate.getApplicationLocales().get(0)
|
||||
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic)
|
||||
}
|
||||
setDefaultValueCompat("")
|
||||
}
|
||||
}
|
||||
|
||||
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_THEME -> {
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
}
|
||||
|
||||
AppSettings.KEY_COLOR_THEME,
|
||||
AppSettings.KEY_THEME_AMOLED -> {
|
||||
postRestart()
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_PASSWORD -> {
|
||||
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
|
||||
?.isChecked = !settings.appPassword.isNullOrEmpty()
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_LOCALE -> {
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRestart() {
|
||||
view?.postDelayed(400) {
|
||||
activityRecreationHandle.recreateAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initLocalePicker(preference: ListPreference) {
|
||||
val locales = resources.getLocalesConfig()
|
||||
.toList()
|
||||
.sortedWith(LocaleComparator(preference.context))
|
||||
preference.entries = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
getString(R.string.automatic)
|
||||
} else {
|
||||
val lc = locales[i - 1]
|
||||
lc.getDisplayName(lc).toTitleCase(lc)
|
||||
}
|
||||
}
|
||||
preference.entryValues = Array(locales.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
""
|
||||
} else {
|
||||
locales[i - 1].toLanguageTag()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LocaleComparator(context: Context) : Comparator<Locale> {
|
||||
|
||||
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)
|
||||
.map { it.language }
|
||||
|
||||
override fun compare(a: Locale, b: Locale): Int {
|
||||
return if (a === b) {
|
||||
0
|
||||
} else {
|
||||
val indexA = deviceLocales.indexOf(a.language)
|
||||
val indexB = deviceLocales.indexOf(b.language)
|
||||
if (indexA == -1 && indexB == -1) {
|
||||
compareValues(a.language, b.language)
|
||||
} else {
|
||||
-2 - (indexA - indexB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
||||
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.ui.dialog.StorageSelectDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getStorageName
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ContentSettingsFragment :
|
||||
BasePreferenceFragment(R.string.content),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
StorageSelectDialog.OnStorageSelectListener {
|
||||
|
||||
@Inject
|
||||
lateinit var storageManager: LocalStorageManager
|
||||
|
||||
@Inject
|
||||
lateinit var contentCache: ContentCache
|
||||
|
||||
@Inject
|
||||
lateinit var downloadsScheduler: DownloadWorker.Scheduler
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_content)
|
||||
findPreference<Preference>(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled
|
||||
findPreference<ListPreference>(AppSettings.KEY_DOH)?.run {
|
||||
entryValues = arrayOf(
|
||||
DoHProvider.NONE,
|
||||
DoHProvider.GOOGLE,
|
||||
DoHProvider.CLOUDFLARE,
|
||||
DoHProvider.ADGUARD,
|
||||
).names()
|
||||
setDefaultValueCompat(DoHProvider.NONE.name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
|
||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
|
||||
)
|
||||
bindRemoteSourcesSummary()
|
||||
bindProxySummary()
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
findPreference<Preference>(key)?.bindStorageName()
|
||||
}
|
||||
|
||||
AppSettings.KEY_SUGGESTIONS -> {
|
||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
|
||||
)
|
||||
}
|
||||
|
||||
AppSettings.KEY_SOURCES_HIDDEN -> {
|
||||
bindRemoteSourcesSummary()
|
||||
}
|
||||
|
||||
AppSettings.KEY_DOWNLOADS_WIFI -> {
|
||||
updateDownloadsConstraints()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_LOCAL_STORAGE -> {
|
||||
val ctx = context ?: return false
|
||||
StorageSelectDialog.Builder(ctx, storageManager, this)
|
||||
.setTitle(preference.title ?: "")
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.create()
|
||||
.show()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStorageSelected(file: File) {
|
||||
settings.mangaStorageDir = file
|
||||
}
|
||||
|
||||
private fun Preference.bindStorageName() {
|
||||
viewLifecycleScope.launch {
|
||||
val storage = storageManager.getDefaultWriteableDir()
|
||||
summary = storage?.getStorageName(context) ?: getString(R.string.not_available)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindRemoteSourcesSummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
|
||||
val total = settings.remoteMangaSources.size
|
||||
summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindProxySummary() {
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY)?.run {
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
summary = if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
||||
context.getString(R.string.disabled)
|
||||
} else {
|
||||
"$type $address:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadsConstraints() {
|
||||
val preference = findPreference<Preference>(AppSettings.KEY_DOWNLOADS_WIFI)
|
||||
viewLifecycleScope.launch {
|
||||
try {
|
||||
preference?.isEnabled = false
|
||||
withContext(Dispatchers.Default) {
|
||||
downloadsScheduler.updateConstraints()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
} finally {
|
||||
preference?.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.EditTextValidator
|
||||
|
||||
class DomainValidator : EditTextValidator() {
|
||||
|
||||
override fun validate(text: String): ValidationResult {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ValidationResult.Success
|
||||
}
|
||||
return if (!checkCharacters(trimmed)) {
|
||||
ValidationResult.Failed(context.getString(R.string.invalid_domain_message))
|
||||
} else {
|
||||
ValidationResult.Success
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCharacters(value: String): Boolean = runCatching {
|
||||
val parts = value.split(':')
|
||||
require(parts.size <= 2)
|
||||
val urlBuilder = HttpUrl.Builder()
|
||||
urlBuilder.host(parts.first())
|
||||
if (parts.size == 2) {
|
||||
urlBuilder.port(parts[1].toInt())
|
||||
}
|
||||
}.isSuccess
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.awaitStateAtLeast
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
|
||||
|
||||
@Inject
|
||||
lateinit var trackerRepo: TrackingRepository
|
||||
|
||||
@Inject
|
||||
lateinit var searchRepository: MangaSearchRepository
|
||||
|
||||
@Inject
|
||||
lateinit var storageManager: LocalStorageManager
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
@Inject
|
||||
lateinit var cache: Cache
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutsUpdater: ShortcutsUpdater
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_history)
|
||||
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
|
||||
shortcutsUpdater.isDynamicShortcutsAvailable()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES)
|
||||
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS)
|
||||
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindSummaryToHttpCacheSize()
|
||||
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launch {
|
||||
lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
|
||||
val items = searchRepository.getSearchHistoryCount()
|
||||
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
|
||||
viewLifecycleScope.launch {
|
||||
lifecycle.awaitStateAtLeast(Lifecycle.State.RESUMED)
|
||||
val items = trackerRepo.getLogsCount()
|
||||
pref.summary = pref.context.resources.getQuantityString(R.plurals.items, items, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
|
||||
clearCache(preference, CacheDir.PAGES)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
|
||||
clearCache(preference, CacheDir.THUMBS)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_COOKIES_CLEAR -> {
|
||||
clearCookies()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
|
||||
clearSearchHistory(preference)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
|
||||
clearHttpCache()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
|
||||
viewLifecycleScope.launch {
|
||||
trackerRepo.clearLogs()
|
||||
preference.summary = preference.context.resources
|
||||
.getQuantityString(R.plurals.items, 0, 0)
|
||||
Snackbar.make(
|
||||
view ?: return@launch,
|
||||
R.string.updates_feed_cleared,
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(preference: Preference, cache: CacheDir) {
|
||||
val ctx = preference.context.applicationContext
|
||||
viewLifecycleScope.launch {
|
||||
try {
|
||||
preference.isEnabled = false
|
||||
storageManager.clearCache(cache)
|
||||
val size = storageManager.computeCacheSize(cache)
|
||||
preference.summary = FileSize.BYTES.format(ctx, size)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
preference.summary = e.getDisplayMessage(ctx.resources)
|
||||
} finally {
|
||||
preference.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preference.bindSummaryToCacheSize(dir: CacheDir) = viewLifecycleScope.launch {
|
||||
val size = storageManager.computeCacheSize(dir)
|
||||
summary = FileSize.BYTES.format(context, size)
|
||||
}
|
||||
|
||||
private fun Preference.bindSummaryToHttpCacheSize() = viewLifecycleScope.launch {
|
||||
val size = runInterruptible(Dispatchers.IO) { cache.size() }
|
||||
summary = FileSize.BYTES.format(context, size)
|
||||
}
|
||||
|
||||
private fun clearHttpCache() {
|
||||
val preference = findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR) ?: return
|
||||
val ctx = preference.context.applicationContext
|
||||
viewLifecycleScope.launch {
|
||||
try {
|
||||
preference.isEnabled = false
|
||||
val size = runInterruptible(Dispatchers.IO) {
|
||||
cache.evictAll()
|
||||
cache.size()
|
||||
}
|
||||
preference.summary = FileSize.BYTES.format(ctx, size)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
preference.summary = e.getDisplayMessage(ctx.resources)
|
||||
} finally {
|
||||
preference.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearSearchHistory(preference: Preference) {
|
||||
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) { _, _ ->
|
||||
viewLifecycleScope.launch {
|
||||
searchRepository.clearSearchHistory()
|
||||
preference.summary = preference.context.resources
|
||||
.getQuantityString(R.plurals.items, 0, 0)
|
||||
Snackbar.make(
|
||||
view ?: return@launch,
|
||||
R.string.search_history_cleared,
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.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) { _, _ ->
|
||||
viewLifecycleScope.launch {
|
||||
cookieJar.clear()
|
||||
Snackbar.make(
|
||||
listView ?: return@launch,
|
||||
R.string.cookies_cleared,
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.Preference
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.utils.RingtonePickContract
|
||||
|
||||
class NotificationSettingsLegacyFragment :
|
||||
BasePreferenceFragment(R.string.notifications),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val ringtonePickContract = registerForActivityResult(
|
||||
RingtonePickContract(R.string.notification_sound),
|
||||
) { uri ->
|
||||
settings.notificationSound = uri ?: return@registerForActivityResult
|
||||
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
|
||||
summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
|
||||
?: getString(R.string.silent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_notifications)
|
||||
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
|
||||
val uri = settings.notificationSound
|
||||
summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
|
||||
?: getString(R.string.silent)
|
||||
}
|
||||
updateInfo()
|
||||
}
|
||||
|
||||
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_TRACKER_NOTIFICATIONS -> updateInfo()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_NOTIFICATIONS_SOUND -> {
|
||||
ringtonePickContract.launch(settings.notificationSound)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateInfo() {
|
||||
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_INFO)
|
||||
?.isVisible = !settings.isTrackerNotificationsEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
|
||||
import java.net.Proxy
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_proxy)
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener(
|
||||
EditTextBindListener(
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
|
||||
hint = null,
|
||||
validator = DomainValidator(),
|
||||
),
|
||||
)
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener(
|
||||
EditTextBindListener(
|
||||
inputType = EditorInfo.TYPE_CLASS_NUMBER,
|
||||
hint = null,
|
||||
validator = null,
|
||||
),
|
||||
)
|
||||
updateDependencies()
|
||||
}
|
||||
|
||||
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(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_PROXY_TYPE -> updateDependencies()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDependencies() {
|
||||
val isProxyEnabled = settings.proxyType != Proxy.Type.DIRECT
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY_ADDRESS)?.isEnabled = isProxyEnabled
|
||||
findPreference<Preference>(AppSettings.KEY_PROXY_PORT)?.isEnabled = isProxyEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderSettingsFragment :
|
||||
BasePreferenceFragment(R.string.reader_settings),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_reader)
|
||||
findPreference<ListPreference>(AppSettings.KEY_READER_MODE)?.run {
|
||||
entryValues = arrayOf(
|
||||
ReaderMode.STANDARD,
|
||||
ReaderMode.REVERSED,
|
||||
ReaderMode.WEBTOON,
|
||||
).names()
|
||||
setDefaultValueCompat(ReaderMode.STANDARD.name)
|
||||
}
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_SWITCHERS)?.run {
|
||||
summaryProvider = MultiSummaryProvider(R.string.gestures_only)
|
||||
}
|
||||
findPreference<ListPreference>(AppSettings.KEY_ZOOM_MODE)?.run {
|
||||
entryValues = arrayOf(
|
||||
ZoomMode.FIT_CENTER,
|
||||
ZoomMode.FIT_HEIGHT,
|
||||
ZoomMode.FIT_WIDTH,
|
||||
ZoomMode.KEEP_START,
|
||||
).names()
|
||||
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
|
||||
}
|
||||
updateReaderModeDependency()
|
||||
}
|
||||
|
||||
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(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_READER_MODE -> updateReaderModeDependency()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateReaderModeDependency() {
|
||||
findPreference<Preference>(AppSettings.KEY_READER_MODE_DETECT)?.run {
|
||||
isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
|
||||
class RootSettingsFragment : BasePreferenceFragment(R.string.settings) {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
|
||||
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
|
||||
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) {
|
||||
|
||||
@Inject
|
||||
lateinit var shikimoriRepository: ShikimoriRepository
|
||||
|
||||
@Inject
|
||||
lateinit var aniListRepository: AniListRepository
|
||||
|
||||
@Inject
|
||||
lateinit var malRepository: MALRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncController: SyncController
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_services)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, shikimoriRepository)
|
||||
bindScrobblerSummary(AppSettings.KEY_ANILIST, aniListRepository)
|
||||
bindScrobblerSummary(AppSettings.KEY_MAL, malRepository)
|
||||
bindSyncSummary()
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_SHIKIMORI -> {
|
||||
if (!shikimoriRepository.isAuthorized) {
|
||||
launchScrobblerAuth(shikimoriRepository)
|
||||
} else {
|
||||
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.SHIKIMORI))
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_MAL -> {
|
||||
if (!malRepository.isAuthorized) {
|
||||
launchScrobblerAuth(malRepository)
|
||||
} else {
|
||||
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.MAL))
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_ANILIST -> {
|
||||
if (!aniListRepository.isAuthorized) {
|
||||
launchScrobblerAuth(aniListRepository)
|
||||
} else {
|
||||
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.ANILIST))
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_SYNC -> {
|
||||
val am = AccountManager.get(requireContext())
|
||||
val accountType = getString(R.string.account_type_sync)
|
||||
val account = am.getAccountsByType(accountType).firstOrNull()
|
||||
if (account == null) {
|
||||
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
|
||||
} else {
|
||||
try {
|
||||
startActivity(SyncSettingsIntent(account))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindScrobblerSummary(
|
||||
key: String,
|
||||
repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
|
||||
) {
|
||||
val pref = findPreference<Preference>(key) ?: return
|
||||
if (!repository.isAuthorized) {
|
||||
pref.setSummary(R.string.disabled)
|
||||
return
|
||||
}
|
||||
val username = repository.cachedUser?.nickname
|
||||
if (username != null) {
|
||||
pref.summary = getString(R.string.logged_in_as, username)
|
||||
} else {
|
||||
pref.setSummary(R.string.loading_)
|
||||
viewLifecycleScope.launch {
|
||||
pref.summary = withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
val user = repository.loadUser()
|
||||
getString(R.string.logged_in_as, user.nickname)
|
||||
}.getOrElse {
|
||||
it.printStackTraceDebug()
|
||||
it.getDisplayMessage(resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchScrobblerAuth(repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository) {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(repository.oauthUrl)
|
||||
startActivity(intent)
|
||||
}.onFailure {
|
||||
Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSyncSummary() {
|
||||
viewLifecycleScope.launch {
|
||||
val account = withContext(Dispatchers.Default) {
|
||||
val type = getString(R.string.account_type_sync)
|
||||
AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull()
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SYNC)?.run {
|
||||
summary = when {
|
||||
account == null -> getString(R.string.sync_title)
|
||||
syncController.isEnabled(account) -> account.name
|
||||
else -> getString(R.string.disabled)
|
||||
}
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SYNC_SETTINGS)?.isEnabled = account != null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.isScrolledToTop
|
||||
import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.SourcesListFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SettingsActivity :
|
||||
BaseActivity<ActivitySettingsBinding>(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
AppBarOwner,
|
||||
FragmentManager.OnBackStackChangedListener {
|
||||
|
||||
override val appBar: AppBarLayout
|
||||
get() = viewBinding.appbar
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
|
||||
openDefaultFragment()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence?, color: Int) {
|
||||
super.onTitleChanged(title, color)
|
||||
viewBinding.collapsingToolbarLayout?.title = title
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
supportFragmentManager.addOnBackStackChangedListener(this)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
supportFragmentManager.removeOnBackStackChangedListener(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(this, "leakcanary.internal.activity.LeakActivity")
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackStackChanged() {
|
||||
val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return
|
||||
val recyclerView = fragment.recyclerView
|
||||
recyclerView.post {
|
||||
viewBinding.appbar.setExpanded(recyclerView.isScrolledToTop, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference,
|
||||
): Boolean {
|
||||
val fm = supportFragmentManager
|
||||
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false)
|
||||
fragment.arguments = pref.extras
|
||||
fragment.setTargetFragment(caller, 0)
|
||||
openFragment(fragment)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.container.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
|
||||
fun openFragment(fragment: Fragment) {
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDefaultFragment() {
|
||||
val fragment = when (intent?.action) {
|
||||
ACTION_READER -> ReaderSettingsFragment()
|
||||
ACTION_SUGGESTIONS -> SuggestionsSettingsFragment()
|
||||
ACTION_HISTORY -> HistorySettingsFragment()
|
||||
ACTION_TRACKER -> TrackerSettingsFragment()
|
||||
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
||||
intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
|
||||
)
|
||||
|
||||
ACTION_MANAGE_SOURCES -> SourcesListFragment()
|
||||
Intent.ACTION_VIEW -> {
|
||||
when (intent.data?.host) {
|
||||
HOST_ABOUT -> AboutSettingsFragment()
|
||||
HOST_SYNC_SETTINGS -> SyncSettingsFragment()
|
||||
else -> SettingsHeadersFragment()
|
||||
}
|
||||
}
|
||||
|
||||
else -> SettingsHeadersFragment()
|
||||
}
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragment)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||
private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||
private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
||||
private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
|
||||
private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
private const val HOST_ABOUT = "about"
|
||||
private const val HOST_SYNC_SETTINGS = "sync-settings"
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
|
||||
|
||||
fun newReaderSettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_READER)
|
||||
|
||||
fun newSuggestionsSettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_SUGGESTIONS)
|
||||
|
||||
fun newTrackerSettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_TRACKER)
|
||||
|
||||
fun newHistorySettingsIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_HISTORY)
|
||||
|
||||
fun newManageSourcesIntent(context: Context) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_MANAGE_SOURCES)
|
||||
|
||||
fun newSourceSettingsIntent(context: Context, source: MangaSource) =
|
||||
Intent(context, SettingsActivity::class.java)
|
||||
.setAction(ACTION_SOURCE)
|
||||
.putExtra(EXTRA_SOURCE, source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceHeaderFragmentCompat
|
||||
import androidx.slidingpanelayout.widget.SlidingPaneLayout
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener {
|
||||
|
||||
private var currentTitle: CharSequence? = null
|
||||
|
||||
override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
slidingPaneLayout.addPanelSlideListener(this)
|
||||
}
|
||||
|
||||
override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
|
||||
|
||||
override fun onPanelOpened(panel: View) {
|
||||
activity?.title = currentTitle ?: getString(R.string.settings)
|
||||
}
|
||||
|
||||
override fun onPanelClosed(panel: View) {
|
||||
activity?.setTitle(R.string.settings)
|
||||
}
|
||||
|
||||
fun setTitle(title: CharSequence?) {
|
||||
currentTitle = title
|
||||
if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) {
|
||||
activity?.title = title
|
||||
}
|
||||
}
|
||||
|
||||
fun openFragment(fragment: Fragment) {
|
||||
childFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(androidx.preference.R.id.preferences_detail, fragment)
|
||||
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
|
||||
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
|
||||
import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider
|
||||
|
||||
fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) {
|
||||
val configKeys = repository.getConfigKeys()
|
||||
val screen = preferenceScreen
|
||||
for (key in configKeys) {
|
||||
val preference: Preference = when (key) {
|
||||
is ConfigKey.Domain -> {
|
||||
val presetValues = key.presetValues
|
||||
if (presetValues.isNullOrEmpty()) {
|
||||
EditTextPreference(requireContext())
|
||||
} else {
|
||||
AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues }
|
||||
}.apply {
|
||||
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
|
||||
setOnBindEditTextListener(
|
||||
EditTextBindListener(
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
|
||||
hint = key.defaultValue,
|
||||
validator = DomainValidator(),
|
||||
),
|
||||
)
|
||||
setTitle(R.string.domain)
|
||||
setDialogTitle(R.string.domain)
|
||||
}
|
||||
}
|
||||
|
||||
is ConfigKey.UserAgent -> {
|
||||
EditTextPreference(requireContext()).apply {
|
||||
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
|
||||
setOnBindEditTextListener(
|
||||
EditTextBindListener(
|
||||
inputType = EditorInfo.TYPE_CLASS_TEXT,
|
||||
hint = key.defaultValue,
|
||||
validator = null,
|
||||
),
|
||||
)
|
||||
setTitle(R.string.user_agent)
|
||||
setDialogTitle(R.string.user_agent)
|
||||
}
|
||||
}
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> {
|
||||
SwitchPreferenceCompat(requireContext()).apply {
|
||||
setDefaultValue(key.defaultValue)
|
||||
setTitle(R.string.show_suspicious_content)
|
||||
}
|
||||
}
|
||||
}
|
||||
preference.isIconSpaceReserved = false
|
||||
preference.key = key.key
|
||||
screen.addPreference(preference)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.awaitViewLifecycle
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.requireSerializable
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourceSettingsFragment : BasePreferenceFragment(0) {
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var source: MangaSource
|
||||
private var repository: RemoteMangaRepository? = null
|
||||
private val exceptionResolver = ExceptionResolver(this)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
source = requireArguments().requireSerializable(EXTRA_SOURCE)
|
||||
repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setTitle(source.title)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
preferenceManager.sharedPreferencesName = source.name
|
||||
val repo = repository ?: return
|
||||
addPreferencesFromResource(R.xml.pref_source)
|
||||
addPreferencesFromRepository(repo)
|
||||
|
||||
findPreference<Preference>(KEY_AUTH)?.run {
|
||||
val authProvider = repo.getAuthProvider()
|
||||
isVisible = authProvider != null
|
||||
isEnabled = authProvider?.isAuthorized == false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
findPreference<Preference>(KEY_AUTH)?.run {
|
||||
if (isVisible) {
|
||||
loadUsername(viewLifecycleOwner, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
KEY_AUTH -> {
|
||||
startActivity(SourceAuthActivity.newIntent(preference.context, source))
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch {
|
||||
runCatchingCancellable {
|
||||
preference.summary = null
|
||||
withContext(Dispatchers.Default) {
|
||||
requireNotNull(repository?.getAuthProvider()?.getUsername())
|
||||
}
|
||||
}.onSuccess { username ->
|
||||
preference.title = getString(R.string.logged_in_as, username)
|
||||
}.onFailure { error ->
|
||||
when {
|
||||
error is AuthRequiredException -> Unit
|
||||
ExceptionResolver.canResolve(error) -> {
|
||||
ensureActive()
|
||||
Snackbar.make(
|
||||
listView ?: return@onFailure,
|
||||
error.getDisplayMessage(preference.context.resources),
|
||||
Snackbar.LENGTH_INDEFINITE,
|
||||
).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) }
|
||||
.show()
|
||||
}
|
||||
|
||||
else -> preference.summary = error.getDisplayMessage(preference.context.resources)
|
||||
}
|
||||
error.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveError(error: Throwable) {
|
||||
view ?: return
|
||||
viewLifecycleScope.launch {
|
||||
if (exceptionResolver.resolve(error)) {
|
||||
val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch
|
||||
val lifecycleOwner = awaitViewLifecycle()
|
||||
loadUsername(lifecycleOwner, pref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_AUTH = "auth"
|
||||
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) {
|
||||
putSerializable(EXTRA_SOURCE, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
|
||||
import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider
|
||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SuggestionsSettingsFragment :
|
||||
BasePreferenceFragment(R.string.suggestions),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var repository: SuggestionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var tagsCompletionProvider: TagsAutoCompleteProvider
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_suggestions)
|
||||
|
||||
findPreference<MultiAutoCompleteTextViewPreference>(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run {
|
||||
autoCompleteProvider = tagsCompletionProvider
|
||||
summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
settings.unsubscribe(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == AppSettings.KEY_SUGGESTIONS && settings.isSuggestionsEnabled) {
|
||||
onSuggestionsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSuggestionsEnabled() {
|
||||
lifecycleScope.launch {
|
||||
if (repository.isEmpty()) {
|
||||
SuggestionsWorker.startNow(context ?: return@launch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.preference.Preference
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.sync.data.SyncSettings
|
||||
import org.koitharu.kotatsu.sync.ui.SyncHostDialogFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SyncSettingsFragment : BasePreferenceFragment(R.string.sync_settings), FragmentResultListener {
|
||||
|
||||
@Inject
|
||||
lateinit var syncSettings: SyncSettings
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_sync)
|
||||
bindHostSummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
childFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, viewLifecycleOwner, this)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
SyncSettings.KEY_HOST -> {
|
||||
SyncHostDialogFragment.show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFragmentResult(requestKey: String, result: Bundle) {
|
||||
bindHostSummary()
|
||||
}
|
||||
|
||||
private fun bindHostSummary() {
|
||||
val preference = findPreference<Preference>(SyncSettings.KEY_HOST) ?: return
|
||||
preference.summary = syncSettings.host
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.github.VersionId
|
||||
import org.koitharu.kotatsu.core.github.isStable
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
|
||||
private val viewModel by viewModels<AboutSettingsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_about)
|
||||
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
|
||||
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
|
||||
isEnabled = viewModel.isUpdateSupported
|
||||
}
|
||||
findPreference<SwitchPreferenceCompat>(AppSettings.KEY_UPDATES_UNSTABLE)?.run {
|
||||
isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable
|
||||
if (!isEnabled) isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) {
|
||||
findPreference<Preference>(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it
|
||||
}
|
||||
viewModel.onUpdateAvailable.observe(viewLifecycleOwner, ::onUpdateAvailable)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_APP_VERSION -> {
|
||||
viewModel.checkForUpdates()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_TRANSLATION -> {
|
||||
openLink(getString(R.string.url_weblate), preference.title)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOGS_SHARE -> {
|
||||
ShareHelper(preference.context).shareLogs(loggers)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUpdateAvailable(version: AppVersion?) {
|
||||
if (version == null) {
|
||||
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
AppUpdateDialog(context ?: return).show(version)
|
||||
}
|
||||
|
||||
private fun openLink(url: String, title: CharSequence?) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url.toUri()
|
||||
startActivity(
|
||||
if (title != null) {
|
||||
Intent.createChooser(intent, title)
|
||||
} else {
|
||||
intent
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AboutSettingsViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val isUpdateSupported = appUpdateRepository.isUpdateSupported()
|
||||
val onUpdateAvailable = SingleLiveEvent<AppVersion?>()
|
||||
|
||||
fun checkForUpdates() {
|
||||
launchLoadingJob {
|
||||
val update = appUpdateRepository.fetchUpdate()
|
||||
onUpdateAvailable.call(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.koitharu.kotatsu.settings.about
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.noties.markwon.Markwon
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class AppUpdateDialog(private val context: Context) {
|
||||
|
||||
fun show(version: AppVersion) {
|
||||
val message = buildSpannedString {
|
||||
append(context.getString(R.string.new_version_s, version.name))
|
||||
appendLine()
|
||||
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
|
||||
appendLine()
|
||||
appendLine()
|
||||
append(Markwon.create(context).toMarkdown(version.description))
|
||||
}
|
||||
MaterialAlertDialogBuilder(
|
||||
context,
|
||||
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
|
||||
)
|
||||
.setTitle(R.string.app_update_available)
|
||||
.setMessage(message)
|
||||
.setIcon(R.drawable.ic_app_update)
|
||||
.setPositiveButton(R.string.download) { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
|
||||
context.startActivity(Intent.createChooser(intent, context.getString(R.string.open_in_browser)))
|
||||
}
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupDataInput
|
||||
import android.app.backup.BackupDataOutput
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import java.io.*
|
||||
|
||||
class AppBackupAgent : BackupAgent() {
|
||||
|
||||
override fun onBackup(
|
||||
oldState: ParcelFileDescriptor?,
|
||||
data: BackupDataOutput?,
|
||||
newState: ParcelFileDescriptor?
|
||||
) = Unit
|
||||
|
||||
override fun onRestore(
|
||||
data: BackupDataInput?,
|
||||
appVersionCode: Int,
|
||||
newState: ParcelFileDescriptor?
|
||||
) = Unit
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
super.onFullBackup(data)
|
||||
val file = createBackupFile(this, BackupRepository(MangaDatabase(applicationContext)))
|
||||
try {
|
||||
fullBackupFile(file, data)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreFile(
|
||||
data: ParcelFileDescriptor,
|
||||
size: Long,
|
||||
destination: File?,
|
||||
type: Int,
|
||||
mode: Long,
|
||||
mtime: Long
|
||||
) {
|
||||
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||
restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext)))
|
||||
destination.delete()
|
||||
} else {
|
||||
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
|
||||
BackupZipOutput(context).use { backup ->
|
||||
backup.put(repository.createIndex())
|
||||
backup.put(repository.dumpHistory())
|
||||
backup.put(repository.dumpCategories())
|
||||
backup.put(repository.dumpFavourites())
|
||||
backup.finish()
|
||||
backup.file
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
FileInputStream(fd).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyLimitedTo(output, size)
|
||||
}
|
||||
}
|
||||
val backup = BackupZipInput(tempFile)
|
||||
try {
|
||||
runBlocking {
|
||||
repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||
repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||
repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||
}
|
||||
} finally {
|
||||
backup.close()
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
|
||||
var bytesCopied: Long = 0
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
|
||||
var bytes = read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
val bytesLeft = (limit - bytesCopied).toInt()
|
||||
if (bytesLeft <= 0) {
|
||||
break
|
||||
}
|
||||
bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
|
||||
private val viewModel by viewModels<BackupViewModel>()
|
||||
|
||||
private var backup: File? = null
|
||||
private val saveFileContract = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("*/*"),
|
||||
) { uri ->
|
||||
val file = backup
|
||||
if (uri != null && file != null) {
|
||||
saveBackup(file, uri)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = DialogProgressBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.textViewTitle.setText(R.string.create_backup)
|
||||
binding.textViewSubtitle.setText(R.string.processing_)
|
||||
|
||||
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
||||
viewModel.onBackupDone.observe(viewLifecycleOwner, this::onBackupDone)
|
||||
viewModel.onError.observe(viewLifecycleOwner, this::onError)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setCancelable(false)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(e.getDisplayMessage(resources))
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(value: Float) {
|
||||
with(requireViewBinding().progressBar) {
|
||||
isVisible = true
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBackupDone(file: File) {
|
||||
this.backup = file
|
||||
saveFileContract.launch(file.name)
|
||||
}
|
||||
|
||||
private fun saveBackup(file: File, output: Uri) {
|
||||
try {
|
||||
requireContext().contentResolver.openFileDescriptor(output, "w")?.use { fd ->
|
||||
FileOutputStream(fd.fileDescriptor).use {
|
||||
it.write(file.readBytes())
|
||||
}
|
||||
}
|
||||
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show()
|
||||
dismiss()
|
||||
} catch (e: InterruptedException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "BackupDialogFragment"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.content.Context
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BackupObserver @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
|
||||
|
||||
private val backupManager = BackupManager(context)
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
backupManager.dataChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
|
||||
class BackupSettingsFragment :
|
||||
BasePreferenceFragment(R.string.backup_restore),
|
||||
ActivityResultCallback<Uri?> {
|
||||
|
||||
private val backupSelectCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
this,
|
||||
)
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_backup)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_BACKUP -> {
|
||||
BackupDialogFragment().show(childFragmentManager, BackupDialogFragment.TAG)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_RESTORE -> {
|
||||
try {
|
||||
backupSelectCall.launch(arrayOf("*/*"))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTraceDebug()
|
||||
Snackbar.make(
|
||||
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
RestoreDialogFragment.newInstance(result ?: return)
|
||||
.show(childFragmentManager, BackupDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BackupViewModel @Inject constructor(
|
||||
private val repository: BackupRepository,
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val progress = MutableLiveData(-1f)
|
||||
val onBackupDone = SingleLiveEvent<File>()
|
||||
|
||||
init {
|
||||
launchLoadingJob {
|
||||
val file = BackupZipOutput(context).use { backup ->
|
||||
backup.put(repository.createIndex())
|
||||
|
||||
progress.value = 0f
|
||||
backup.put(repository.dumpHistory())
|
||||
|
||||
progress.value = 0.3f
|
||||
backup.put(repository.dumpCategories())
|
||||
|
||||
progress.value = 0.6f
|
||||
backup.put(repository.dumpFavourites())
|
||||
|
||||
progress.value = 0.9f
|
||||
backup.finish()
|
||||
progress.value = 1f
|
||||
backup.close()
|
||||
backup.file
|
||||
}
|
||||
onBackupDone.call(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
|
||||
private val viewModel: RestoreViewModel by viewModels()
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = DialogProgressBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.textViewTitle.setText(R.string.restore_backup)
|
||||
binding.textViewSubtitle.setText(R.string.preparing_)
|
||||
|
||||
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
||||
viewModel.onRestoreDone.observe(viewLifecycleOwner, this::onRestoreDone)
|
||||
viewModel.onError.observe(viewLifecycleOwner, this::onError)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setCancelable(false)
|
||||
}
|
||||
|
||||
private fun onError(e: Throwable) {
|
||||
MaterialAlertDialogBuilder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(e.getDisplayMessage(resources))
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(value: Float) {
|
||||
with(requireViewBinding().progressBar) {
|
||||
isVisible = true
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreDone(result: CompositeResult) {
|
||||
val builder = MaterialAlertDialogBuilder(context ?: return)
|
||||
when {
|
||||
result.isAllSuccess -> builder.setTitle(R.string.data_restored)
|
||||
.setMessage(R.string.data_restored_success)
|
||||
|
||||
result.isAllFailed -> builder.setTitle(R.string.error)
|
||||
.setMessage(
|
||||
result.failures.map {
|
||||
it.getDisplayMessage(resources)
|
||||
}.distinct().joinToString("\n"),
|
||||
)
|
||||
|
||||
else -> builder.setTitle(R.string.data_restored)
|
||||
.setMessage(R.string.data_restored_with_errors)
|
||||
}
|
||||
builder.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_FILE = "file"
|
||||
const val TAG = "RestoreDialogFragment"
|
||||
|
||||
fun newInstance(uri: Uri) = RestoreDialogFragment().withArgs(1) {
|
||||
putString(ARG_FILE, uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.settings.backup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class RestoreViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: BackupRepository,
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val progress = MutableLiveData(-1f)
|
||||
val onRestoreDone = SingleLiveEvent<CompositeResult>()
|
||||
|
||||
init {
|
||||
launchLoadingJob {
|
||||
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_FILE)
|
||||
?.toUriOrNull() ?: throw FileNotFoundException()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
val backup = runInterruptible(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
BackupZipInput(tempFile)
|
||||
}
|
||||
try {
|
||||
val result = CompositeResult()
|
||||
|
||||
progress.value = 0f
|
||||
result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||
|
||||
progress.value = 0.3f
|
||||
result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||
|
||||
progress.value = 0.6f
|
||||
result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||
|
||||
progress.value = 1f
|
||||
onRestoreDone.call(result)
|
||||
} finally {
|
||||
backup.close()
|
||||
backup.file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NewSourcesDialogFragment :
|
||||
AlertDialogFragment<DialogOnboardBinding>(),
|
||||
SourceConfigListener,
|
||||
DialogInterface.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<NewSourcesViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding {
|
||||
return DialogOnboardBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.new_sources_text)
|
||||
|
||||
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
return super.onBuildDialog(builder)
|
||||
.setPositiveButton(R.string.done, this)
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.remote_sources)
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
viewModel.apply()
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.onItemEnabledChanged(item, isEnabled)
|
||||
}
|
||||
|
||||
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit
|
||||
|
||||
override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "NewSources"
|
||||
|
||||
fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToSet
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewSourcesViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val sources = MutableLiveData<List<SourceConfigItem>>()
|
||||
private val initialList = settings.newSources
|
||||
|
||||
init {
|
||||
buildList()
|
||||
}
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
if (isEnabled) {
|
||||
settings.hiddenSources -= item.source.name
|
||||
} else {
|
||||
settings.hiddenSources += item.source.name
|
||||
}
|
||||
}
|
||||
|
||||
fun apply() {
|
||||
settings.markKnownSources(initialList)
|
||||
}
|
||||
|
||||
private fun buildList() {
|
||||
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
|
||||
val pendingHidden = HashSet<String>()
|
||||
sources.value = initialList.map {
|
||||
val locale = it.locale
|
||||
val isEnabledByLocale = locale == null || locale in locales
|
||||
if (!isEnabledByLocale) {
|
||||
pendingHidden += it.name
|
||||
}
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
summary = it.getLocaleTitle(),
|
||||
isEnabled = isEnabledByLocale,
|
||||
isDraggable = false,
|
||||
)
|
||||
}
|
||||
if (pendingHidden.isNotEmpty()) {
|
||||
settings.hiddenSources += pendingHidden
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.settings.newsources
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigDiffCallback
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourcesSelectAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : AsyncListDifferDelegationAdapter<SourceConfigItem>(
|
||||
SourceConfigDiffCallback(),
|
||||
sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.settings.onboard
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
|
||||
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
|
||||
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
|
||||
@AndroidEntryPoint
|
||||
class OnboardDialogFragment :
|
||||
AlertDialogFragment<DialogOnboardBinding>(),
|
||||
DialogInterface.OnClickListener, SourceLocaleListener {
|
||||
|
||||
private val viewModel by viewModels<OnboardViewModel>()
|
||||
private var isWelcome: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
arguments?.run {
|
||||
isWelcome = getBoolean(ARG_WELCOME, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = DialogOnboardBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||
super.onBuildDialog(builder)
|
||||
.setPositiveButton(R.string.done, this)
|
||||
.setCancelable(false)
|
||||
if (isWelcome) {
|
||||
builder.setTitle(R.string.welcome)
|
||||
} else {
|
||||
builder
|
||||
.setTitle(R.string.remote_sources)
|
||||
.setNegativeButton(android.R.string.cancel, this)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val adapter = SourceLocalesAdapter(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.textViewTitle.setText(R.string.onboard_text)
|
||||
viewModel.list.observe(viewLifecycleOwner) {
|
||||
adapter.items = it.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean) {
|
||||
viewModel.setItemChecked(item.key, isChecked)
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> viewModel.apply()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "OnboardDialog"
|
||||
private const val ARG_WELCOME = "welcome"
|
||||
|
||||
fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG)
|
||||
|
||||
fun showWelcome(fm: FragmentManager) {
|
||||
OnboardDialogFragment().withArgs(1) {
|
||||
putBoolean(ARG_WELCOME, true)
|
||||
}.showAllowStateLoss(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.koitharu.kotatsu.settings.onboard
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val allSources = settings.remoteMangaSources
|
||||
|
||||
private val locales = allSources.groupBy { it.locale }
|
||||
|
||||
private val selectedLocales = locales.keys.toMutableSet()
|
||||
|
||||
val list = MutableLiveData<List<SourceLocale>?>()
|
||||
|
||||
init {
|
||||
if (settings.isSourcesSelected) {
|
||||
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale })
|
||||
} else {
|
||||
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
|
||||
x.language
|
||||
}
|
||||
selectedLocales.retainAll(deviceLocales)
|
||||
if (selectedLocales.isEmpty()) {
|
||||
selectedLocales += "en"
|
||||
}
|
||||
selectedLocales += null
|
||||
}
|
||||
rebuildList()
|
||||
}
|
||||
|
||||
fun setItemChecked(key: String?, isChecked: Boolean) {
|
||||
val isModified = if (isChecked) {
|
||||
selectedLocales.add(key)
|
||||
} else {
|
||||
selectedLocales.remove(key)
|
||||
}
|
||||
if (isModified) {
|
||||
rebuildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun apply() {
|
||||
settings.hiddenSources = allSources.filterNot { x ->
|
||||
x.locale in selectedLocales
|
||||
}.mapToSet { x -> x.name }
|
||||
settings.markKnownSources(settings.newSources)
|
||||
}
|
||||
|
||||
private fun rebuildList() {
|
||||
list.value = locales.map { (key, srcs) ->
|
||||
val locale = if (key != null) {
|
||||
Locale(key)
|
||||
} else null
|
||||
SourceLocale(
|
||||
key = key,
|
||||
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
|
||||
summary = srcs.joinToString { it.title },
|
||||
isChecked = key in selectedLocales,
|
||||
)
|
||||
}.sortedWith(SourceLocaleComparator())
|
||||
}
|
||||
|
||||
private class SourceLocaleComparator : Comparator<SourceLocale?> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
|
||||
.map { it.language }
|
||||
|
||||
override fun compare(a: SourceLocale?, b: SourceLocale?): Int {
|
||||
return when {
|
||||
a === b -> 0
|
||||
a?.key == null -> 1
|
||||
b?.key == null -> -1
|
||||
else -> {
|
||||
val indexA = deviceLocales.indexOf(a.key)
|
||||
val indexB = deviceLocales.indexOf(b.key)
|
||||
if (indexA == -1 && indexB == -1) {
|
||||
compareValues(a.title, b.title)
|
||||
} else {
|
||||
-2 - (indexA - indexB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.settings.onboard.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
|
||||
fun sourceLocaleAD(
|
||||
listener: SourceLocaleListener,
|
||||
) = adapterDelegateViewBinding<SourceLocale, SourceLocale, ItemSourceLocaleBinding>(
|
||||
{ inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
|
||||
listener.onItemCheckedChanged(item, isChecked)
|
||||
}
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.title ?: getString(R.string.different_languages)
|
||||
binding.textViewDescription.textAndVisible = item.summary
|
||||
binding.switchToggle.setChecked(item.isChecked, payloads.isNotEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.settings.onboard.adapter
|
||||
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
|
||||
interface SourceLocaleListener {
|
||||
|
||||
fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.settings.onboard.adapter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
|
||||
|
||||
class SourceLocalesAdapter(
|
||||
listener: SourceLocaleListener,
|
||||
) : AsyncListDifferDelegationAdapter<SourceLocale>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(sourceLocaleAD(listener))
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<SourceLocale>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: SourceLocale,
|
||||
newItem: SourceLocale,
|
||||
): Boolean = oldItem.key == newItem.key
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: SourceLocale,
|
||||
newItem: SourceLocale,
|
||||
): Boolean = oldItem == newItem
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings.onboard.model
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
data class SourceLocale(
|
||||
val key: String?,
|
||||
val title: String?,
|
||||
val summary: String?,
|
||||
val isChecked: Boolean,
|
||||
) : Comparable<SourceLocale> {
|
||||
|
||||
override fun compareTo(other: SourceLocale): Int {
|
||||
return when {
|
||||
this === other -> 0
|
||||
key == Locale.getDefault().language -> -2
|
||||
key == null -> 1
|
||||
other.key == null -> -1
|
||||
else -> compareValues(title, other.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.koitharu.kotatsu.settings.protect
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.TextView
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding
|
||||
|
||||
private const val MIN_PASSWORD_LENGTH = 4
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ProtectSetupActivity :
|
||||
BaseActivity<ActivitySetupProtectBinding>(),
|
||||
TextWatcher,
|
||||
View.OnClickListener,
|
||||
TextView.OnEditorActionListener,
|
||||
CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
private val viewModel by viewModels<ProtectSetupViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
setContentView(ActivitySetupProtectBinding.inflate(layoutInflater))
|
||||
viewBinding.editPassword.addTextChangedListener(this)
|
||||
viewBinding.editPassword.setOnEditorActionListener(this)
|
||||
viewBinding.buttonNext.setOnClickListener(this)
|
||||
viewBinding.buttonCancel.setOnClickListener(this)
|
||||
|
||||
viewBinding.switchBiometric.isChecked = viewModel.isBiometricEnabled
|
||||
viewBinding.switchBiometric.setOnCheckedChangeListener(this)
|
||||
|
||||
viewModel.isSecondStep.observe(this, this::onStepChanged)
|
||||
viewModel.onPasswordSet.observe(this) {
|
||||
finishAfterTransition()
|
||||
}
|
||||
viewModel.onPasswordMismatch.observe(this) {
|
||||
viewBinding.editPassword.error = getString(R.string.passwords_mismatch)
|
||||
}
|
||||
viewModel.onClearText.observe(this) {
|
||||
viewBinding.editPassword.text?.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
viewBinding.root.setPadding(
|
||||
basePadding + insets.left,
|
||||
basePadding + insets.top,
|
||||
basePadding + insets.right,
|
||||
basePadding + insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> finish()
|
||||
R.id.button_next -> viewModel.onNextClick(
|
||||
password = viewBinding.editPassword.text?.toString() ?: return,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||
viewModel.setBiometricEnabled(isChecked)
|
||||
}
|
||||
|
||||
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
|
||||
return if (actionId == EditorInfo.IME_ACTION_DONE && viewBinding.buttonNext.isEnabled) {
|
||||
viewBinding.buttonNext.performClick()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
viewBinding.editPassword.error = null
|
||||
val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH
|
||||
viewBinding.buttonNext.isEnabled = isEnoughLength
|
||||
viewBinding.layoutPassword.isHelperTextEnabled =
|
||||
!isEnoughLength || viewModel.isSecondStep.value == true
|
||||
}
|
||||
|
||||
private fun onStepChanged(isSecondStep: Boolean) {
|
||||
viewBinding.buttonCancel.isGone = isSecondStep
|
||||
viewBinding.switchBiometric.isVisible = isSecondStep && isBiometricAvailable()
|
||||
if (isSecondStep) {
|
||||
viewBinding.layoutPassword.helperText = getString(R.string.repeat_password)
|
||||
viewBinding.buttonNext.setText(R.string.confirm)
|
||||
} else {
|
||||
viewBinding.layoutPassword.helperText = getString(R.string.password_length_hint)
|
||||
viewBinding.buttonNext.setText(R.string.next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBiometricAvailable(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.settings.protect
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.asFlowLiveData
|
||||
import org.koitharu.kotatsu.parsers.util.md5
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ProtectSetupViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val firstPassword = MutableStateFlow<String?>(null)
|
||||
|
||||
val isSecondStep = firstPassword.map {
|
||||
it != null
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext, false)
|
||||
val onPasswordSet = SingleLiveEvent<Unit>()
|
||||
val onPasswordMismatch = SingleLiveEvent<Unit>()
|
||||
val onClearText = SingleLiveEvent<Unit>()
|
||||
|
||||
val isBiometricEnabled
|
||||
get() = settings.isBiometricProtectionEnabled
|
||||
|
||||
fun onNextClick(password: String) {
|
||||
if (firstPassword.value == null) {
|
||||
firstPassword.value = password
|
||||
onClearText.call(Unit)
|
||||
} else {
|
||||
if (firstPassword.value == password) {
|
||||
settings.appPassword = password.md5()
|
||||
onPasswordSet.call(Unit)
|
||||
} else {
|
||||
onPasswordMismatch.call(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBiometricEnabled(isEnabled: Boolean) {
|
||||
settings.isBiometricProtectionEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||
import org.koitharu.kotatsu.settings.SourceSettingsFragment
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourcesListFragment :
|
||||
BaseFragment<FragmentSettingsSourcesBinding>(),
|
||||
SourceConfigListener,
|
||||
RecyclerViewOwner {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var reorderHelper: ItemTouchHelper? = null
|
||||
private val viewModel by viewModels<SourcesListViewModel>()
|
||||
|
||||
override val recyclerView: RecyclerView
|
||||
get() = requireViewBinding().recyclerView
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentSettingsSourcesBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = sourcesAdapter
|
||||
reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also {
|
||||
it.attachToRecyclerView(this)
|
||||
}
|
||||
}
|
||||
viewModel.items.observe(viewLifecycleOwner) {
|
||||
sourcesAdapter.items = it
|
||||
}
|
||||
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
addMenuProvider(SourcesMenuProvider())
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
activity?.setTitle(R.string.remote_sources)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
reorderHelper = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
requireViewBinding().recyclerView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
|
||||
val fragment = SourceSettingsFragment.newInstance(item.source)
|
||||
(parentFragment as? SettingsHeadersFragment)?.openFragment(fragment)
|
||||
?: (activity as? SettingsActivity)?.openFragment(fragment)
|
||||
}
|
||||
|
||||
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
|
||||
viewModel.setEnabled(item.source, isEnabled)
|
||||
}
|
||||
|
||||
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
|
||||
viewModel.expandOrCollapse(header.localeId)
|
||||
}
|
||||
|
||||
override fun onCloseTip(tip: SourceConfigItem.Tip) {
|
||||
viewModel.onTipClosed(tip)
|
||||
}
|
||||
|
||||
private inner class SourcesMenuProvider :
|
||||
MenuProvider,
|
||||
MenuItem.OnActionExpandListener,
|
||||
SearchView.OnQueryTextListener {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_sources, menu)
|
||||
val searchMenuItem = menu.findItem(R.id.action_search)
|
||||
searchMenuItem.setOnActionExpandListener(this)
|
||||
val searchView = searchMenuItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(this)
|
||||
searchView.setIconifiedByDefault(false)
|
||||
searchView.queryHint = searchMenuItem.title
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_disable_all -> {
|
||||
viewModel.disableAll()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
(item.actionView as SearchView).setQuery("", false)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.performSearch(newText)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
|
||||
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
|
||||
) {
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources(
|
||||
viewHolder.bindingAdapterPosition,
|
||||
target.bindingAdapterPosition,
|
||||
)
|
||||
|
||||
override fun canDropOver(
|
||||
recyclerView: RecyclerView,
|
||||
current: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder(
|
||||
current.bindingAdapterPosition,
|
||||
target.bindingAdapterPosition,
|
||||
)
|
||||
|
||||
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java)
|
||||
return if (item != null && item.isDraggable) {
|
||||
super.getDragDirs(recyclerView, viewHolder)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
|
||||
return if (item != null) {
|
||||
super.getSwipeDirs(recyclerView, viewHolder)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
|
||||
if (item != null) {
|
||||
viewModel.onTipClosed(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled() = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.koitharu.kotatsu.settings.sources
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getLocaleTitle
|
||||
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.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.map
|
||||
import org.koitharu.kotatsu.core.util.ext.move
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import java.util.Locale
|
||||
import java.util.TreeMap
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
private const val KEY_ENABLED = "!"
|
||||
private const val TIP_REORDER = "src_reorder"
|
||||
|
||||
@HiltViewModel
|
||||
class SourcesListViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val items = MutableLiveData<List<SourceConfigItem>>(emptyList())
|
||||
val onActionDone = SingleLiveEvent<ReversibleAction>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val expandedGroups = HashSet<String?>()
|
||||
private var searchQuery: String? = null
|
||||
|
||||
init {
|
||||
launchAtomicJob(Dispatchers.Default) {
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderSources(oldPos: Int, newPos: Int): Boolean {
|
||||
val snapshot = items.value?.toMutableList() ?: return false
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
launchAtomicJob(Dispatchers.Default) {
|
||||
snapshot.move(oldPos, newPos)
|
||||
settings.sourcesOrder = snapshot.mapNotNull {
|
||||
(it as? SourceConfigItem.SourceItem)?.source?.name
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun canReorder(oldPos: Int, newPos: Int): Boolean {
|
||||
val snapshot = items.value?.toMutableList() ?: return false
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||
return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true
|
||||
}
|
||||
|
||||
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
|
||||
launchAtomicJob(Dispatchers.Default) {
|
||||
settings.hiddenSources = if (isEnabled) {
|
||||
settings.hiddenSources - source.name
|
||||
} else {
|
||||
settings.hiddenSources + source.name
|
||||
}
|
||||
if (isEnabled) {
|
||||
settings.markKnownSources(setOf(source))
|
||||
} else {
|
||||
val rollback = ReversibleHandle {
|
||||
setEnabled(source, true)
|
||||
}
|
||||
onActionDone.emitCall(ReversibleAction(R.string.source_disabled, rollback))
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun disableAll() {
|
||||
launchAtomicJob(Dispatchers.Default) {
|
||||
settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet {
|
||||
it.name
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun expandOrCollapse(headerId: String?) {
|
||||
launchAtomicJob {
|
||||
if (headerId in expandedGroups) {
|
||||
expandedGroups.remove(headerId)
|
||||
} else {
|
||||
expandedGroups.add(headerId)
|
||||
}
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun performSearch(query: String?) {
|
||||
launchAtomicJob {
|
||||
searchQuery = query?.trim()
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
fun onTipClosed(item: SourceConfigItem.Tip) {
|
||||
launchAtomicJob(Dispatchers.Default) {
|
||||
settings.closeTip(item.key)
|
||||
buildList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildList() = runInterruptible(Dispatchers.Default) {
|
||||
val sources = settings.getMangaSources(includeHidden = true)
|
||||
val hiddenSources = settings.hiddenSources
|
||||
val query = searchQuery
|
||||
if (!query.isNullOrEmpty()) {
|
||||
items.postValue(
|
||||
sources.mapNotNull {
|
||||
if (!it.title.contains(query, ignoreCase = true)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
summary = it.getLocaleTitle(),
|
||||
isEnabled = it.name !in hiddenSources,
|
||||
isDraggable = false,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(SourceConfigItem.EmptySearchResult)
|
||||
},
|
||||
)
|
||||
return@runInterruptible
|
||||
}
|
||||
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
|
||||
if (it.name !in hiddenSources) {
|
||||
KEY_ENABLED
|
||||
} else {
|
||||
it.locale
|
||||
}
|
||||
}
|
||||
val result = ArrayList<SourceConfigItem>(sources.size + map.size + 2)
|
||||
val enabledSources = map.remove(KEY_ENABLED)
|
||||
if (!enabledSources.isNullOrEmpty()) {
|
||||
result += SourceConfigItem.Header(R.string.enabled_sources)
|
||||
if (settings.isTipEnabled(TIP_REORDER)) {
|
||||
result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip)
|
||||
}
|
||||
enabledSources.mapTo(result) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
summary = it.getLocaleTitle(),
|
||||
isEnabled = true,
|
||||
isDraggable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (enabledSources?.size != sources.size) {
|
||||
result += SourceConfigItem.Header(R.string.available_sources)
|
||||
for ((key, list) in map) {
|
||||
list.sortBy { it.ordinal }
|
||||
val isExpanded = key in expandedGroups
|
||||
result += SourceConfigItem.LocaleGroup(
|
||||
localeId = key,
|
||||
title = getLocaleTitle(key),
|
||||
isExpanded = isExpanded,
|
||||
)
|
||||
if (isExpanded) {
|
||||
list.mapTo(result) {
|
||||
SourceConfigItem.SourceItem(
|
||||
source = it,
|
||||
summary = null,
|
||||
isEnabled = false,
|
||||
isDraggable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items.postValue(result)
|
||||
}
|
||||
|
||||
private fun getLocaleTitle(localeKey: String?): String? {
|
||||
val locale = Locale(localeKey ?: return null)
|
||||
return locale.getDisplayLanguage(locale).toTitleCase(locale)
|
||||
}
|
||||
|
||||
private inline fun launchAtomicJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
crossinline block: suspend CoroutineScope.() -> Unit
|
||||
) = launchJob(context) {
|
||||
mutex.withLock {
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
private class LocaleKeyComparator : Comparator<String?> {
|
||||
|
||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
|
||||
.map { it.language }
|
||||
|
||||
override fun compare(a: String?, b: String?): Int {
|
||||
when {
|
||||
a == b -> return 0
|
||||
a == null -> return 1
|
||||
b == null -> return -1
|
||||
}
|
||||
val ai = deviceLocales.indexOf(a!!)
|
||||
val bi = deviceLocales.indexOf(b!!)
|
||||
return when {
|
||||
ai < 0 && bi < 0 -> a.compareTo(b)
|
||||
ai < 0 -> 1
|
||||
bi < 0 -> -1
|
||||
else -> ai.compareTo(bi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourceConfigAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : AsyncListDifferDelegationAdapter<SourceConfigItem>(
|
||||
SourceConfigDiffCallback(),
|
||||
sourceConfigHeaderDelegate(),
|
||||
sourceConfigGroupDelegate(listener),
|
||||
sourceConfigItemDelegate2(listener, coil, lifecycleOwner),
|
||||
sourceConfigEmptySearchDelegate(),
|
||||
sourceConfigTipDelegate(listener),
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.FaviconFallbackDrawable
|
||||
import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemTipBinding
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
fun sourceConfigHeaderDelegate() =
|
||||
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
|
||||
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.setText(item.titleResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigGroupDelegate(
|
||||
listener: SourceConfigListener,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
|
||||
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
listener.onHeaderClick(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.root.text = item.title ?: getString(R.string.various_languages)
|
||||
binding.root.isChecked = item.isExpanded
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigItemCheckableDelegate(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
|
||||
{ layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
|
||||
listener.onItemEnabledChanged(item, isChecked)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.title
|
||||
binding.switchToggle.isChecked = item.isEnabled
|
||||
binding.textViewDescription.textAndVisible = item.summary
|
||||
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
crossfade(context)
|
||||
error(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
fallback(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.imageViewIcon.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigItemDelegate2(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
|
||||
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
val eventListener = View.OnClickListener { v ->
|
||||
when (v.id) {
|
||||
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
|
||||
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
|
||||
R.id.imageView_config -> listener.onItemSettingsClick(item)
|
||||
}
|
||||
}
|
||||
binding.imageViewRemove.setOnClickListener(eventListener)
|
||||
binding.imageViewAdd.setOnClickListener(eventListener)
|
||||
binding.imageViewConfig.setOnClickListener(eventListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.source.title
|
||||
binding.imageViewAdd.isGone = item.isEnabled
|
||||
binding.imageViewRemove.isVisible = item.isEnabled
|
||||
binding.imageViewConfig.isVisible = item.isEnabled
|
||||
binding.textViewDescription.textAndVisible = item.summary
|
||||
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
|
||||
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
|
||||
crossfade(context)
|
||||
error(fallbackIcon)
|
||||
placeholder(fallbackIcon)
|
||||
fallback(fallbackIcon)
|
||||
source(item.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.imageViewIcon.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigTipDelegate(
|
||||
listener: OnTipCloseListener<SourceConfigItem.Tip>
|
||||
) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>(
|
||||
{ layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.buttonClose.setOnClickListener {
|
||||
listener.onCloseTip(item)
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.imageViewIcon.setImageResource(item.iconResId)
|
||||
binding.textView.setText(item.textResId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
|
||||
R.layout.item_sources_empty,
|
||||
) { }
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.EmptySearchResult
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.Header
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.LocaleGroup
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.SourceItem
|
||||
|
||||
class SourceConfigDiffCallback : DiffUtil.ItemCallback<SourceConfigItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
|
||||
return when {
|
||||
oldItem.javaClass != newItem.javaClass -> false
|
||||
oldItem is LocaleGroup && newItem is LocaleGroup -> {
|
||||
oldItem.localeId == newItem.localeId
|
||||
}
|
||||
|
||||
oldItem is SourceItem && newItem is SourceItem -> {
|
||||
oldItem.source == newItem.source
|
||||
}
|
||||
|
||||
oldItem is Header && newItem is Header -> {
|
||||
oldItem.titleResId == newItem.titleResId
|
||||
}
|
||||
|
||||
oldItem == EmptySearchResult && newItem == EmptySearchResult -> {
|
||||
true
|
||||
}
|
||||
|
||||
oldItem is SourceConfigItem.Tip && newItem is SourceConfigItem.Tip -> {
|
||||
oldItem.key == newItem.key
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
|
||||
|
||||
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
|
||||
|
||||
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
|
||||
|
||||
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.koitharu.kotatsu.settings.sources.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.browser.ProgressChromeClient
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
private lateinit var authProvider: MangaParserAuthProvider
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||
return
|
||||
}
|
||||
val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource
|
||||
if (source == null) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||
authProvider = (repository)?.getAuthProvider() ?: run {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string.auth_not_supported_by, source.title),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = CommonHeadersInterceptor.userAgentChrome
|
||||
}
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
val url = authProvider.authUrl
|
||||
onTitleChanged(
|
||||
source.title,
|
||||
getString(R.string.loading_),
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
viewBinding.webView.stopLoading()
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
finishAfterTransition()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
if (!isLoading && authProvider.isAuthorized) {
|
||||
Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show()
|
||||
setResult(Activity.RESULT_OK)
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(top = insets.top)
|
||||
viewBinding.webView.updatePadding(bottom = insets.bottom)
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<MangaSource, TaggedActivityResult>() {
|
||||
override fun createIntent(context: Context, input: MangaSource): Intent {
|
||||
return newIntent(context, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||
return TaggedActivityResult(TAG, resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
const val TAG = "SourceAuthActivity"
|
||||
|
||||
fun newIntent(context: Context, source: MangaSource): Intent {
|
||||
return Intent(context, SourceAuthActivity::class.java)
|
||||
.putExtra(EXTRA_SOURCE, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.koitharu.kotatsu.settings.sources.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
sealed interface SourceConfigItem {
|
||||
|
||||
class Header(
|
||||
@StringRes val titleResId: Int,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as Header
|
||||
return titleResId == other.titleResId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = titleResId
|
||||
}
|
||||
|
||||
class LocaleGroup(
|
||||
val localeId: String?,
|
||||
val title: String?,
|
||||
val isExpanded: Boolean,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as LocaleGroup
|
||||
|
||||
if (localeId != other.localeId) return false
|
||||
if (title != other.title) return false
|
||||
if (isExpanded != other.isExpanded) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = localeId?.hashCode() ?: 0
|
||||
result = 31 * result + (title?.hashCode() ?: 0)
|
||||
result = 31 * result + isExpanded.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class SourceItem(
|
||||
val source: MangaSource,
|
||||
val isEnabled: Boolean,
|
||||
val summary: String?,
|
||||
val isDraggable: Boolean,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as SourceItem
|
||||
|
||||
if (source != other.source) return false
|
||||
if (summary != other.summary) return false
|
||||
if (isEnabled != other.isEnabled) return false
|
||||
if (isDraggable != other.isDraggable) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = source.hashCode()
|
||||
result = 31 * result + summary.hashCode()
|
||||
result = 31 * result + isEnabled.hashCode()
|
||||
result = 31 * result + isDraggable.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Tip(
|
||||
val key: String,
|
||||
@DrawableRes val iconResId: Int,
|
||||
@StringRes val textResId: Int,
|
||||
) : SourceConfigItem {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Tip
|
||||
|
||||
if (key != other.key) return false
|
||||
if (iconResId != other.iconResId) return false
|
||||
if (textResId != other.textResId) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = key.hashCode()
|
||||
result = 31 * result + iconResId
|
||||
result = 31 * result + textResId
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
object EmptySearchResult : SourceConfigItem
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.koitharu.kotatsu.settings.tools
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CompoundButton
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||
import org.koitharu.kotatsu.databinding.FragmentToolsBinding
|
||||
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ToolsFragment :
|
||||
BaseFragment<FragmentToolsBinding>(),
|
||||
CompoundButton.OnCheckedChangeListener,
|
||||
View.OnClickListener {
|
||||
|
||||
private val viewModel by viewModels<ToolsViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentToolsBinding {
|
||||
return FragmentToolsBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentToolsBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonDownloads.setOnClickListener(this)
|
||||
binding.cardUpdate.buttonChangelog.setOnClickListener(this)
|
||||
binding.cardUpdate.buttonDownload.setOnClickListener(this)
|
||||
binding.switchIncognito.setOnCheckedChangeListener(this)
|
||||
binding.memoryUsageView.setManageButtonOnClickListener(this)
|
||||
|
||||
viewModel.isIncognitoModeEnabled.observe(viewLifecycleOwner) {
|
||||
binding.switchIncognito.setChecked(it, false)
|
||||
}
|
||||
viewModel.storageUsage.observe(viewLifecycleOwner) {
|
||||
binding.memoryUsageView.bind(it)
|
||||
}
|
||||
viewModel.appUpdate.observe(viewLifecycleOwner, ::onAppUpdateAvailable)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> startActivity(SettingsActivity.newIntent(v.context))
|
||||
R.id.button_manage -> startActivity(SettingsActivity.newHistorySettingsIntent(v.context))
|
||||
R.id.button_downloads -> startActivity(DownloadsActivity.newIntent(v.context))
|
||||
R.id.button_download -> {
|
||||
val url = viewModel.appUpdate.value?.apkUrl ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url.toUri()
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
|
||||
}
|
||||
|
||||
R.id.button_changelog -> {
|
||||
val version = viewModel.appUpdate.value ?: return
|
||||
AppUpdateDialog(v.context).show(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(button: CompoundButton?, isChecked: Boolean) {
|
||||
viewModel.toggleIncognitoMode(isChecked)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
requireViewBinding().root.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onAppUpdateAvailable(version: AppVersion?) {
|
||||
if (version == null) {
|
||||
requireViewBinding().cardUpdate.root.isVisible = false
|
||||
return
|
||||
}
|
||||
requireViewBinding().cardUpdate.textSecondary.text = getString(R.string.new_version_s, version.name)
|
||||
requireViewBinding().cardUpdate.root.isVisible = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = ToolsFragment()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.koitharu.kotatsu.settings.tools
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koitharu.kotatsu.core.github.AppUpdateRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
|
||||
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.tools.model.StorageUsage
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ToolsViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val appUpdate = appUpdateRepository.observeAvailableUpdate()
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
|
||||
val storageUsage: LiveData<StorageUsage?> = liveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
) {
|
||||
emit(collectStorageUsage())
|
||||
}
|
||||
|
||||
val isIncognitoModeEnabled = settings.observeAsLiveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_INCOGNITO_MODE,
|
||||
valueProducer = { isIncognitoModeEnabled },
|
||||
)
|
||||
|
||||
fun toggleIncognitoMode(isEnabled: Boolean) {
|
||||
settings.isIncognitoModeEnabled = isEnabled
|
||||
}
|
||||
|
||||
private suspend fun collectStorageUsage(): 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.settings.tools.model
|
||||
|
||||
class StorageUsage(
|
||||
val savedManga: Item,
|
||||
val pagesCache: Item,
|
||||
val otherCache: Item,
|
||||
val available: Item,
|
||||
) {
|
||||
|
||||
class Item(
|
||||
val bytes: Long,
|
||||
val percent: Float,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.koitharu.kotatsu.settings.tools.views
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.databinding.LayoutMemoryUsageBinding
|
||||
import org.koitharu.kotatsu.settings.tools.model.StorageUsage
|
||||
|
||||
class MemoryUsageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : LinearLayout(context, attrs) {
|
||||
|
||||
private val binding = LayoutMemoryUsageBinding.inflate(LayoutInflater.from(context), this)
|
||||
private val labelPattern = context.getString(R.string.memory_usage_pattern)
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
bind(null)
|
||||
}
|
||||
|
||||
fun setManageButtonOnClickListener(listener: OnClickListener?) {
|
||||
binding.buttonManage.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun bind(usage: StorageUsage?) {
|
||||
val storageSegment = SegmentedBarView.Segment(usage?.savedManga?.percent ?: 0f, segmentColor(1))
|
||||
val pagesSegment = SegmentedBarView.Segment(usage?.pagesCache?.percent ?: 0f, segmentColor(2))
|
||||
val otherSegment = SegmentedBarView.Segment(usage?.otherCache?.percent ?: 0f, segmentColor(3))
|
||||
|
||||
with(binding) {
|
||||
bar.animateSegments(listOf(storageSegment, pagesSegment, otherSegment).filter { it.percent > 0f })
|
||||
labelStorage.text = formatLabel(usage?.savedManga, R.string.saved_manga)
|
||||
labelPagesCache.text = formatLabel(usage?.pagesCache, R.string.pages_cache)
|
||||
labelOtherCache.text = formatLabel(usage?.otherCache, R.string.other_cache)
|
||||
labelAvailable.text = formatLabel(usage?.available, R.string.available, R.string.available)
|
||||
|
||||
TextViewCompat.setCompoundDrawableTintList(labelStorage, ColorStateList.valueOf(storageSegment.color))
|
||||
TextViewCompat.setCompoundDrawableTintList(labelPagesCache, ColorStateList.valueOf(pagesSegment.color))
|
||||
TextViewCompat.setCompoundDrawableTintList(labelOtherCache, ColorStateList.valueOf(otherSegment.color))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatLabel(
|
||||
item: StorageUsage.Item?,
|
||||
@StringRes labelResId: Int,
|
||||
@StringRes emptyResId: Int = R.string.computing_,
|
||||
): String {
|
||||
return if (item != null) {
|
||||
labelPattern.format(
|
||||
FileSize.BYTES.format(context, item.bytes),
|
||||
context.getString(labelResId),
|
||||
)
|
||||
} else {
|
||||
context.getString(emptyResId)
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private fun segmentColor(i: Int): Int {
|
||||
val hue = (93.6f * i) % 360
|
||||
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.4f, 0.6f))
|
||||
val backgroundColor = context.getThemeColor(com.google.android.material.R.attr.colorSecondaryContainer)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.koitharu.kotatsu.settings.tracker
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.text.style.URLSpan
|
||||
import android.view.View
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
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.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TrackerSettingsFragment :
|
||||
BasePreferenceFragment(R.string.check_for_new_chapters),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val viewModel by viewModels<TrackerSettingsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var channels: TrackerNotificationChannels
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_tracker)
|
||||
|
||||
findPreference<MultiSelectListPreference>(AppSettings.KEY_TRACK_SOURCES)
|
||||
?.summaryProvider = MultiSummaryProvider(R.string.dont_check)
|
||||
val warningPreference = findPreference<Preference>(AppSettings.KEY_TRACK_WARNING)
|
||||
if (warningPreference != null) {
|
||||
warningPreference.summary = buildSpannedString {
|
||||
append(getString(R.string.tracker_warning))
|
||||
append(" ")
|
||||
inSpans(URLSpan("https://dontkillmyapp.com/")) {
|
||||
append(getString(R.string.read_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
updateCategoriesEnabled()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
findPreference<Preference>(KEY_IGNORE_DOZE)?.run {
|
||||
isVisible = isDozeIgnoreAvailable(context)
|
||||
}
|
||||
updateNotificationsSummary()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings.subscribe(this)
|
||||
viewModel.categoriesCount.observe(viewLifecycleOwner, ::onCategoriesCountChanged)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) {
|
||||
when (key) {
|
||||
AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateNotificationsSummary()
|
||||
AppSettings.KEY_TRACK_SOURCES,
|
||||
AppSettings.KEY_TRACKER_ENABLED,
|
||||
-> updateCategoriesEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_NOTIFICATIONS_SETTINGS -> when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
channels.areNotificationsDisabled -> {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.fromParts("package", requireContext().packageName, null))
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
AppSettings.KEY_TRACK_CATEGORIES -> {
|
||||
TrackerCategoriesConfigSheet.show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
KEY_IGNORE_DOZE -> {
|
||||
startIgnoreDoseActivity(preference.context)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotificationsSummary() {
|
||||
val pref = findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return
|
||||
pref.setSummary(
|
||||
when {
|
||||
channels.areNotificationsDisabled -> R.string.disabled
|
||||
channels.isNotificationGroupEnabled() -> R.string.show_notification_new_chapters_on
|
||||
else -> R.string.show_notification_new_chapters_off
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateCategoriesEnabled() {
|
||||
val pref = findPreference<Preference>(AppSettings.KEY_TRACK_CATEGORIES) ?: return
|
||||
pref.isEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources
|
||||
}
|
||||
|
||||
private fun onCategoriesCountChanged(count: IntArray?) {
|
||||
val pref = findPreference<Preference>(AppSettings.KEY_TRACK_CATEGORIES) ?: return
|
||||
pref.summary = count?.let {
|
||||
getString(R.string.enabled_d_of_d, count[0], count[1])
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun startIgnoreDoseActivity(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||
try {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:$packageName".toUri(),
|
||||
)
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDozeIgnoreAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
return false
|
||||
}
|
||||
val packageName = context.packageName
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return !powerManager.isIgnoringBatteryOptimizations(packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.settings.tracker
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.room.InvalidationTracker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.removeObserverAsync
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.emitValue
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TrackerSettingsViewModel @Inject constructor(
|
||||
private val repository: TrackingRepository,
|
||||
private val database: MangaDatabase,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val categoriesCount = MutableLiveData<IntArray?>(null)
|
||||
|
||||
init {
|
||||
updateCategoriesCount()
|
||||
val databaseObserver = DatabaseObserver(this)
|
||||
addCloseable(databaseObserver)
|
||||
launchJob(Dispatchers.Default) {
|
||||
database.invalidationTracker.addObserver(databaseObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCategoriesCount() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
categoriesCount.emitValue(repository.getCategoriesCount())
|
||||
}
|
||||
}
|
||||
|
||||
private class DatabaseObserver(private var vm: TrackerSettingsViewModel?) :
|
||||
InvalidationTracker.Observer(arrayOf(TABLE_FAVOURITE_CATEGORIES)),
|
||||
Closeable {
|
||||
|
||||
override fun onInvalidated(tables: Set<String>) {
|
||||
vm?.updateCategoriesCount()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
(vm ?: return).database.invalidationTracker.removeObserverAsync(this)
|
||||
vm = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.settings.tracker.categories
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
|
||||
class TrackerCategoriesConfigAdapter(
|
||||
listener: OnListItemClickListener<FavouriteCategory>,
|
||||
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(trackerCategoryAD(listener))
|
||||
}
|
||||
|
||||
class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
|
||||
return oldItem.isTrackingEnabled == newItem.isTrackingEnabled && oldItem.title == newItem.title
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: FavouriteCategory, newItem: FavouriteCategory): Any? {
|
||||
return if (oldItem.isTrackingEnabled == newItem.isTrackingEnabled) {
|
||||
super.getChangePayload(oldItem, newItem)
|
||||
} else Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.koitharu.kotatsu.settings.tracker.categories
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.SheetBaseBinding
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TrackerCategoriesConfigSheet :
|
||||
BaseBottomSheet<SheetBaseBinding>(),
|
||||
OnListItemClickListener<FavouriteCategory>,
|
||||
View.OnClickListener {
|
||||
|
||||
private val viewModel by viewModels<TrackerCategoriesConfigViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding {
|
||||
return SheetBaseBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetBaseBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
binding.headerBar.setTitle(R.string.favourites_categories)
|
||||
binding.buttonDone.isVisible = true
|
||||
binding.buttonDone.setOnClickListener(this)
|
||||
val adapter = TrackerCategoriesConfigAdapter(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
|
||||
}
|
||||
|
||||
override fun onItemClick(item: FavouriteCategory, view: View) {
|
||||
viewModel.toggleItem(item)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "TrackerCategoriesConfigSheet"
|
||||
|
||||
fun show(fm: FragmentManager) = TrackerCategoriesConfigSheet().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.settings.tracker.categories
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.asFlowLiveData
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TrackerCategoriesConfigViewModel @Inject constructor(
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val content = favouritesRepository.observeCategories()
|
||||
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
|
||||
|
||||
private var updateJob: Job? = null
|
||||
|
||||
fun toggleItem(category: FavouriteCategory) {
|
||||
val prevJob = updateJob
|
||||
updateJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.join()
|
||||
favouritesRepository.updateCategoryTracking(category.id, !category.isTrackingEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.settings.tracker.categories
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
|
||||
|
||||
fun trackerCategoryAD(
|
||||
listener: OnListItemClickListener<FavouriteCategory>,
|
||||
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>(
|
||||
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||
) {
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
|
||||
bind {
|
||||
binding.root.text = item.title
|
||||
binding.root.isChecked = item.isTrackingEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding
|
||||
|
||||
class AboutLinksPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : Preference(context, attrs), View.OnClickListener {
|
||||
|
||||
init {
|
||||
layoutResource = R.layout.preference_about_links
|
||||
isSelectable = false
|
||||
isPersistent = false
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
|
||||
val binding = PreferenceAboutLinksBinding.bind(holder.itemView)
|
||||
arrayOf(
|
||||
binding.btn4pda,
|
||||
binding.btnDiscord,
|
||||
binding.btnGithub,
|
||||
binding.btnTelegram,
|
||||
).forEach { button ->
|
||||
TooltipCompat.setTooltipText(button, button.contentDescription)
|
||||
button.setOnClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val urlResId = when (v.id) {
|
||||
R.id.btn_4pda -> R.string.url_forpda
|
||||
R.id.btn_discord -> R.string.url_discord
|
||||
R.id.btn_telegram -> R.string.url_telegram
|
||||
R.id.btn_github -> R.string.url_github
|
||||
else -> return
|
||||
}
|
||||
openLink(v.context.getString(urlResId), v.contentDescription)
|
||||
}
|
||||
|
||||
private fun openLink(url: String, title: CharSequence?) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
try {
|
||||
context.startActivity(
|
||||
if (title != null) {
|
||||
Intent.createChooser(intent, title)
|
||||
} else {
|
||||
intent
|
||||
},
|
||||
)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AttributeSet
|
||||
import androidx.preference.ListPreference
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
|
||||
class ActivityListPreference : ListPreference {
|
||||
|
||||
var activityIntent: Intent? = null
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
defStyleRes: Int
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
override fun onClick() {
|
||||
val intent = activityIntent
|
||||
if (intent == null) {
|
||||
super.onClick()
|
||||
return
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTraceDebug()
|
||||
super.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.EditText
|
||||
import androidx.annotation.ArrayRes
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.preference.EditTextPreference
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class AutoCompleteTextViewPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = R.attr.autoCompleteTextViewPreferenceStyle,
|
||||
@StyleRes defStyleRes: Int = R.style.Preference_AutoCompleteTextView,
|
||||
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private val autoCompleteBindListener = AutoCompleteBindListener()
|
||||
var entries: Array<String> = emptyArray()
|
||||
|
||||
init {
|
||||
super.setOnBindEditTextListener(autoCompleteBindListener)
|
||||
context.withStyledAttributes(attrs, R.styleable.AutoCompleteTextViewPreference, defStyleAttr, defStyleRes) {
|
||||
val entriesId = getResourceId(R.styleable.AutoCompleteTextViewPreference_android_entries, 0)
|
||||
if (entriesId != 0) {
|
||||
setEntries(entriesId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEntries(@ArrayRes arrayResId: Int) {
|
||||
this.entries = context.resources.getStringArray(arrayResId)
|
||||
}
|
||||
|
||||
fun setEntries(entries: Collection<String>) {
|
||||
this.entries = entries.toTypedArray()
|
||||
}
|
||||
|
||||
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
|
||||
autoCompleteBindListener.delegate = onBindEditTextListener
|
||||
}
|
||||
|
||||
private inner class AutoCompleteBindListener : OnBindEditTextListener {
|
||||
|
||||
var delegate: OnBindEditTextListener? = null
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
delegate?.onBindEditText(editText)
|
||||
if (editText !is AutoCompleteTextView || entries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
editText.threshold = 0
|
||||
editText.setAdapter(ArrayAdapter(editText.context, android.R.layout.simple_spinner_dropdown_item, entries))
|
||||
(editText.parent as? ViewGroup)?.findViewById<View>(R.id.dropdown)?.setOnClickListener {
|
||||
editText.showDropDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.widget.EditText
|
||||
import androidx.preference.EditTextPreference
|
||||
import org.koitharu.kotatsu.core.util.EditTextValidator
|
||||
|
||||
class EditTextBindListener(
|
||||
private val inputType: Int,
|
||||
private val hint: String?,
|
||||
private val validator: EditTextValidator?,
|
||||
) : EditTextPreference.OnBindEditTextListener {
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
editText.inputType = inputType
|
||||
editText.hint = hint
|
||||
validator?.attachToEditText(editText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class EditTextDefaultSummaryProvider(
|
||||
private val defaultValue: String
|
||||
) : Preference.SummaryProvider<EditTextPreference> {
|
||||
|
||||
override fun provideSummary(preference: EditTextPreference): CharSequence {
|
||||
val text = preference.text
|
||||
return if (text.isNullOrEmpty()) {
|
||||
preference.context.getString(R.string.default_s, defaultValue)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
|
||||
class EditTextSummaryProvider(@StringRes private val emptySummaryId: Int) :
|
||||
Preference.SummaryProvider<EditTextPreference> {
|
||||
|
||||
override fun provideSummary(preference: EditTextPreference): CharSequence {
|
||||
val text = preference.text
|
||||
return if (text.isNullOrEmpty()) {
|
||||
preference.context.getString(emptySummaryId)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
|
||||
class LinksPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle,
|
||||
defStyleRes: Int = 0,
|
||||
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
val summaryView = holder.findViewById(android.R.id.summary) as TextView
|
||||
summaryView.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.Filter
|
||||
import android.widget.MultiAutoCompleteTextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.preference.EditTextPreference
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
|
||||
class MultiAutoCompleteTextViewPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = R.attr.multiAutoCompleteTextViewPreferenceStyle,
|
||||
@StyleRes defStyleRes: Int = R.style.Preference_MultiAutoCompleteTextView,
|
||||
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private val autoCompleteBindListener = AutoCompleteBindListener()
|
||||
|
||||
var autoCompleteProvider: AutoCompleteProvider? = null
|
||||
|
||||
init {
|
||||
super.setOnBindEditTextListener(autoCompleteBindListener)
|
||||
}
|
||||
|
||||
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
|
||||
autoCompleteBindListener.delegate = onBindEditTextListener
|
||||
}
|
||||
|
||||
private inner class AutoCompleteBindListener : OnBindEditTextListener {
|
||||
|
||||
var delegate: OnBindEditTextListener? = null
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
delegate?.onBindEditText(editText)
|
||||
if (editText !is MultiAutoCompleteTextView) {
|
||||
return
|
||||
}
|
||||
editText.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer())
|
||||
editText.setAdapter(
|
||||
autoCompleteProvider?.let {
|
||||
CompletionAdapter(editText.context, it, ArrayList())
|
||||
}
|
||||
)
|
||||
editText.threshold = 1
|
||||
}
|
||||
}
|
||||
|
||||
interface AutoCompleteProvider {
|
||||
|
||||
suspend fun getSuggestions(query: String): List<String>
|
||||
}
|
||||
|
||||
class SimpleSummaryProvider(
|
||||
private val emptySummary: CharSequence?,
|
||||
) : SummaryProvider<MultiAutoCompleteTextViewPreference> {
|
||||
|
||||
override fun provideSummary(preference: MultiAutoCompleteTextViewPreference): CharSequence? {
|
||||
return if (preference.text.isNullOrEmpty()) {
|
||||
emptySummary
|
||||
} else {
|
||||
preference.text?.trimEnd(' ', ',')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CompletionAdapter(
|
||||
context: Context,
|
||||
private val completionProvider: AutoCompleteProvider,
|
||||
private val dataset: MutableList<String>,
|
||||
) : ArrayAdapter<String>(context, android.R.layout.simple_dropdown_item_1line, dataset) {
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return CompletionFilter(this, completionProvider)
|
||||
}
|
||||
|
||||
fun publishResults(results: List<String>) {
|
||||
dataset.replaceWith(results)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private class CompletionFilter(
|
||||
private val adapter: CompletionAdapter,
|
||||
private val provider: AutoCompleteProvider,
|
||||
) : Filter() {
|
||||
|
||||
@WorkerThread
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val query = constraint?.toString().orEmpty()
|
||||
val suggestions = runBlocking { provider.getSuggestions(query) }
|
||||
return CompletionResults(suggestions)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
val completions = (results as CompletionResults).completions
|
||||
adapter.publishResults(completions)
|
||||
}
|
||||
|
||||
private class CompletionResults(
|
||||
val completions: List<String>,
|
||||
) : FilterResults() {
|
||||
|
||||
init {
|
||||
values = completions
|
||||
count = completions.size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.Preference
|
||||
|
||||
class MultiSummaryProvider(@StringRes private val emptySummaryId: Int) :
|
||||
Preference.SummaryProvider<MultiSelectListPreference> {
|
||||
|
||||
override fun provideSummary(preference: MultiSelectListPreference): CharSequence {
|
||||
val values = preference.values
|
||||
return if (values.isEmpty()) {
|
||||
return preference.context.getString(emptySummaryId)
|
||||
} else {
|
||||
values.joinToString(", ") {
|
||||
preference.entries[preference.findIndexOfValue(it)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||
|
||||
class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityResultContract<Uri?, Uri?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
|
||||
intent.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_TYPE,
|
||||
RingtoneManager.TYPE_NOTIFICATION,
|
||||
)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
|
||||
intent.putExtra(
|
||||
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI,
|
||||
)
|
||||
if (titleResId != 0) {
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, context.getString(titleResId))
|
||||
}
|
||||
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, input)
|
||||
return intent
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||
return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
|
||||
class SliderPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.sliderPreferenceStyle,
|
||||
defStyleRes: Int = R.style.Preference_Slider,
|
||||
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private var valueFrom: Int = 0
|
||||
private var valueTo: Int = 100
|
||||
private var stepSize: Int = 1
|
||||
private var currentValue: Int = 0
|
||||
|
||||
var value: Int
|
||||
get() = currentValue
|
||||
set(value) = setValueInternal(value, notifyChanged = true)
|
||||
|
||||
private val sliderListener = Slider.OnChangeListener { _, value, fromUser ->
|
||||
if (fromUser) {
|
||||
syncValueInternal(value.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(
|
||||
attrs,
|
||||
R.styleable.SliderPreference,
|
||||
defStyleAttr,
|
||||
defStyleRes,
|
||||
) {
|
||||
valueFrom = getFloat(
|
||||
R.styleable.SliderPreference_android_valueFrom,
|
||||
valueFrom.toFloat(),
|
||||
).toInt()
|
||||
valueTo =
|
||||
getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
|
||||
stepSize =
|
||||
getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
val slider = holder.findViewById(R.id.slider) as? Slider ?: return
|
||||
slider.removeOnChangeListener(sliderListener)
|
||||
slider.addOnChangeListener(sliderListener)
|
||||
slider.valueFrom = valueFrom.toFloat()
|
||||
slider.valueTo = valueTo.toFloat()
|
||||
slider.stepSize = stepSize.toFloat()
|
||||
slider.setValueRounded(currentValue.toFloat())
|
||||
slider.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
override fun onSetInitialValue(defaultValue: Any?) {
|
||||
value = getPersistedInt(defaultValue as? Int ?: 0)
|
||||
}
|
||||
|
||||
override fun onGetDefaultValue(a: TypedArray, index: Int): Any {
|
||||
return a.getInt(index, 0)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState()
|
||||
if (superState == null || isPersistent) {
|
||||
return superState
|
||||
}
|
||||
return SavedState(
|
||||
superState = superState,
|
||||
valueFrom = valueFrom,
|
||||
valueTo = valueTo,
|
||||
currentValue = currentValue,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state !is SavedState) {
|
||||
super.onRestoreInstanceState(state)
|
||||
return
|
||||
}
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
valueFrom = state.valueFrom
|
||||
valueTo = state.valueTo
|
||||
currentValue = state.currentValue
|
||||
notifyChanged()
|
||||
}
|
||||
|
||||
private fun setValueInternal(sliderValue: Int, notifyChanged: Boolean) {
|
||||
val newValue = sliderValue.coerceIn(valueFrom, valueTo)
|
||||
if (newValue != currentValue) {
|
||||
currentValue = newValue
|
||||
persistInt(newValue)
|
||||
if (notifyChanged) {
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncValueInternal(sliderValue: Int) {
|
||||
if (sliderValue != currentValue) {
|
||||
if (callChangeListener(sliderValue)) {
|
||||
setValueInternal(sliderValue, notifyChanged = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SavedState : AbsSavedState {
|
||||
|
||||
val valueFrom: Int
|
||||
val valueTo: Int
|
||||
val currentValue: Int
|
||||
|
||||
constructor(
|
||||
superState: Parcelable,
|
||||
valueFrom: Int,
|
||||
valueTo: Int,
|
||||
currentValue: Int,
|
||||
) : super(superState) {
|
||||
this.valueFrom = valueFrom
|
||||
this.valueTo = valueTo
|
||||
this.currentValue = currentValue
|
||||
}
|
||||
|
||||
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
|
||||
valueFrom = source.readInt()
|
||||
valueTo = source.readInt()
|
||||
currentValue = source.readInt()
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
out.writeInt(valueFrom)
|
||||
out.writeInt(valueTo)
|
||||
out.writeInt(currentValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("unused")
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
|
||||
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
|
||||
|
||||
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import javax.inject.Inject
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
|
||||
class TagsAutoCompleteProvider @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
) : MultiAutoCompleteTextViewPreference.AutoCompleteProvider {
|
||||
|
||||
override suspend fun getSuggestions(query: String): List<String> {
|
||||
if (query.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val tags = db.tagsDao.findTags(query = "$query%", limit = 6)
|
||||
val set = HashSet<String>()
|
||||
val result = ArrayList<String>(tags.size)
|
||||
for (tag in tags) {
|
||||
if (set.add(tag.title)) {
|
||||
result.add(tag.title)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.koitharu.kotatsu.settings.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.ColorScheme
|
||||
import org.koitharu.kotatsu.databinding.ItemColorSchemeBinding
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class ThemeChooserPreference @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.themeChooserPreferenceStyle,
|
||||
defStyleRes: Int = R.style.Preference_ThemeChooser,
|
||||
) : Preference(context, attrs, defStyleAttr, defStyleRes) {
|
||||
|
||||
private val entries = ColorScheme.getAvailableList()
|
||||
private var currentValue: ColorScheme = ColorScheme.default
|
||||
private val lastScrollPosition = intArrayOf(-1)
|
||||
private val itemClickListener = View.OnClickListener {
|
||||
val tag = it.tag as? ColorScheme ?: return@OnClickListener
|
||||
setValueInternal(tag.name, true)
|
||||
}
|
||||
private var scrollPersistListener: ScrollPersistListener? = null
|
||||
|
||||
var value: String
|
||||
get() = currentValue.name
|
||||
set(value) = setValueInternal(value, notifyChanged = true)
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
val layout = holder.findViewById(R.id.linear) as? LinearLayout ?: return
|
||||
val scrollView = holder.findViewById(R.id.scrollView) as? HorizontalScrollView ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
scrollView.suppressLayout(true)
|
||||
layout.suppressLayout(true)
|
||||
}
|
||||
layout.removeAllViews()
|
||||
for (theme in entries) {
|
||||
val context = ContextThemeWrapper(context, theme.styleResId)
|
||||
val item = ItemColorSchemeBinding.inflate(LayoutInflater.from(context), layout, false)
|
||||
item.card.isChecked = theme == currentValue
|
||||
item.textViewTitle.setText(theme.titleResId)
|
||||
item.root.tag = theme
|
||||
item.card.tag = theme
|
||||
item.imageViewCheck.isVisible = theme == currentValue
|
||||
item.root.setOnClickListener(itemClickListener)
|
||||
item.card.setOnClickListener(itemClickListener)
|
||||
layout.addView(item.root)
|
||||
}
|
||||
if (lastScrollPosition[0] >= 0) {
|
||||
val scroller = Scroller(scrollView, lastScrollPosition[0])
|
||||
scroller.run()
|
||||
scrollView.post(scroller)
|
||||
}
|
||||
scrollView.viewTreeObserver.run {
|
||||
scrollPersistListener?.let { removeOnScrollChangedListener(it) }
|
||||
scrollPersistListener = ScrollPersistListener(WeakReference(scrollView), lastScrollPosition)
|
||||
addOnScrollChangedListener(scrollPersistListener)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
layout.suppressLayout(false)
|
||||
scrollView.suppressLayout(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetInitialValue(defaultValue: Any?) {
|
||||
value = getPersistedString(
|
||||
when (defaultValue) {
|
||||
is String -> ColorScheme.safeValueOf(defaultValue) ?: ColorScheme.default
|
||||
is ColorScheme -> defaultValue
|
||||
else -> ColorScheme.default
|
||||
}.name,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetDefaultValue(a: TypedArray, index: Int): Any {
|
||||
return a.getInt(index, 0)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState() ?: return null
|
||||
return SavedState(
|
||||
superState = superState,
|
||||
scrollPosition = lastScrollPosition[0],
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state !is SavedState) {
|
||||
super.onRestoreInstanceState(state)
|
||||
return
|
||||
}
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
lastScrollPosition[0] = state.scrollPosition
|
||||
}
|
||||
|
||||
private fun setValueInternal(enumName: String, notifyChanged: Boolean) {
|
||||
val newValue = ColorScheme.safeValueOf(enumName) ?: return
|
||||
if (newValue != currentValue) {
|
||||
currentValue = newValue
|
||||
persistString(newValue.name)
|
||||
if (notifyChanged) {
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SavedState : AbsSavedState {
|
||||
|
||||
val scrollPosition: Int
|
||||
|
||||
constructor(
|
||||
superState: Parcelable,
|
||||
scrollPosition: Int
|
||||
) : super(superState) {
|
||||
this.scrollPosition = scrollPosition
|
||||
}
|
||||
|
||||
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
|
||||
scrollPosition = source.readInt()
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
out.writeInt(scrollPosition)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("unused")
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
|
||||
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
|
||||
|
||||
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ScrollPersistListener(
|
||||
private val scrollViewRef: WeakReference<HorizontalScrollView>,
|
||||
private val lastScrollPosition: IntArray,
|
||||
) : ViewTreeObserver.OnScrollChangedListener {
|
||||
|
||||
override fun onScrollChanged() {
|
||||
val scrollView = scrollViewRef.get() ?: return
|
||||
lastScrollPosition[0] = scrollView.scrollX
|
||||
}
|
||||
}
|
||||
|
||||
private class Scroller(
|
||||
private val scrollView: HorizontalScrollView,
|
||||
private val position: Int,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
scrollView.scrollTo(position, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user