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 4c6982d4b..cbd12ef52 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,6 +7,12 @@ 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 @@ -15,6 +21,7 @@ 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( @@ -89,3 +96,36 @@ 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/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 05ccaa494..7643d74c6 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,6 +43,14 @@ 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) 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 51b90f8e0..cb4f71799 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 @@ -5,6 +5,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput 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.CoroutineIntentService import javax.inject.Inject @@ -14,7 +15,8 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage - + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader @Inject lateinit var repository: BackupRepository @@ -43,6 +45,7 @@ class PeriodicalBackupService : CoroutineIntentService() { } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) + telegramBackupUploader.uploadBackupToTelegram(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 4e8e9ddb4..416fd4247 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,6 +6,7 @@ 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 @@ -22,7 +23,14 @@ 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 @@ -39,7 +47,96 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) + + 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) + } + 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) @@ -100,3 +197,4 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi return resolveFile(context)?.path ?: toString() } } + 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 new file mode 100644 index 000000000..e585b2f03 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -0,0 +1,153 @@ +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-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cf089e778..c259941bf 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -671,4 +671,4 @@ Procenta přečtených Kapitol přečtených Kapitol zbývajících - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29a840ae3..6466c3a6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -778,4 +778,12 @@ 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! diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index 06529b803..db4819bef 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -50,4 +50,15 @@ app:allowDividerAbove="true" app:isPreferenceVisible="false" /> + + +