diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt index cbd12ef52..4c6982d4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt @@ -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}") - } - } - } -} 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 new file mode 100644 index 000000000..6d38ddb60 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt @@ -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" + } +} 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 7643d74c6..7888f2c94 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 @@ -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" 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 416fd4247..fb4ff5d7a 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 @@ -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("open_telegram_chat") openTelegramBotPreference?.setOnPreferenceClickListener { - openTelegramBot("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) - } + telegramBackupUploader.openTelegramBot(it.context, "kotatsu_backup_bot") 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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt deleted file mode 100644 index e585b2f03..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ /dev/null @@ -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( - 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" - } -} diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 1c260acf0..ab1537dba 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -20,6 +20,8 @@ kgpuhoNJpSsQDCwu org.koitharu.kotatsu.history org.koitharu.kotatsu.favourites + 7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM + kotatsu_backup_bot -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6466c3a6a..5b48102f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -778,12 +778,9 @@ Incognito Connection reset by remote host - Check API work - Click to check the operation of the Telegram Bot API - Kotatsu backup in Telegram is working!! - Network error! Check your Net - Chat ID is not set! - Success! Check Telegram Bot - OOPS! Something went wrong - Network error! + Check if API works + Kotatsu backup in Telegram is working!! + Chat ID is not set + Telegram chat ID + Open the Telegram bot diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index db4819bef..581479c55 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -50,15 +50,16 @@ app:allowDividerAbove="true" app:isPreferenceVisible="false" /> - + 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" /> + +