Telegram backups refactoring stage 2

This commit is contained in:
Koitharu
2024-12-15 09:44:57 +02:00
parent 07e81f21c7
commit 1b80e48ed4
10 changed files with 206 additions and 101 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Uri?> {
@Inject
lateinit var backupStorage: ExternalBackupStorage
@Inject
lateinit var telegramBackupUploader: TelegramBackupUploader
private val viewModel by viewModels<PeriodicalBackupSettingsViewModel>()
private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
val openTelegramBotPreference = findPreference<Preference>("open_telegram_chat")
openTelegramBotPreference?.setOnPreferenceClickListener {
telegramBackupUploader.openTelegramBot(it.context, "kotatsu_backup_bot")
true
}
findPreference<EditTextPreference>(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<Preference>(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<Preference>(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<Preference>(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
preference.summary = lastBackupDate?.let {
preference.context.getString(
R.string.last_successful_backup,
DateUtils.getRelativeTimeSpanString(it.time),
)
}
}
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)
}
return resolveFile(context)?.path ?: toString()
preference.isVisible = lastBackupDate != null
}
}

View File

@@ -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<Date?>(null)
val backupsDirectory = MutableStateFlow<String?>("")
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()
}
}

View File

@@ -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<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
}
override fun provideSummary(
preference: EditTextPreference,
): CharSequence = preference.text.ifNullOrEmpty {
preference.context.getString(R.string.default_s, defaultValue)
}
}
}

View File

@@ -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<EditTextPreference> {
override fun provideSummary(
preference: EditTextPreference,
): CharSequence = preference.text.ifNullOrEmpty {
preference.context.getString(fallbackResId)
}
}

View File

@@ -783,4 +783,8 @@
<string name="backup_tg_id_not_set">Chat ID is not set</string>
<string name="telegram_chat_id">Telegram chat ID</string>
<string name="open_telegram_bot">Open the Telegram bot</string>
<string name="send_backups_telegram">Send backups in Telegram</string>
<string name="test_connection">Test connection</string>
<string name="telegram_chat_id_summary">Enter the chat ID where backups should be sent</string>
<string name="open_telegram_bot_summary">Press to open chat with Kotatsu Backup Bot</string>
</resources>

View File

@@ -47,19 +47,32 @@
android:key="backup_periodic_last"
android:persistent="false"
android:selectable="false"
app:allowDividerAbove="true"
app:isPreferenceVisible="false" />
<EditTextPreference
android:inputType="text"
android:key="telegram_chat_id"
android:summary="Enter the chat ID where backups should be sent"
android:title="@string/telegram_chat_id"
<SwitchPreferenceCompat
android:defaultValue="false"
android:dependency="backup_periodic"
android:key="backup_periodic_tg_enabled"
android:title="@string/send_backups_telegram"
app:allowDividerAbove="true" />
<EditTextPreference
android:dependency="backup_periodic_tg_enabled"
android:inputType="text"
android:key="backup_periodic_tg_chat_id"
android:title="@string/telegram_chat_id" />
<Preference
android:key="open_telegram_chat"
android:summary="Press to open chat with Kotatsu Backup Bot"
android:dependency="backup_periodic_tg_enabled"
android:key="backup_periodic_tg_open"
android:persistent="false"
android:summary="@string/open_telegram_bot_summary"
android:title="@string/open_telegram_bot" />
<Preference
android:dependency="backup_periodic_tg_enabled"
android:key="backup_periodic_tg_test"
android:persistent="false"
android:title="@string/test_connection" />
</androidx.preference.PreferenceScreen>

View File

@@ -40,7 +40,7 @@
<Preference
android:key="proxy_test"
android:persistent="false"
android:title="Test connection"
android:title="@string/test_connection"
app:allowDividerAbove="true" />
</PreferenceScreen>