Telegram backups refactoring stage 1

This commit is contained in:
Koitharu
2024-12-14 16:26:37 +02:00
parent 0dbd01f6fc
commit 07e81f21c7
8 changed files with 112 additions and 312 deletions

View File

@@ -7,12 +7,6 @@ import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okio.buffer
import okio.sink
import okio.source
@@ -21,7 +15,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.io.IOException
import javax.inject.Inject
class ExternalBackupStorage @Inject constructor(
@@ -96,36 +89,3 @@ class ExternalBackupStorage @Inject constructor(
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
}
}
class TelegramBackupUploader @Inject constructor(private val settings: AppSettings) {
private val client = OkHttpClient()
suspend fun uploadBackupToTelegram(file: File) = withContext(Dispatchers.IO) {
val botToken = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM"
val chatId = settings.telegramChatId
if (botToken.isNullOrEmpty() || chatId.isNullOrEmpty()) {
throw IllegalStateException("Telegram API key or chat ID not set in settings.")
}
val mediaType = "application/zip".toMediaTypeOrNull()
val requestBody = file.asRequestBody(mediaType)
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("chat_id", chatId)
.addFormDataPart("document", file.name, requestBody)
.build()
val request = Request.Builder()
.url("https://api.telegram.org/bot$botToken/sendDocument")
.post(multipartBody)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Failed to send backup to Telegram: ${response.message}")
}
}
}
}

View File

@@ -0,0 +1,86 @@
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 androidx.annotation.UiContext
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
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 java.io.File
import javax.inject.Inject
class TelegramBackupUploader @Inject constructor(
private val settings: AppSettings,
@BaseHttpClient private val client: OkHttpClient,
@ApplicationContext private val context: Context,
) {
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)
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()
}
suspend fun checkTelegramBotApiKey(apiKey: String) {
val request = Request.Builder()
.url("https://api.telegram.org/bot$apiKey/getMe")
.build()
client.newCall(request).await().ensureSuccess().closeQuietly()
sendMessageToTelegram(apiKey, context.getString(R.string.backup_tg_echo))
}
@SuppressLint("UnsafeImplicitIntentLaunch")
fun openTelegramBot(@UiContext context: Context) {
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)
}
}
private suspend fun sendMessageToTelegram(apiKey: String, message: String) {
val url = "https://api.telegram.org/bot$apiKey/sendMessage?chat_id=${requireChatId()}&text=$message"
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).await().ensureSuccess().closeQuietly()
}
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
"Telegram chat ID not set in settings"
}
}

View File

@@ -43,14 +43,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
var telegramChatId: String?
get() = preferences.getString("telegram_chat_id", null)
set(value) {
preferences.edit().putString("telegram_chat_id", value).apply()
}
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
@@ -497,6 +489,9 @@ 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 backupTelegramChatId: String?
get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
@@ -724,6 +719,7 @@ 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"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"

View File

@@ -6,7 +6,6 @@ import android.net.Uri
import android.os.Bundle
import android.text.format.DateUtils
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
@@ -18,19 +17,13 @@ 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.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import javax.inject.Inject
@AndroidEntryPoint
@@ -40,10 +33,10 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
@Inject
lateinit var backupStorage: ExternalBackupStorage
private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
this,
)
@Inject
lateinit var telegramBackupUploader: TelegramBackupUploader
private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
@@ -51,92 +44,10 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val openTelegramBotPreference = findPreference<Preference>("open_telegram_chat")
openTelegramBotPreference?.setOnPreferenceClickListener {
openTelegramBot("kotatsu_backup_bot")
telegramBackupUploader.openTelegramBot(it.context, "kotatsu_backup_bot")
true
}
val checkApiButton = Preference(requireContext()).apply {
key = "check_api_working"
title = context.getString(R.string.api_telegram_check)
summary = context.getString(R.string.api_check_desc)
}
checkApiButton.setOnPreferenceClickListener {
val apiKey = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM"
if (apiKey.isNotEmpty()) {
checkTelegramBotApiKey(apiKey)
}
true
}
preferenceScreen.addPreference(checkApiButton)
}
private fun checkTelegramBotApiKey(apiKey: String) {
val url = "https://api.telegram.org/bot$apiKey/getMe"
val client = OkHttpClient()
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
requireActivity().runOnUiThread {
if (response.isSuccessful) {
context?.let { sendMessageToTelegram(apiKey, it.getString(R.string.api_is_work)) }
}
}
}
override fun onFailure(call: Call, e: IOException) {
requireActivity().runOnUiThread {
Toast.makeText(requireContext(), R.string.api_net_error, Toast.LENGTH_SHORT).show()
}
}
})
}
private fun openTelegramBot(botUsername: String) {
try {
val telegramIntent = Intent(Intent.ACTION_VIEW)
telegramIntent.data = Uri.parse("https://t.me/$botUsername")
telegramIntent.setPackage("org.telegram.messenger")
startActivity(telegramIntent)
} catch (e: Exception) {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$botUsername"))
startActivity(browserIntent)
}
}
private fun sendMessageToTelegram(apiKey: String, message: String) {
val chatId = settings.telegramChatId
if (chatId.isNullOrEmpty()) {
Toast.makeText(requireContext(), R.string.id_not_set, Toast.LENGTH_SHORT).show()
return
}
val url = "https://api.telegram.org/bot$apiKey/sendMessage?chat_id=$chatId&text=$message"
val client = OkHttpClient()
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
requireActivity().runOnUiThread {
if (response.isSuccessful) {
Toast.makeText(requireContext(), R.string.api_check_success, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), R.string.api_check_error, Toast.LENGTH_SHORT).show()
}
}
}
override fun onFailure(call: Call, e: IOException) {
requireActivity().runOnUiThread {
Toast.makeText(requireContext(), R.string.api_error, Toast.LENGTH_SHORT).show()
}
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -1,153 +0,0 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.os.Build
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import java.io.File
@HiltWorker
class PeriodicalBackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repository: BackupRepository,
private val settings: AppSettings,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
val file = BackupZipOutput(applicationContext).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSources())
backup.put(repository.dumpSettings())
backup.finish()
backup.file
}
val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData)
val target = DocumentFile.fromTreeUri(applicationContext, dirUri)
?.createFile("application/zip", file.nameWithoutExtension)
?.uri ?: return Result.failure()
applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output ->
file.inputStream().copyTo(output)
} ?: return Result.failure()
val botToken = "7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM"
val chatId = settings.telegramChatId ?: return Result.failure()
val success = sendBackupToTelegram(file, botToken, chatId)
file.deleteAwait()
return if (success) {
Result.success(resultData)
} else {
Result.failure()
}
}
fun sendBackupToTelegram(file: File, botToken: String, chatId: String): Boolean {
val client = OkHttpClient()
val mediaType = "application/zip".toMediaTypeOrNull()
val requestBody = file.asRequestBody(mediaType)
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("chat_id", chatId)
.addFormDataPart("document", file.name, requestBody)
.build()
val request = Request.Builder()
.url("https://api.telegram.org/bot$botToken/sendDocument")
.post(multipartBody)
.build()
client.newCall(request).execute().use { response ->
return response.isSuccessful
}
}
@Reusable
class Scheduler @Inject constructor(
private val workManager: WorkManager,
private val settings: AppSettings,
) : PeriodicWorkScheduler {
override suspend fun schedule() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
constraints.setRequiresDeviceIdle(true)
}
val request = PeriodicWorkRequestBuilder<PeriodicalBackupWorker>(
settings.periodicalBackupFrequency,
TimeUnit.DAYS,
).setConstraints(constraints.build())
.keepResultsForAtLeast(20, TimeUnit.DAYS)
.addTag(TAG)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
.await()
}
override suspend fun unschedule() {
workManager
.cancelUniqueWork(TAG)
.await()
}
override suspend fun isScheduled(): Boolean {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.any { !it.state.isFinished }
}
suspend fun getLastSuccessfulBackup(): Date? {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
?.outputData
?.getLong(DATA_TIMESTAMP, 0)
?.let { if (it != 0L) Date(it) else null }
}
}
private companion object {
const val TAG = "backups"
const val DATA_TIMESTAMP = "ts"
}
}

View File

@@ -20,6 +20,8 @@
<string name="acra_password" translatable="false">kgpuhoNJpSsQDCwu</string>
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.history</string>
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.favourites</string>
<string name="tg_backup_bot_token" translatable="false">7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM</string>
<string name="tg_backup_bot_name" translatable="false">kotatsu_backup_bot</string>
<string-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -778,12 +778,9 @@
<!-- Button label, should be as short as possible -->
<string name="incognito">Incognito</string>
<string name="error_connection_reset">Connection reset by remote host</string>
<string name="api_telegram_check">Check API work</string>
<string name="api_check_desc">Click to check the operation of the Telegram Bot API</string>
<string name="api_is_work">Kotatsu backup in Telegram is working!!</string>
<string name="api_net_error">Network error! Check your Net</string>
<string name="id_not_set">Chat ID is not set!</string>
<string name="api_check_success">Success! Check Telegram Bot</string>
<string name="api_check_error">OOPS! Something went wrong</string>
<string name="api_error">Network error!</string>
<string name="backup_tg_check">Check if API works</string>
<string name="backup_tg_echo">Kotatsu backup in Telegram is working!!</string>
<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>
</resources>

View File

@@ -50,15 +50,16 @@
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"
app:allowDividerAbove="true" />
<Preference
android:key="open_telegram_chat"
android:title="Open Telegram Bot"
android:summary="Press to open chat with Kotatsu Backup Bot" />
<EditTextPreference
android:key="telegram_chat_id"
android:title="Telegram Chat ID"
android:inputType="text"
android:defaultValue=""
android:summary="Enter the chat ID where backups should be sent" />
android:summary="Press to open chat with Kotatsu Backup Bot"
android:title="@string/open_telegram_bot" />
</androidx.preference.PreferenceScreen>