Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
},
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View 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())
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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),
)

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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),
)

View File

@@ -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,
) { }

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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(),
),
)
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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) {
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)]
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}