Telegram backups refactoring stage 2

master
Koitharu 1 year ago
parent 07e81f21c7
commit 1b80e48ed4
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.widget.Toast
import androidx.annotation.UiContext import androidx.annotation.UiContext
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -14,12 +14,15 @@ import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.parsers.util.await 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 java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -31,56 +34,60 @@ class TelegramBackupUploader @Inject constructor(
private val botToken = context.getString(R.string.tg_backup_bot_token) private val botToken = context.getString(R.string.tg_backup_bot_token)
suspend fun uploadBackupToTelegram(file: File) = withContext(Dispatchers.IO) { suspend fun uploadBackup(file: File) = withContext(Dispatchers.IO) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val mediaType = "application/zip".toMediaTypeOrNull()
val requestBody = file.asRequestBody(mediaType)
val multipartBody = MultipartBody.Builder() val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("chat_id", requireChatId()) .addFormDataPart("chat_id", requireChatId())
.addFormDataPart("document", file.name, requestBody) .addFormDataPart("document", file.name, requestBody)
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url("https://api.telegram.org/bot$botToken/sendDocument") .url("https://api.telegram.org/bot$botToken/sendDocument")
.post(multipartBody) .post(multipartBody)
.build() .build()
client.newCall(request).await().consume()
client.newCall(request).await().ensureSuccess().closeQuietly()
} }
suspend fun checkTelegramBotApiKey(apiKey: String) { suspend fun sendTestMessage() {
val request = Request.Builder() val request = Request.Builder()
.url("https://api.telegram.org/bot$apiKey/getMe") .url("https://api.telegram.org/bot$botToken/getMe")
.build() .build()
client.newCall(request).await().ensureSuccess().closeQuietly() client.newCall(request).await().consume()
sendMessageToTelegram(apiKey, context.getString(R.string.backup_tg_echo)) sendMessage(context.getString(R.string.backup_tg_echo))
} }
@SuppressLint("UnsafeImplicitIntentLaunch") @SuppressLint("UnsafeImplicitIntentLaunch")
fun openTelegramBot(@UiContext context: Context) { fun openBotInApp(@UiContext context: Context): Boolean {
val botUsername = context.getString(R.string.tg_backup_bot_name) val botUsername = context.getString(R.string.tg_backup_bot_name)
try { return runCatching {
val telegramIntent = Intent(Intent.ACTION_VIEW) context.startActivity(Intent(Intent.ACTION_VIEW, "tg://resolve?domain=$botUsername".toUri()))
telegramIntent.data = Uri.parse("tg://resolve?domain=$botUsername") }.recoverCatching {
context.startActivity(telegramIntent) context.startActivity(Intent(Intent.ACTION_VIEW, "https://t.me/$botUsername".toUri()))
} catch (e: ActivityNotFoundException) { }.onFailure {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/$botUsername")) Toast.makeText(context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
context.startActivity(browserIntent) }.isSuccess
}
} }
private suspend fun sendMessageToTelegram(apiKey: String, message: String) { private suspend fun sendMessage(message: String) {
val url = "https://api.telegram.org/bot$apiKey/sendMessage?chat_id=${requireChatId()}&text=$message" val url = "https://api.telegram.org/bot$botToken/sendMessage?chat_id=${requireChatId()}&text=$message"
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
client.newCall(request).await().consume()
client.newCall(request).await().ensureSuccess().closeQuietly()
} }
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) { private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
"Telegram chat ID not set in settings" "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"))
}
}
} }

@ -489,8 +489,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } 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? val backupTelegramChatId: String?
get() = prefs.getString(KEY_BACKUP_TG_CHAT, null) get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.takeUnless { it.isEmpty() }
val isReadingTimeEstimationEnabled: Boolean val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true) get() = prefs.getBoolean(KEY_READING_TIME, true)
@ -719,7 +722,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version" const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_QUICK_FILTER = "quick_filter" const val KEY_QUICK_FILTER = "quick_filter"
const val KEY_BACKUP_TG_CHAT = "telegram_chat_id" 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 // keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"
@ -733,6 +737,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TEST = "proxy_test" const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser" const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links" 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"
// old keys are for migration only // old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy" private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

@ -15,8 +15,10 @@ class PeriodicalBackupService : CoroutineIntentService() {
@Inject @Inject
lateinit var externalBackupStorage: ExternalBackupStorage lateinit var externalBackupStorage: ExternalBackupStorage
@Inject @Inject
lateinit var telegramBackupUploader: TelegramBackupUploader lateinit var telegramBackupUploader: TelegramBackupUploader
@Inject @Inject
lateinit var repository: BackupRepository lateinit var repository: BackupRepository
@ -45,7 +47,9 @@ class PeriodicalBackupService : CoroutineIntentService() {
} }
externalBackupStorage.put(output.file) externalBackupStorage.put(output.file)
externalBackupStorage.trim(settings.periodicalBackupMaxCount) externalBackupStorage.trim(settings.periodicalBackupMaxCount)
telegramBackupUploader.uploadBackupToTelegram(output.file) if (settings.isBackupTelegramUploadEnabled) {
telegramBackupUploader.uploadBackup(output.file)
}
} finally { } finally {
output.file.delete() output.file.delete()
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.settings.backup package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -8,56 +7,58 @@ import android.text.format.DateUtils
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.viewModels
import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R 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.backup.TelegramBackupUploader
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile 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.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider
import java.io.File import java.util.Date
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups), class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
ActivityResultCallback<Uri?> { ActivityResultCallback<Uri?> {
@Inject
lateinit var backupStorage: ExternalBackupStorage
@Inject @Inject
lateinit var telegramBackupUploader: TelegramBackupUploader lateinit var telegramBackupUploader: TelegramBackupUploader
private val viewModel by viewModels<PeriodicalBackupSettingsViewModel>()
private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) private val outputSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic) addPreferencesFromResource(R.xml.pref_backup_periodic)
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
val openTelegramBotPreference = findPreference<Preference>("open_telegram_chat") EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
openTelegramBotPreference?.setOnPreferenceClickListener {
telegramBackupUploader.openTelegramBot(it.context, "kotatsu_backup_bot")
true
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
bindOutputSummary() viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo)
bindLastBackupInfo() viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary)
viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) {
findPreference<Preference>(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(preference.context)
AppSettings.KEY_BACKUP_TG_TEST -> {
viewModel.checkTelegram()
true
}
else -> super.onPreferenceTreeClick(preference) else -> super.onPreferenceTreeClick(preference)
} }
} }
@ -67,45 +68,28 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags) context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupDirectory = result settings.periodicalBackupDirectory = result
bindOutputSummary() viewModel.updateSummaryData()
bindLastBackupInfo()
} }
} }
private fun bindOutputSummary() { private fun bindOutputSummary(path: String?) {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch { preference.summary = when (path) {
preference.summary = withContext(Dispatchers.Default) { null -> getString(R.string.invalid_value_message)
val value = settings.periodicalBackupDirectory "" -> null
value?.toUserFriendlyString(preference.context) ?: preference.context.run { else -> path
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path
}
} }
} }
private fun bindLastBackupInfo() { private fun bindLastBackupInfo(lastBackupDate: Date?) {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch { preference.summary = lastBackupDate?.let {
val lastDate = withContext(Dispatchers.Default) {
backupStorage.getLastBackupDate()
}
preference.summary = lastDate?.let {
preference.context.getString( preference.context.getString(
R.string.last_successful_backup, R.string.last_successful_backup,
DateUtils.getRelativeTimeSpanString(it.time), DateUtils.getRelativeTimeSpanString(it.time),
) )
} }
preference.isVisible = lastDate != null preference.isVisible = lastBackupDate != null
}
}
private fun Uri.toUserFriendlyString(context: Context): String {
val df = DocumentFile.fromTreeUri(context, this)
if (df?.canWrite() != true) {
return context.getString(R.string.invalid_value_message)
}
return resolveFile(context)?.path ?: toString()
} }
} }

@ -0,0 +1,72 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
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.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.util.ext.resolveFile
import java.io.File
import java.util.Date
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<Date?>(null)
val backupsDirectory = MutableStateFlow<String?>("")
val isTelegramCheckLoading = MutableStateFlow(false)
init {
updateSummaryData()
}
fun checkTelegram() {
launchJob(Dispatchers.Default) {
try {
isTelegramCheckLoading.value = true
telegramUploader.sendTestMessage()
} finally {
isTelegramCheckLoading.value = false
}
}
}
fun updateSummaryData() {
updateBackupsDirectory()
updateLastBackupDate()
}
private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) {
val dir = settings.periodicalBackupDirectory
backupsDirectory.value = if (dir != null) {
dir.toUserFriendlyString()
} else {
(appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path
}
}
private fun updateLastBackupDate() = launchJob(Dispatchers.Default) {
lastBackupDate.value = backupStorage.getLastBackupDate()
}
private fun Uri.toUserFriendlyString(): String? {
val df = DocumentFile.fromTreeUri(appContext, this)
if (df?.canWrite() != true) {
return null
}
return resolveFile(appContext)?.path ?: toString()
}
}

@ -3,17 +3,15 @@ package org.koitharu.kotatsu.settings.utils
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
class EditTextDefaultSummaryProvider( class EditTextDefaultSummaryProvider(
private val defaultValue: String private val defaultValue: String,
) : Preference.SummaryProvider<EditTextPreference> { ) : Preference.SummaryProvider<EditTextPreference> {
override fun provideSummary(preference: EditTextPreference): CharSequence { override fun provideSummary(
val text = preference.text preference: EditTextPreference,
return if (text.isNullOrEmpty()) { ): CharSequence = preference.text.ifNullOrEmpty {
preference.context.getString(R.string.default_s, defaultValue) preference.context.getString(R.string.default_s, defaultValue)
} else {
text
}
} }
} }

@ -0,0 +1,17 @@
package org.koitharu.kotatsu.settings.utils
import androidx.annotation.StringRes
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
class EditTextFallbackSummaryProvider(
@StringRes private val fallbackResId: Int,
) : Preference.SummaryProvider<EditTextPreference> {
override fun provideSummary(
preference: EditTextPreference,
): CharSequence = preference.text.ifNullOrEmpty {
preference.context.getString(fallbackResId)
}
}

@ -783,4 +783,8 @@
<string name="backup_tg_id_not_set">Chat ID is not set</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="telegram_chat_id">Telegram chat ID</string>
<string name="open_telegram_bot">Open the Telegram bot</string> <string name="open_telegram_bot">Open the Telegram bot</string>
<string name="send_backups_telegram">Send backups in Telegram</string>
<string name="test_connection">Test connection</string>
<string name="telegram_chat_id_summary">Enter the chat ID where backups should be sent</string>
<string name="open_telegram_bot_summary">Press to open chat with Kotatsu Backup Bot</string>
</resources> </resources>

@ -47,19 +47,32 @@
android:key="backup_periodic_last" android:key="backup_periodic_last"
android:persistent="false" android:persistent="false"
android:selectable="false" android:selectable="false"
app:allowDividerAbove="true"
app:isPreferenceVisible="false" /> app:isPreferenceVisible="false" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:dependency="backup_periodic"
android:key="backup_periodic_tg_enabled"
android:title="@string/send_backups_telegram"
app:allowDividerAbove="true" />
<EditTextPreference <EditTextPreference
android:dependency="backup_periodic_tg_enabled"
android:inputType="text" android:inputType="text"
android:key="telegram_chat_id" android:key="backup_periodic_tg_chat_id"
android:summary="Enter the chat ID where backups should be sent" android:title="@string/telegram_chat_id" />
android:title="@string/telegram_chat_id"
app:allowDividerAbove="true" />
<Preference <Preference
android:key="open_telegram_chat" android:dependency="backup_periodic_tg_enabled"
android:summary="Press to open chat with Kotatsu Backup Bot" android:key="backup_periodic_tg_open"
android:persistent="false"
android:summary="@string/open_telegram_bot_summary"
android:title="@string/open_telegram_bot" /> android:title="@string/open_telegram_bot" />
<Preference
android:dependency="backup_periodic_tg_enabled"
android:key="backup_periodic_tg_test"
android:persistent="false"
android:title="@string/test_connection" />
</androidx.preference.PreferenceScreen> </androidx.preference.PreferenceScreen>

@ -40,7 +40,7 @@
<Preference <Preference
android:key="proxy_test" android:key="proxy_test"
android:persistent="false" android:persistent="false"
android:title="Test connection" android:title="@string/test_connection"
app:allowDividerAbove="true" /> app:allowDividerAbove="true" />
</PreferenceScreen> </PreferenceScreen>

Loading…
Cancel
Save