Include reader tap settings into backups

This commit is contained in:
Koitharu
2025-02-23 18:48:37 +02:00
parent 4cee432a82
commit fc5ad9ff90
15 changed files with 141 additions and 77 deletions

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -40,13 +39,16 @@ class AlternativesUseCase @Inject constructor(
searchHelper(manga.title, SearchKind.TITLE)?.manga
}
}.getOrNull()
list?.forEach { send(it) }
list?.forEach {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
send(details)
}
}
}
}
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
}
}

View File

@@ -16,6 +16,7 @@ class BackupEntry(
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.util.Date
import javax.inject.Inject
@@ -20,6 +21,7 @@ private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
) {
suspend fun dumpHistory(): BackupEntry {
@@ -105,6 +107,14 @@ class BackupRepository @Inject constructor(
return entry
}
fun dumpReaderGridSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
val settingsDump = tapGridSettings.getAllValues()
val json = JsonSerializer(settingsDump).toJson()
entry.data.put(json)
return entry
}
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
@@ -229,4 +239,14 @@ class BackupRepository @Inject constructor(
}
return result
}
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -15,13 +15,13 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -569,20 +569,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) {
prefs.edit {
m.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
}
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -592,15 +579,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}
companion object {
const val TRACK_HISTORY = "history"

View File

@@ -13,19 +13,16 @@ import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper
import android.content.Intent
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.content.pm.ResolveInfo
import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color
import android.net.ConnectivityManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.view.ViewPropertyAnimator
import android.view.Window
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
@@ -37,7 +34,6 @@ import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -46,15 +42,8 @@ import androidx.lifecycle.coroutineScope
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException
@@ -68,7 +57,6 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong
import com.google.android.material.R as materialR
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
@@ -101,25 +89,6 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug()
}.isSuccess
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()
fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) {
coroutineScope.launch {
delay(delay)

View File

@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.SharedPreferences
import androidx.collection.ArraySet
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import org.json.JSONArray
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
if (value == null) {
@@ -28,3 +36,44 @@ fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E):
fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
putString(key, value?.name)
}
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()
fun SharedPreferences.Editor.putAll(values: Map<String, *>) {
values.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}

View File

@@ -14,6 +14,9 @@ data class Progress(
val isFull: Boolean
get() = progress == total
val isIndeterminate: Boolean
get() = total < 0
override fun compareTo(other: Progress): Int = if (total == other.total) {
progress.compareTo(other.progress)
} else {
@@ -44,4 +47,9 @@ data class Progress(
)
fun percentSting() = (percent * 100f).toInt().toString()
companion object {
val INDETERMINATE = Progress(0, -1)
}
}

View File

@@ -3,16 +3,19 @@ package org.koitharu.kotatsu.reader.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.reader.domain.TapGridArea
import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction
import javax.inject.Inject
@Reusable
class TapGridSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -43,6 +46,10 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context)
fun observe() = prefs.observe().flowOn(Dispatchers.IO)
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
private fun initPrefs(withDefaultValues: Boolean) {
prefs.edit {
clear()

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.File
import java.io.FileDescriptor
import java.io.FileInputStream
@@ -38,7 +39,14 @@ class AppBackupAgent : BackupAgent() {
override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data)
val file =
createBackupFile(this, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)))
createBackupFile(
this,
BackupRepository(
MangaDatabase(context = applicationContext),
AppSettings(applicationContext),
TapGridSettings(applicationContext),
),
)
try {
fullBackupFile(file, data)
} finally {
@@ -58,7 +66,11 @@ class AppBackupAgent : BackupAgent() {
restoreBackupFile(
data.fileDescriptor,
size,
BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)),
BackupRepository(
db = MangaDatabase(applicationContext),
settings = AppSettings(applicationContext),
tapGridSettings = TapGridSettings(applicationContext),
),
)
destination.delete()
} else {
@@ -76,6 +88,7 @@ class AppBackupAgent : BackupAgent() {
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSources())
backup.put(repository.dumpSettings())
backup.put(repository.dumpReaderGridSettings())
backup.finish()
backup.file
}
@@ -103,6 +116,7 @@ class AppBackupAgent : BackupAgent() {
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }
backup.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { repository.restoreReaderGridSettings(it) }
}
} finally {
backup.close()

View File

@@ -16,10 +16,10 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.databinding.DialogProgressBinding
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
@AndroidEntryPoint
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
@@ -68,13 +68,14 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
dismiss()
}
private fun onProgressChanged(value: Float) {
private fun onProgressChanged(value: Progress) {
with(requireViewBinding().progressBar) {
isVisible = true
val wasIndeterminate = isIndeterminate
isIndeterminate = value < 0
if (value >= 0) {
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
isIndeterminate = value.isIndeterminate
if (!value.isIndeterminate) {
max = value.total
setProgressCompat(value.progress, !wasIndeterminate)
}
}
}

View File

@@ -20,6 +20,7 @@ data class BackupEntryModel(
BackupEntry.Name.CATEGORIES -> R.string.favourites_categories
BackupEntry.Name.FAVOURITES -> R.string.favourites
BackupEntry.Name.SETTINGS -> R.string.settings
BackupEntry.Name.SETTINGS_READER_GRID -> R.string.reader_actions
BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
BackupEntry.Name.SOURCES -> R.string.remote_sources
}

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.progress.Progress
import java.io.File
import javax.inject.Inject
@@ -18,35 +19,37 @@ class BackupViewModel @Inject constructor(
@ApplicationContext context: Context,
) : BaseViewModel() {
val progress = MutableStateFlow(-1f)
val progress = MutableStateFlow(Progress.INDETERMINATE)
val onBackupDone = MutableEventFlow<File>()
init {
launchLoadingJob {
val file = BackupZipOutput.createTemp(context).use { backup ->
val step = 1f / 6f
progress.value = Progress(0, 7)
backup.put(repository.createIndex())
progress.value = 0f
backup.put(repository.dumpHistory())
progress.value = progress.value.inc()
progress.value += step
backup.put(repository.dumpCategories())
progress.value = progress.value.inc()
progress.value += step
backup.put(repository.dumpFavourites())
progress.value = progress.value.inc()
progress.value += step
backup.put(repository.dumpBookmarks())
progress.value = progress.value.inc()
progress.value += step
backup.put(repository.dumpSources())
progress.value = progress.value.inc()
progress.value += step
backup.put(repository.dumpSettings())
progress.value = progress.value.inc()
backup.put(repository.dumpReaderGridSettings())
progress.value = progress.value.inc()
backup.finish()
progress.value = 1f
backup.file
}
onBackupDone.call(file)

View File

@@ -43,6 +43,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSources())
backup.put(repository.dumpSettings())
backup.put(repository.dumpReaderGridSettings())
backup.finish()
}
externalBackupStorage.put(output.file)

View File

@@ -164,6 +164,15 @@ class RestoreService : CoroutineIntentService() {
notify()
if (BackupEntry.Name.SETTINGS_READER_GRID in entries) {
input.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let {
result += repository.restoreReaderGridSettings(it)
}
progress++
}
notify()
return result
}

View File

@@ -82,6 +82,7 @@
android:layout_marginEnd="@dimen/margin_normal"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintBottom_toBottomOf="@id/guideline"
app:layout_constraintEnd_toEndOf="parent"