Merge branch 'devel' of github.com:MAPKOBKA135/Kotatsu into MAPKOBKA135-devel

This commit is contained in:
Koitharu
2024-12-14 15:47:41 +02:00
8 changed files with 323 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -671,4 +671,4 @@
<string name="percent_read">Procenta přečtených</string>
<string name="chapters_read">Kapitol přečtených</string>
<string name="chapters_left">Kapitol zbývajících</string>
</resources>
</resources>

View File

@@ -778,4 +778,12 @@
<!-- 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>
</resources>

View File

@@ -50,4 +50,15 @@
app:allowDividerAbove="true"
app:isPreferenceVisible="false" />
<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" />
</androidx.preference.PreferenceScreen>