Skip to content

Commit

Permalink
Telegram backups refactoring stage 2
Browse files Browse the repository at this point in the history
  • Loading branch information
Koitharu committed Dec 15, 2024
1 parent 07e81f2 commit 1b80e48
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 100 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ class PeriodicalBackupService : CoroutineIntentService() {

@Inject
lateinit var externalBackupStorage: ExternalBackupStorage

@Inject
lateinit var telegramBackupUploader: TelegramBackupUploader

@Inject
lateinit var repository: BackupRepository

Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,63 +1,64 @@
package org.koitharu.kotatsu.settings.backup

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
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)
}
}
Expand All @@ -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
}
}

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

Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading

0 comments on commit 1b80e48

Please sign in to comment.