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..6426733c2 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.core.backup + +import android.content.Context +import androidx.annotation.CheckResult +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +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.nav.AppRouter +import org.koitharu.kotatsu.core.network.BaseHttpClient +import org.koitharu.kotatsu.core.prefs.AppSettings +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 + +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 uploadBackup(file: File) { + 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(urlOf("sendDocument").build()) + .post(multipartBody) + .build() + client.newCall(request).await().consume() + } + + suspend fun sendTestMessage() { + val request = Request.Builder() + .url(urlOf("getMe").build()) + .build() + client.newCall(request).await().consume() + sendMessage(context.getString(R.string.backup_tg_echo)) + } + + @CheckResult + fun openBotInApp(router: AppRouter): Boolean { + val botUsername = context.getString(R.string.tg_backup_bot_name) + return router.openExternalBrowser("tg://resolve?domain=$botUsername") || + router.openExternalBrowser("https://t.me/$botUsername") + } + + private suspend fun sendMessage(message: String) { + val url = urlOf("sendMessage") + .addQueryParameter("chat_id", requireChatId()) + .addQueryParameter("text", message) + .build() + val request = Request.Builder() + .url(url) + .build() + 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")) + } + } + + private fun urlOf(method: String) = HttpUrl.Builder() + .scheme("https") + .host("api.telegram.org") + .addPathSegment("bot$botToken") + .addPathSegment(method) +} \ No newline at end of file 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 510bf0dce..aea0309be 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 @@ -490,6 +490,12 @@ 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)?.nullIfEmpty() + val isReadingTimeEstimationEnabled: Boolean get() = prefs.getBoolean(KEY_READING_TIME, true) @@ -717,6 +723,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SOURCES_VERSION = "sources_version" const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all" const val KEY_QUICK_FILTER = "quick_filter" + 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" @@ -730,6 +738,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" const val KEY_CLEAR_MANGA_DATA = "manga_data_clear" // old keys are for migration only 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..8b7959fdd 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 @@ -15,6 +16,9 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader + @Inject lateinit var repository: BackupRepository @@ -43,6 +47,9 @@ class PeriodicalBackupService : CoroutineIntentService() { } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) + if (settings.isBackupTelegramUploadEnabled) { + telegramBackupUploader.uploadBackup(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 56397d098..839b2ecf0 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 @@ -8,28 +8,38 @@ import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment 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.settings.utils.EditTextFallbackSummaryProvider import java.util.Date +import javax.inject.Inject @AndroidEntryPoint class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups), ActivityResultCallback { + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader + private val viewModel by viewModels() private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) + findPreference(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider = + EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -37,11 +47,19 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo) viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) { + findPreference(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it + } } override fun onPreferenceTreeClick(preference: Preference): Boolean { val result = when (preference.key) { AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) + AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router) + AppSettings.KEY_BACKUP_TG_TEST -> { + viewModel.checkTelegram() + true + } else -> return super.onPreferenceTreeClick(preference) } if (!result) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt index dfcfc0b4c..f557ed11d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt @@ -7,12 +7,15 @@ 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.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.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.resolveFile import java.io.File import java.util.Date @@ -21,18 +24,32 @@ 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(null) val backupsDirectory = MutableStateFlow("") + val isTelegramCheckLoading = MutableStateFlow(false) val onActionDone = MutableEventFlow() init { updateSummaryData() } + fun checkTelegram() { + launchJob(Dispatchers.Default) { + try { + isTelegramCheckLoading.value = true + telegramUploader.sendTestMessage() + onActionDone.call(ReversibleAction(R.string.connection_ok, null)) + } finally { + isTelegramCheckLoading.value = false + } + } + } + fun updateSummaryData() { updateBackupsDirectory() updateLastBackupDate() 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/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index 8a733d55e..d55555267 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -48,5 +48,31 @@ android:persistent="false" android:selectable="false" app:isPreferenceVisible="false" /> + + + + + + + +