Migrate to MVVM

This commit is contained in:
Koitharu
2020-11-17 21:47:22 +02:00
parent eaac271143
commit 7d24286c55
273 changed files with 1926 additions and 2115 deletions

View File

@@ -0,0 +1,138 @@
package org.koitharu.kotatsu.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.GithubRepository
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
class AppUpdateChecker(private val activity: ComponentActivity) {
private val settings by activity.inject<AppSettings>()
private val repo by activity.inject<GithubRepository>()
fun launchIfNeeded(): Job? {
return if (settings.appUpdateAuto && settings.appUpdate + PERIOD < System.currentTimeMillis()) {
launch()
} else {
null
}
}
fun launch(): Job? {
return if (isUpdateSupported(activity)) {
launchInternal()
} else {
null
}
}
suspend fun checkNow() = runCatching {
val version = repo.getLatestVersion()
val newVersionId = VersionId.parse(version.name)
val currentVersionId = VersionId.parse(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId
if (result) {
showUpdateDialog(version)
}
settings.appUpdate = System.currentTimeMillis()
result
}.getOrNull()
private fun launchInternal() = activity.lifecycleScope.launch(Dispatchers.Main) {
checkNow()
}
private fun showUpdateDialog(version: AppVersion) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.app_update_available)
.setMessage(buildString {
append(activity.getString(R.string.new_version_s, version.name))
appendLine()
append(
activity.getString(
R.string.size_s,
FileSizeUtils.formatBytes(activity, version.apkSize)
)
)
appendLine()
appendLine()
append(version.description)
})
.setPositiveButton(R.string.download) { _, _ ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(version.apkUrl)))
}
.setNegativeButton(R.string.close, null)
.create()
.show()
}
companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun isUpdateSupported(context: Context): Boolean {
return getCertificateSHA1Fingerprint(context) == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(context: Context): String? {
val packageInfo = try {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNATURES
)
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
return null
}
val signatures = packageInfo?.signatures
val cert: ByteArray = signatures?.firstOrNull()?.toByteArray() ?: return null
val input: InputStream = ByteArrayInputStream(cert)
val c = try {
val cf = CertificateFactory.getInstance("X509")
cf.generateCertificate(input) as X509Certificate
} catch (e: CertificateException) {
e.printStackTrace()
return null
}
return try {
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
publicKey.byte2HexFormatted()
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
null
} catch (e: CertificateEncodingException) {
e.printStackTrace()
null
}
}
}
}

View File

@@ -0,0 +1,121 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.Cache
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) {
private val trackerRepo by inject<TrackingRepository>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed {
val size = withContext(Dispatchers.IO) {
CacheUtils.computeCacheSize(pref.context, Cache.PAGES.dir)
}
pref.summary = FileSizeUtils.formatBytes(pref.context, size)
}
}
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed {
val size = withContext(Dispatchers.IO) {
CacheUtils.computeCacheSize(pref.context, Cache.THUMBS.dir)
}
pref.summary = FileSizeUtils.formatBytes(pref.context, size)
}
}
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed {
val items = MangaSuggestionsProvider.getItemsCount(pref.context)
pref.summary =
pref.context.resources.getQuantityString(R.plurals.items, items, items)
}
}
findPreference<Preference>(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed {
val items = trackerRepo.count()
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, Cache.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
clearCache(preference, Cache.THUMBS)
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
viewLifecycleScope.launch {
MangaSuggestionsProvider.clearHistory(preference.context)
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()
}
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: Cache) {
val ctx = preference.context.applicationContext
viewLifecycleScope.launch {
try {
preference.isEnabled = false
val size = withContext(Dispatchers.IO) {
CacheUtils.clearCache(ctx, cache.dir)
CacheUtils.computeCacheSize(ctx, cache.dir)
}
preference.summary = FileSizeUtils.formatBytes(ctx, size)
} catch (e: Exception) {
preference.summary = e.getDisplayMessage(ctx.resources)
} finally {
preference.isEnabled = true
}
}
}
}

View File

@@ -0,0 +1,236 @@
package org.koitharu.kotatsu.settings
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.InputType
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arrayMapOf
import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.ListModeSelectDialog
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.md5
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import java.io.File
class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
SharedPreferences.OnSharedPreferenceChangeListener,
StorageSelectDialog.OnStorageSelectListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_main)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LIST_MODE)?.summary =
LIST_MODES[settings.listMode]?.let(::getString)
findPreference<SeekBarPreference>(AppSettings.KEY_GRID_SIZE)?.run {
summary = "%d%%".format(value)
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = "%d%%".format(newValue)
true
}
}
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_SWITCHERS)?.summaryProvider =
MultiSummaryProvider(R.string.gestures_only)
findPreference<MultiSelectListPreference>(AppSettings.KEY_TRACK_SOURCES)?.summaryProvider =
MultiSummaryProvider(R.string.dont_check)
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = AppUpdateChecker.isUpdateSupported(context)
}
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
findPreference<ListPreference>(AppSettings.KEY_ZOOM_MODE)?.let {
it.entryValues = ZoomMode.values().names()
it.setDefaultValue(ZoomMode.FIT_CENTER.name)
}
findPreference<SwitchPreference>(AppSettings.KEY_PROTECT_APP)?.isChecked =
!settings.appPassword.isNullOrEmpty()
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = AppUpdateChecker.isUpdateSupported(context)
}
settings.subscribe(this)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
AppSettings.KEY_LIST_MODE -> findPreference<Preference>(key)?.summary =
LIST_MODES[settings.listMode]?.let(::getString)
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_THEME_AMOLED -> {
findPreference<Preference>(key)?.setSummary(R.string.restart_required)
}
AppSettings.KEY_LOCAL_STORAGE -> {
findPreference<Preference>(key)?.run {
summary = settings.getStorageDir(context)?.getStorageName(context)
?: getString(R.string.not_available)
}
}
}
}
override fun onResume() {
super.onResume()
findPreference<PreferenceScreen>(AppSettings.KEY_REMOTE_SOURCES)?.run {
val total = MangaSource.values().size - 1
summary = getString(
R.string.enabled_d_from_d, total - settings.hiddenSources.size, total
)
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
AppSettings.KEY_LIST_MODE -> {
ListModeSelectDialog.show(childFragmentManager)
true
}
AppSettings.KEY_NOTIFICATIONS_SETTINGS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
startActivity(intent)
} else {
(activity as? SettingsActivity)?.openNotificationSettingsLegacy()
}
true
}
AppSettings.KEY_LOCAL_STORAGE -> {
val ctx = context ?: return false
StorageSelectDialog.Builder(ctx, settings.getStorageDir(ctx), this)
.setTitle(preference.title)
.setNegativeButton(android.R.string.cancel)
.create()
.show()
true
}
AppSettings.KEY_PROTECT_APP -> {
if ((preference as? SwitchPreference ?: return false).isChecked) {
enableAppProtection(preference)
} else {
settings.appPassword = null
}
true
}
AppSettings.KEY_APP_VERSION -> {
checkForUpdates()
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onStorageSelected(file: File) {
settings.setStorageDir(context ?: return, file)
}
private fun enableAppProtection(preference: SwitchPreference) {
val ctx = preference.context ?: return
val cancelListener =
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
override fun onCancel(dialog: DialogInterface?) {
settings.appPassword = null
preference.isChecked = false
preference.isEnabled = true
}
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
}
preference.isEnabled = false
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.enter_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d, password ->
if (password.isBlank()) {
cancelListener.onCancel(d)
return@setPositiveButton
}
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.repeat_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d2, password2 ->
if (password == password2) {
settings.appPassword = password.md5()
preference.isChecked = true
preference.isEnabled = true
} else {
cancelListener.onCancel(d2)
Snackbar.make(
listView,
R.string.passwords_mismatch,
Snackbar.LENGTH_SHORT
).show()
}
}.setTitle(preference.title)
.create()
.show()
}.setTitle(preference.title)
.create()
.show()
}
private fun checkForUpdates() {
viewLifecycleScope.launch {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(R.string.checking_for_updates)
isSelectable = false
}
val result = AppUpdateChecker(activity ?: return@launch).checkNow()
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(
when (result) {
true -> R.string.check_for_updates
false -> R.string.no_update_available
null -> R.string.update_check_failed
}
)
isSelectable = true
}
}
}
private companion object {
val LIST_MODES = arrayMapOf(
ListMode.DETAILED_LIST to R.string.detailed_list,
ListMode.GRID to R.string.grid,
ListMode.LIST to R.string.list
)
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
class NetworkSettingsFragment : BasePreferenceFragment(R.string.settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
//TODO https://developer.android.com/training/basics/network-ops/managing
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.settings
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.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.utils.RingtonePickContract
import org.koitharu.kotatsu.utils.ext.toUriOrNull
class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_notifications)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
val uri = settings.notificationSound.toUriOrNull()
summary = RingtoneManager.getRingtone(context, uri).getTitle(context)
}
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
return when (preference?.key) {
AppSettings.KEY_NOTIFICATIONS_SOUND -> {
registerForActivityResult(RingtonePickContract(preference.title.toString())) { uri ->
settings.notificationSound = uri?.toString().orEmpty()
findPreference<Preference>(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
summary = RingtoneManager.getRingtone(context, uri).getTitle(context)
}
}.launch(settings.notificationSound.toUriOrNull())
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.utils.ext.names
class ReaderSettingsFragment : BasePreferenceFragment(R.string.reader_settings) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_reader)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_SWITCHERS)?.let {
it.summaryProvider = MultiSummaryProvider(R.string.gestures_only)
}
findPreference<ListPreference>(AppSettings.KEY_ZOOM_MODE)?.let {
it.entryValues = ZoomMode.values().names()
it.setDefaultValue(ZoomMode.FIT_CENTER.name)
}
}
}

View File

@@ -0,0 +1,62 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.MangaSource
class SettingsActivity : BaseActivity(),
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (supportFragmentManager.findFragmentById(R.id.container) == null) {
supportFragmentManager.commit {
replace(R.id.container, MainSettingsFragment())
}
}
}
@Suppress("DEPRECATION")
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference
): Boolean {
val fm = supportFragmentManager
val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment)
fragment.arguments = pref.extras
fragment.setTargetFragment(caller, 0)
openFragment(fragment)
return true
}
fun openMangaSourceSettings(mangaSource: MangaSource) {
openFragment(SourceSettingsFragment.newInstance(mangaSource))
}
fun openNotificationSettingsLegacy() {
openFragment(NotificationSettingsLegacyFragment())
}
private fun openFragment(fragment: Fragment) {
supportFragmentManager.commit {
replace(R.id.container, fragment)
setReorderingAllowed(true)
addToBackStack(null)
}
}
companion object {
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)
}
}

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.settings
import android.net.Uri
import org.koin.android.ext.koin.androidContext
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
val settingsModule
get() = module {
single { BackupRepository(get()) }
single { RestoreRepository(get()) }
single { AppSettings(androidContext()) }
viewModel { BackupViewModel(get(), androidContext()) }
viewModel { (uri: Uri?) -> RestoreViewModel(uri, get(), androidContext()) }
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceFragmentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.settings.utils.EditTextSummaryProvider
import org.koitharu.kotatsu.utils.ext.withArgs
class SourceSettingsFragment : PreferenceFragmentCompat() {
private val source by lazy(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable<MangaSource>(EXTRA_SOURCE)!!
}
override fun onResume() {
super.onResume()
activity?.title = source.title
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.sharedPreferencesName = source.name
val repo = source.repository as? RemoteMangaRepository ?: return
val keys = repo.onCreatePreferences()
addPreferencesFromResource(R.xml.pref_source)
for (i in 0 until preferenceScreen.preferenceCount) {
val pref = preferenceScreen.getPreference(i)
pref.isVisible = pref.key in keys
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<EditTextPreference>(SourceSettings.KEY_DOMAIN)?.summaryProvider =
EditTextSummaryProvider(R.string._default)
}
companion object {
private const val EXTRA_SOURCE = "source"
fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) {
putParcelable(EXTRA_SOURCE, source)
}
}
}

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu.settings.backup
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.dialog_progress.*
import org.koin.android.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
class BackupDialogFragment : AlertDialogFragment(R.layout.dialog_progress) {
private val viewModel by viewModel<BackupViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
textView_title.setText(R.string.create_backup)
textView_subtitle.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: AlertDialog.Builder) {
builder.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null)
}
private fun onError(e: Throwable) {
AlertDialog.Builder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))
.show()
dismiss()
}
private fun onProgressChanged(progress: Progress?) {
with(progressBar) {
isVisible = true
isIndeterminate = progress == null
if (progress != null) {
this.max = progress.total
this.progress = progress.value
}
}
}
private fun onBackupDone(file: File) {
ShareHelper.shareBackup(context ?: return, file)
dismiss()
}
companion object {
const val TAG = "BackupDialogFragment"
}
}

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 kotlinx.android.synthetic.main.fragment_list.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
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) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
Snackbar.make(
recyclerView, 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,41 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
class BackupViewModel(
private val repository: BackupRepository,
private val context: Context
) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null)
val onBackupDone = SingleLiveEvent<File>()
init {
launchLoadingJob {
val backup = BackupArchive.createNew(context)
backup.put(repository.createIndex())
progress.value = Progress(0, 3)
backup.put(repository.dumpHistory())
progress.value = Progress(1, 3)
backup.put(repository.dumpCategories())
progress.value = Progress(2, 3)
backup.put(repository.dumpFavourites())
progress.value = Progress(3, 3)
backup.flush()
progress.value = null
backup.cleanup()
onBackupDone.call(backup.file)
}
}
}

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.settings.backup
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import kotlinx.android.synthetic.main.dialog_progress.*
import org.koin.android.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import org.koitharu.kotatsu.utils.ext.withArgs
import org.koitharu.kotatsu.utils.progress.Progress
class RestoreDialogFragment : AlertDialogFragment(R.layout.dialog_progress) {
private val viewModel by viewModel<RestoreViewModel> {
parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
textView_title.setText(R.string.restore_backup)
textView_subtitle.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: AlertDialog.Builder) {
builder.setCancelable(false)
}
private fun onError(e: Throwable) {
AlertDialog.Builder(context ?: return)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error)
.setMessage(e.getDisplayMessage(resources))
.show()
dismiss()
}
private fun onProgressChanged(progress: Progress?) {
with(progressBar) {
isVisible = true
isIndeterminate = progress == null
if (progress != null) {
this.max = progress.total
this.progress = progress.value
}
}
}
private fun onRestoreDone(result: CompositeResult) {
val builder = AlertDialog.Builder(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,69 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileNotFoundException
class RestoreViewModel(
uri: Uri?,
private val repository: RestoreRepository,
private val context: Context
) : BaseViewModel() {
val progress = MutableLiveData<Progress?>(null)
val onRestoreDone = SingleLiveEvent<CompositeResult>()
init {
launchLoadingJob {
if (uri == null) {
throw FileNotFoundException()
}
val contentResolver = context.contentResolver
@Suppress("BlockingMethodInNonBlockingContext")
val backup = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri)
?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
BackupArchive(tempFile)
}
try {
backup.unpack()
val result = CompositeResult()
progress.value = Progress(0, 3)
result += repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
progress.value = Progress(1, 3)
result += repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
progress.value = Progress(2, 3)
result += repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
progress.value = Progress(3, 3)
onRestoreDone.call(result)
} finally {
withContext(NonCancellable) {
backup.cleanup()
backup.file.delete()
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.settings.sources
import android.view.ViewGroup
import kotlinx.android.synthetic.main.item_source_config.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaSource
class SourceViewHolder(parent: ViewGroup) :
BaseViewHolder<MangaSource, Boolean>(parent, R.layout.item_source_config) {
override fun onBind(data: MangaSource, extra: Boolean) {
textView_title.text = data.title
imageView_hidden.isChecked = extra
}
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.settings.sources
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.item_source_config.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.safe
class SourcesAdapter(private val onItemClickListener: OnRecyclerItemClickListener<MangaSource>) :
RecyclerView.Adapter<SourceViewHolder>(), KoinComponent {
private val dataSet = MangaProviderFactory.getSources(includeHidden = true).toMutableList()
private val settings by inject<AppSettings>()
private val hiddenItems = settings.hiddenSources.mapNotNull {
safe {
MangaSource.valueOf(it)
}
}.toMutableSet()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = SourceViewHolder(parent).also(::onViewHolderCreated)
override fun getItemCount() = dataSet.size
override fun onBindViewHolder(holder: SourceViewHolder, position: Int) {
val item = dataSet[position]
holder.bind(item, !hiddenItems.contains(item))
}
@SuppressLint("ClickableViewAccessibility")
private fun onViewHolderCreated(holder: SourceViewHolder) {
holder.imageView_hidden.setOnCheckedChangeListener {
if (it) {
hiddenItems.remove(holder.requireData())
} else {
hiddenItems.add(holder.requireData())
}
settings.hiddenSources = hiddenItems.mapToSet { x -> x.name }
}
holder.imageView_config.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), holder.bindingAdapterPosition, v)
}
holder.imageView_handle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onItemClickListener.onItemLongClick(
holder.requireData(),
holder.bindingAdapterPosition,
v
)
} else {
false
}
}
}
fun moveItem(oldPos: Int, newPos: Int) {
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
settings.sourcesOrder = dataSet.map { it.ordinal }
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.settings.sources
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
class SourcesReorderCallback :
ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val adapter = recyclerView.adapter as? SourcesAdapter ?: return false
val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.settings.sources
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.fragment_settings_sources.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.settings.SettingsActivity
class SourcesSettingsFragment : BaseFragment(R.layout.fragment_settings_sources),
OnRecyclerItemClickListener<MangaSource> {
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
reorderHelper = ItemTouchHelper(SourcesReorderCallback())
}
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.remote_sources)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL))
recyclerView.adapter = SourcesAdapter(this)
reorderHelper.attachToRecyclerView(recyclerView)
}
override fun onDestroyView() {
reorderHelper.attachToRecyclerView(null)
super.onDestroyView()
}
override fun onItemClick(item: MangaSource, position: Int, view: View) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item)
}
override fun onItemLongClick(item: MangaSource, position: Int, view: View): Boolean {
reorderHelper.startDrag(
recyclerView.findViewHolderForAdapterPosition(position) ?: return false
)
return true
}
}

View File

@@ -0,0 +1,17 @@
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 {
return if (preference.text.isNullOrEmpty()) {
preference.context.getString(emptySummaryId)
} else {
preference.text
}
}
}

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,34 @@
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
class RingtonePickContract(private val title: String?) : 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 (title != null) {
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title)
}
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, input)
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent?.getParcelableExtra<Uri>(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
}