From 1b80e48ed4ed68e1ef5c90b66f4ee917d7736398 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 15 Dec 2024 09:44:57 +0200 Subject: [PATCH] Telegram backups refactoring stage 2 --- .../core/backup/TelegramBackupUploader.kt | 63 +++++++------ .../kotatsu/core/prefs/AppSettings.kt | 10 ++- .../backup/PeriodicalBackupService.kt | 6 +- .../PeriodicalBackupSettingsFragment.kt | 88 ++++++++----------- .../PeriodicalBackupSettingsViewModel.kt | 72 +++++++++++++++ .../utils/EditTextDefaultSummaryProvider.kt | 16 ++-- .../utils/EditTextFallbackSummaryProvider.kt | 17 ++++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/pref_backup_periodic.xml | 27 ++++-- app/src/main/res/xml/pref_proxy.xml | 2 +- 10 files changed, 205 insertions(+), 100 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt index 6d38ddb60..94fe19ee0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt @@ -1,11 +1,11 @@ package org.koitharu.kotatsu.core.backup import android.annotation.SuppressLint -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri +import android.widget.Toast import androidx.annotation.UiContext +import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -14,12 +14,15 @@ import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.ensureSuccess import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.parseJson import java.io.File import javax.inject.Inject @@ -31,56 +34,60 @@ class TelegramBackupUploader @Inject constructor( private val botToken = context.getString(R.string.tg_backup_bot_token) - suspend fun uploadBackupToTelegram(file: File) = withContext(Dispatchers.IO) { - - val mediaType = "application/zip".toMediaTypeOrNull() - val requestBody = file.asRequestBody(mediaType) - + suspend fun uploadBackup(file: File) = withContext(Dispatchers.IO) { + val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull()) val multipartBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("chat_id", requireChatId()) .addFormDataPart("document", file.name, requestBody) .build() - val request = Request.Builder() .url("https://api.telegram.org/bot$botToken/sendDocument") .post(multipartBody) .build() - - client.newCall(request).await().ensureSuccess().closeQuietly() + client.newCall(request).await().consume() } - suspend fun checkTelegramBotApiKey(apiKey: String) { + suspend fun sendTestMessage() { val request = Request.Builder() - .url("https://api.telegram.org/bot$apiKey/getMe") + .url("https://api.telegram.org/bot$botToken/getMe") .build() - client.newCall(request).await().ensureSuccess().closeQuietly() - sendMessageToTelegram(apiKey, context.getString(R.string.backup_tg_echo)) + client.newCall(request).await().consume() + sendMessage(context.getString(R.string.backup_tg_echo)) } @SuppressLint("UnsafeImplicitIntentLaunch") - fun openTelegramBot(@UiContext context: Context) { + fun openBotInApp(@UiContext context: Context): Boolean { val botUsername = context.getString(R.string.tg_backup_bot_name) - try { - val telegramIntent = Intent(Intent.ACTION_VIEW) - telegramIntent.data = Uri.parse("tg://resolve?domain=$botUsername") - context.startActivity(telegramIntent) - } catch (e: ActivityNotFoundException) { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$botUsername")) - context.startActivity(browserIntent) - } + return runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, "tg://resolve?domain=$botUsername".toUri())) + }.recoverCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, "https://t.me/$botUsername".toUri())) + }.onFailure { + Toast.makeText(context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() + }.isSuccess } - private suspend fun sendMessageToTelegram(apiKey: String, message: String) { - val url = "https://api.telegram.org/bot$apiKey/sendMessage?chat_id=${requireChatId()}&text=$message" + private suspend fun sendMessage(message: String) { + val url = "https://api.telegram.org/bot$botToken/sendMessage?chat_id=${requireChatId()}&text=$message" val request = Request.Builder() .url(url) .build() - - client.newCall(request).await().ensureSuccess().closeQuietly() + client.newCall(request).await().consume() } private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) { "Telegram chat ID not set in settings" } + + private fun Response.consume() { + if (isSuccessful) { + closeQuietly() + return + } + val jo = parseJson() + if (!jo.getBooleanOrDefault("ok", true)) { + throw RuntimeException(jo.getStringOrNull("description")) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 7888f2c94..145f7a65c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -489,8 +489,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } + val isBackupTelegramUploadEnabled: Boolean + get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false) + val backupTelegramChatId: String? - get() = prefs.getString(KEY_BACKUP_TG_CHAT, null) + get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.takeUnless { it.isEmpty() } val isReadingTimeEstimationEnabled: Boolean get() = prefs.getBoolean(KEY_READING_TIME, true) @@ -719,7 +722,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SOURCES_VERSION = "sources_version" const val KEY_QUICK_FILTER = "quick_filter" - const val KEY_BACKUP_TG_CHAT = "telegram_chat_id" + const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled" + const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" @@ -733,6 +737,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PROXY_TEST = "proxy_test" const val KEY_OPEN_BROWSER = "open_browser" const val KEY_HANDLE_LINKS = "handle_links" + const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open" + const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test" // old keys are for migration only private const val KEY_IMAGES_PROXY_OLD = "images_proxy" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt index cb4f71799..8b7959fdd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt @@ -15,8 +15,10 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage + @Inject lateinit var telegramBackupUploader: TelegramBackupUploader + @Inject lateinit var repository: BackupRepository @@ -45,7 +47,9 @@ class PeriodicalBackupService : CoroutineIntentService() { } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) - telegramBackupUploader.uploadBackupToTelegram(output.file) + if (settings.isBackupTelegramUploadEnabled) { + telegramBackupUploader.uploadBackup(output.file) + } } finally { output.file.delete() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index fb4ff5d7a..7adbd748f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.settings.backup -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle @@ -8,56 +7,58 @@ import android.text.format.DateUtils import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference import androidx.preference.Preference 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.backup.BackupZipOutput.Companion.DIR_BACKUPS -import org.koitharu.kotatsu.core.backup.ExternalBackupStorage import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.resolveFile +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.ext.viewLifecycleScope -import java.io.File +import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider +import java.util.Date import javax.inject.Inject @AndroidEntryPoint class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups), ActivityResultCallback { - @Inject - lateinit var backupStorage: ExternalBackupStorage - @Inject lateinit var telegramBackupUploader: TelegramBackupUploader + private val viewModel by viewModels() + private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) - - val openTelegramBotPreference = findPreference("open_telegram_chat") - - openTelegramBotPreference?.setOnPreferenceClickListener { - telegramBackupUploader.openTelegramBot(it.context, "kotatsu_backup_bot") - true - } + findPreference(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider = + EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - bindOutputSummary() - bindLastBackupInfo() + viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo) + viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary) + viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) { + findPreference(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it + } + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) + AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(preference.context) + AppSettings.KEY_BACKUP_TG_TEST -> { + viewModel.checkTelegram() + true + } + else -> super.onPreferenceTreeClick(preference) } } @@ -67,45 +68,28 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context?.contentResolver?.takePersistableUriPermission(result, takeFlags) settings.periodicalBackupDirectory = result - bindOutputSummary() - bindLastBackupInfo() + viewModel.updateSummaryData() } } - private fun bindOutputSummary() { + private fun bindOutputSummary(path: String?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return - viewLifecycleScope.launch { - preference.summary = withContext(Dispatchers.Default) { - val value = settings.periodicalBackupDirectory - value?.toUserFriendlyString(preference.context) ?: preference.context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - }.path - } + preference.summary = when (path) { + null -> getString(R.string.invalid_value_message) + "" -> null + else -> path } } - private fun bindLastBackupInfo() { + private fun bindLastBackupInfo(lastBackupDate: Date?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return - viewLifecycleScope.launch { - val lastDate = withContext(Dispatchers.Default) { - backupStorage.getLastBackupDate() - } - preference.summary = lastDate?.let { - preference.context.getString( - R.string.last_successful_backup, - DateUtils.getRelativeTimeSpanString(it.time), - ) - } - preference.isVisible = lastDate != null - } - } - - private fun Uri.toUserFriendlyString(context: Context): String { - val df = DocumentFile.fromTreeUri(context, this) - if (df?.canWrite() != true) { - return context.getString(R.string.invalid_value_message) + preference.summary = lastBackupDate?.let { + preference.context.getString( + R.string.last_successful_backup, + DateUtils.getRelativeTimeSpanString(it.time), + ) } - return resolveFile(context)?.path ?: toString() + preference.isVisible = lastBackupDate != null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt new file mode 100644 index 000000000..c233d257c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt @@ -0,0 +1,72 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS +import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.resolveFile +import java.io.File +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class PeriodicalBackupSettingsViewModel @Inject constructor( + private val settings: AppSettings, + private val telegramUploader: TelegramBackupUploader, + private val backupStorage: ExternalBackupStorage, + @ApplicationContext private val appContext: Context, +) : BaseViewModel() { + + val lastBackupDate = MutableStateFlow(null) + val backupsDirectory = MutableStateFlow("") + val isTelegramCheckLoading = MutableStateFlow(false) + + init { + updateSummaryData() + } + + fun checkTelegram() { + launchJob(Dispatchers.Default) { + try { + isTelegramCheckLoading.value = true + telegramUploader.sendTestMessage() + } finally { + isTelegramCheckLoading.value = false + } + } + } + + fun updateSummaryData() { + updateBackupsDirectory() + updateLastBackupDate() + } + + private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) { + val dir = settings.periodicalBackupDirectory + backupsDirectory.value = if (dir != null) { + dir.toUserFriendlyString() + } else { + (appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path + } + } + + private fun updateLastBackupDate() = launchJob(Dispatchers.Default) { + lastBackupDate.value = backupStorage.getLastBackupDate() + } + + private fun Uri.toUserFriendlyString(): String? { + val df = DocumentFile.fromTreeUri(appContext, this) + if (df?.canWrite() != true) { + return null + } + return resolveFile(appContext)?.path ?: toString() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt index b2f448b1b..292359cef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt @@ -3,17 +3,15 @@ package org.koitharu.kotatsu.settings.utils import androidx.preference.EditTextPreference import androidx.preference.Preference import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty class EditTextDefaultSummaryProvider( - private val defaultValue: String + private val defaultValue: String, ) : Preference.SummaryProvider { - override fun provideSummary(preference: EditTextPreference): CharSequence { - val text = preference.text - return if (text.isNullOrEmpty()) { - preference.context.getString(R.string.default_s, defaultValue) - } else { - text - } + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(R.string.default_s, defaultValue) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt new file mode 100644 index 000000000..b2e14f386 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.settings.utils + +import androidx.annotation.StringRes +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty + +class EditTextFallbackSummaryProvider( + @StringRes private val fallbackResId: Int, +) : Preference.SummaryProvider { + + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(fallbackResId) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b48102f3..e4d67dd5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -783,4 +783,8 @@ Chat ID is not set Telegram chat ID Open the Telegram bot + Send backups in Telegram + Test connection + Enter the chat ID where backups should be sent + Press to open chat with Kotatsu Backup Bot diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index 581479c55..afcb77d36 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -47,19 +47,32 @@ android:key="backup_periodic_last" android:persistent="false" android:selectable="false" - app:allowDividerAbove="true" app:isPreferenceVisible="false" /> + + + android:key="backup_periodic_tg_chat_id" + android:title="@string/telegram_chat_id" /> + + diff --git a/app/src/main/res/xml/pref_proxy.xml b/app/src/main/res/xml/pref_proxy.xml index 38391805e..a69d1cbec 100644 --- a/app/src/main/res/xml/pref_proxy.xml +++ b/app/src/main/res/xml/pref_proxy.xml @@ -40,7 +40,7 @@