From 4d4c9c7a4841eac8fcc37281019b3503062b99a3 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 20 Jul 2025 15:18:11 +0300 Subject: [PATCH 1/2] Discord RPC --- app/build.gradle | 1 + .../PeriodicalBackupSettingsFragment.kt | 5 +- .../koitharu/kotatsu/core/nav/AppRouter.kt | 9 ++ .../kotatsu/core/prefs/AppSettings.kt | 8 ++ .../kotatsu/core/ui/BasePreferenceFragment.kt | 8 ++ .../kotatsu/reader/ui/ReaderActivity.kt | 6 + .../kotatsu/reader/ui/ReaderViewModel.kt | 11 ++ .../kotatsu/scrobbling/discord/DiscordRpc.kt | 105 ++++++++++++++++++ .../settings/DiscordSettingsFragment.kt | 44 ++++++++ .../kotatsu/settings/SettingsActivity.kt | 1 + app/src/main/res/drawable/ic_discord.xml | 11 ++ app/src/main/res/values/constants.xml | 1 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/pref_discord.xml | 17 +++ app/src/main/res/xml/pref_services.xml | 12 ++ gradle/libs.versions.toml | 2 + 16 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt create mode 100644 app/src/main/res/drawable/ic_discord.xml create mode 100644 app/src/main/res/xml/pref_discord.xml diff --git a/app/build.gradle b/app/build.gradle index 0f51ebe02..8b8940a85 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -172,6 +172,7 @@ dependencies { implementation libs.ssiv implementation libs.disk.lru.cache implementation libs.markwon + implementation libs.kizzyrpc implementation libs.acra.http implementation libs.acra.dialog diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt index 916421f53..cfe59c1fd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.text.format.DateUtils import android.view.View import androidx.activity.result.ActivityResultCallback -import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels import androidx.preference.EditTextPreference import androidx.preference.Preference @@ -86,9 +85,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi else -> path } preference.icon = if (path == null) { - ContextCompat.getDrawable(preference.context, R.drawable.ic_alert_outline)?.also { - it.setTint(ContextCompat.getColor(preference.context, R.color.warning)) - } + getWarningIcon() } else { null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt index fcab7686f..12ff405f5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -281,6 +281,10 @@ class AppRouter private constructor( startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) } + fun openDiscordSettings() { + startActivity(discordSettingsIntent(contextOrNull() ?: return)) + } + fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) fun openScrobblerSettings(scrobbler: ScrobblerService) { @@ -745,6 +749,10 @@ class AppRouter private constructor( Intent(context, SettingsActivity::class.java) .setAction(ACTION_PERIODIC_BACKUP) + fun discordSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DISCORD) + fun proxySettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_PROXY) @@ -827,6 +835,7 @@ class AppRouter private constructor( const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" + const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD" const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP" 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 3ddd8b1ae..876f9c371 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 @@ -517,6 +517,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val is32BitColorsEnabled: Boolean get() = prefs.getBoolean(KEY_32BIT_COLOR, false) + val isDiscordRpcEnabled: Boolean + get() = prefs.getBoolean(KEY_DISCORD_RPC, false) + + val discordToken: String? + get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty() + val isPeriodicalBackupEnabled: Boolean get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) @@ -782,6 +788,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" const val KEY_MANGA_LIST_BADGES = "manga_list_badges" const val KEY_TAGS_WARNINGS = "tags_warnings" + const val KEY_DISCORD_RPC = "discord_rpc" + const val KEY_DISCORD_TOKEN = "discord_token" // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index 06113c780..af0ae7ff1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.core.ui import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : (activity as? SettingsActivity)?.setSectionTitle(title) } + protected fun getWarningIcon(): Drawable? = context?.let { ctx -> + ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also { + it.setTint(ContextCompat.getColor(ctx, R.color.warning)) + } + } + private fun focusPreference(key: String) { val pref = findPreference(key) if (pref == null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 9a522da11..9debc305d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -190,6 +190,11 @@ class ReaderActivity : viewModel.onPause() } + override fun onStop() { + super.onStop() + viewModel.onStop() + } + override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -201,6 +206,7 @@ class ReaderActivity : override fun onIdle() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) + viewModel.onIdle() } override fun onVisibilityChanged(v: View, visibility: Int) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index fba48bc93..e5a7135ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -63,6 +63,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.scrobbling.discord.DiscordRpc import org.koitharu.kotatsu.stats.domain.StatsCollector import java.time.Instant import javax.inject.Inject @@ -84,6 +85,7 @@ class ReaderViewModel @Inject constructor( private val historyUpdateUseCase: HistoryUpdateUseCase, private val detectReaderModeUseCase: DetectReaderModeUseCase, private val statsCollector: StatsCollector, + private val discordRpc: DiscordRpc, @LocalStorageChanges localStorageChanges: SharedFlow, interactor: DetailsInteractor, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, @@ -210,6 +212,14 @@ class ReaderViewModel @Inject constructor( } } + fun onStop() { + discordRpc.clearRpc() + } + + fun onIdle() { + discordRpc.setIdle() + } + fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(getMangaOrNull()) @@ -450,6 +460,7 @@ class ReaderViewModel @Inject constructor( uiState.value = newState if (isIncognitoMode.value == false) { statsCollector.onStateChanged(m.id, state) + discordRpc.updateRpc(m.toManga(), newState) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt new file mode 100644 index 000000000..5d0df34d9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt @@ -0,0 +1,105 @@ +package org.koitharu.kotatsu.scrobbling.discord + +import android.content.Context +import com.my.kizzyrpc.KizzyRPC +import com.my.kizzyrpc.entities.presence.Activity +import com.my.kizzyrpc.entities.presence.Assets +import com.my.kizzyrpc.entities.presence.Metadata +import com.my.kizzyrpc.entities.presence.Timestamps +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.lifecycle.RetainedLifecycle +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.model.appUrl +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import javax.inject.Inject + +private const val STATUS_ONLINE = "online" +private const val STATUS_IDLE = "idle" + +@ViewModelScoped +class DiscordRpc @Inject constructor( + @LocalizedAppContext private val context: Context, + private val settings: AppSettings, + lifecycle: ViewModelLifecycle, +) : RetainedLifecycle.OnClearedListener { + + private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default + private val appId = context.getString(R.string.discord_app_id) + private val appName = context.getString(R.string.app_name) + private val rpc = if (settings.isDiscordRpcEnabled) { + settings.discordToken?.let { KizzyRPC(it) } + } else { + null + } + + private var lastActivity: Activity? = null + + init { + lifecycle.addOnClearedListener(this) + } + + override fun onCleared() { + clearRpc() + } + + fun clearRpc() { + rpc?.closeRPC() + } + + fun setIdle() { + if (rpc != null) { + lastActivity?.let { activity -> + updateRpcAsync(activity, idle = true) + } + } + } + + fun updateRpc(manga: Manga, state: ReaderUiState) { + if (rpc != null) { + updateRpcAsync( + activity = Activity( + applicationId = appId, + name = appName, + details = manga.title, + state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal), + type = 3, + timestamps = Timestamps( + start = System.currentTimeMillis(), + ), + assets = Assets( + largeImage = "mp:attachments/1396092865544716390/1396123149921419465/Kotatsu.png?ex=687d9941&is=687c47c1&hm=61da2b66445adaea18ad16cc2c7f829d1c97f0622beec332f123a56f4d294820&=&format=webp&quality=lossless&width=256&height=256", + largeText = "Reading manga on Kotatsu - A manga reader app", + smallText = "Reading: ${manga.title}", + smallImage = "mp:attachments/1282576939831529473/1395712714415800392/button.png?ex=687b7242&is=687a20c2&hm=828ad97537c94128504402b43512523fe30801d534a48258f80c6fd29fda67c2&=&format=webp&quality=lossless", + ), + buttons = listOf( + context.getString(R.string.link_to_manga_in_app), + context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), + ), + metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)), + ), + idle = false, + ) + } + } + + private fun updateRpcAsync(activity: Activity, idle: Boolean) { + val rpc = rpc ?: return + lastActivity = activity + coroutineScope.launch { + rpc.updateRPC( + activity = activity, + status = if (idle) STATUS_IDLE else STATUS_ONLINE, + ) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt new file mode 100644 index 000000000..fc80ec7ab --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.settings + +import android.os.Bundle +import android.view.View +import androidx.preference.EditTextPreference +import org.koitharu.kotatsu.R +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.settings.utils.EditTextFallbackSummaryProvider + +class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_discord) + findPreference(AppSettings.KEY_DISCORD_TOKEN)?.let { pref -> + pref.summaryProvider = EditTextFallbackSummaryProvider(R.string.discord_token_summary) + pref.setDialogMessage(R.string.discord_token_summary) + pref.setOnBindEditTextListener { + it.setHint(R.string.discord_token_hint) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.observe( + AppSettings.KEY_DISCORD_RPC, + AppSettings.KEY_DISCORD_TOKEN, + ).observe(viewLifecycleOwner) { + bindTokenWarning() + } + } + + private fun bindTokenWarning() { + val pref = findPreference(AppSettings.KEY_DISCORD_TOKEN) ?: return + val shouldShowError = settings.isDiscordRpcEnabled && settings.discordToken == null + pref.icon = if (shouldShowError) { + getWarningIcon() + } else { + null + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 9f6aa10d7..45116c6ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -149,6 +149,7 @@ class SettingsActivity : AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment() AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() + AppRouter.ACTION_MANAGE_DISCORD -> DiscordSettingsFragment() AppRouter.ACTION_PROXY -> ProxySettingsFragment() AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance( diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml new file mode 100644 index 000000000..73fde1989 --- /dev/null +++ b/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 80fed50f0..32dc09294 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -21,6 +21,7 @@ org.koitharu.kotatsu.favourites 7455491254:AAHq5AJmizJJpVqFgx16pEAO4g0AX8V6NTY kotatsu_backup_bot + 1395464028611940393 -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b14b5788..9e7c74a47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -860,4 +860,10 @@ Show floating Continue button Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty Corrupted ZIP archive (%s) + Discord + Discord Rich Presence + Discord Token + Enter your Discord Token to enable Rich Presence + Paste your Discord Token here + Show your reading status on Discord diff --git a/app/src/main/res/xml/pref_discord.xml b/app/src/main/res/xml/pref_discord.xml new file mode 100644 index 000000000..fd2390feb --- /dev/null +++ b/app/src/main/res/xml/pref_discord.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 3d36ad3fe..40e059e2a 100644 --- a/app/src/main/res/xml/pref_services.xml +++ b/app/src/main/res/xml/pref_services.xml @@ -45,24 +45,36 @@ + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71646c68b..76d64c64c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ json = "20250517" junit = "4.13.2" junitKtx = "1.2.1" kotlin = "2.1.21" +kizzyRpc = "ad8f2e32eb" ksp = "2.1.21-2.0.1" leakcanary = "3.0-alpha-8" lifecycle = "2.9.1" @@ -94,6 +95,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } json = { module = "org.json:json", version.ref = "json" } junit = { module = "junit:junit", version.ref = "junit" } +kizzyrpc = { module = "com.github.dead8309:KizzyRPC", version.ref = "kizzyRpc" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" } From 896452a096421240ecb98fd6f97e34fb48f08511 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 22 Jul 2025 13:05:27 +0300 Subject: [PATCH 2/2] Discord RPC improvements --- app/src/main/AndroidManifest.xml | 3 + .../koitharu/kotatsu/browser/BrowserClient.kt | 17 +- .../kotatsu/core/prefs/AppSettings.kt | 7 +- .../kotatsu/reader/ui/ReaderViewModel.kt | 2 +- .../kotatsu/scrobbling/discord/DiscordRpc.kt | 105 ----------- .../discord/data/DiscordRepository.kt | 73 ++++++++ .../discord/ui/DiscordAuthActivity.kt | 52 ++++++ .../scrobbling/discord/ui/DiscordRpc.kt | 170 ++++++++++++++++++ .../discord/ui/DiscordTokenWebClient.kt | 34 ++++ .../settings/DiscordSettingsFragment.kt | 44 ----- .../kotatsu/settings/SettingsActivity.kt | 1 + .../discord/DiscordSettingsFragment.kt | 114 ++++++++++++ .../discord/DiscordSettingsViewModel.kt | 67 +++++++ .../kotatsu/settings/discord/TokenState.kt | 6 + app/src/main/res/values/constants.xml | 1 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/pref_discord.xml | 6 + app/src/main/res/xml/pref_services.xml | 2 +- 18 files changed, 552 insertions(+), 159 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 158500059..2fa91911a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -287,6 +287,9 @@ + - updateRpcAsync(activity, idle = true) - } - } - } - - fun updateRpc(manga: Manga, state: ReaderUiState) { - if (rpc != null) { - updateRpcAsync( - activity = Activity( - applicationId = appId, - name = appName, - details = manga.title, - state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal), - type = 3, - timestamps = Timestamps( - start = System.currentTimeMillis(), - ), - assets = Assets( - largeImage = "mp:attachments/1396092865544716390/1396123149921419465/Kotatsu.png?ex=687d9941&is=687c47c1&hm=61da2b66445adaea18ad16cc2c7f829d1c97f0622beec332f123a56f4d294820&=&format=webp&quality=lossless&width=256&height=256", - largeText = "Reading manga on Kotatsu - A manga reader app", - smallText = "Reading: ${manga.title}", - smallImage = "mp:attachments/1282576939831529473/1395712714415800392/button.png?ex=687b7242&is=687a20c2&hm=828ad97537c94128504402b43512523fe30801d534a48258f80c6fd29fda67c2&=&format=webp&quality=lossless", - ), - buttons = listOf( - context.getString(R.string.link_to_manga_in_app), - context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), - ), - metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)), - ), - idle = false, - ) - } - } - - private fun updateRpcAsync(activity: Activity, idle: Boolean) { - val rpc = rpc ?: return - lastActivity = activity - coroutineScope.launch { - rpc.updateRPC( - activity = activity, - status = if (idle) STATUS_IDLE else STATUS_ONLINE, - ) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt new file mode 100644 index 000000000..600c13c00 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu.scrobbling.discord.data + +import android.content.Context +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.closeQuietly +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.BaseHttpClient +import org.koitharu.kotatsu.core.network.CommonHeaders +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.parseRaw +import javax.inject.Inject + +private const val SCHEME_MP = "mp:" + +@Reusable +class DiscordRepository @Inject constructor( + @ApplicationContext context: Context, + private val settings: AppSettings, + @BaseHttpClient private val httpClient: OkHttpClient, +) { + + private val appId = context.getString(R.string.discord_app_id) + + suspend fun getMediaProxyUrl(url: String): String? { + if (isMediaProxyUrl(url)) { + return url + } + val token = checkNotNull(settings.discordToken) { + "Discord token is missing" + } + val request = Request.Builder() + .url("https://discord.com/api/v10/applications/${appId}/external-assets") + .header(CommonHeaders.AUTHORIZATION, token) + .post("{\"urls\":[\"${url}\"]}".toRequestBody("application/json".toMediaType())) + .build() + val body = httpClient.newCall(request).await().parseRaw() + when (val json = Json.parseToJsonElement(body)) { + is JsonObject -> throw RuntimeException(json.jsonObject["message"]?.jsonPrimitive?.content) + is JsonArray -> { + val externalAssetPath = json.firstOrNull() + ?.jsonObject + ?.get("external_asset_path") + ?.toString() + ?.replace("\"", "") + return externalAssetPath?.let { SCHEME_MP + it } + } + else -> throw RuntimeException("Unexpected response: $json") + } + } + + fun isMediaProxyUrl(url: String) = url.startsWith(SCHEME_MP) + + suspend fun checkToken(token: String) { + val request = Request.Builder() + .url("https://discord.com/api/v10/users/@me") + .header(CommonHeaders.AUTHORIZATION, token) + .get() + .build() + httpClient.newCall(request).await().ensureSuccess().closeQuietly() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt new file mode 100644 index 000000000..26fefd648 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.os.Bundle +import android.view.MenuItem +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.browser.BaseBrowserActivity +import org.koitharu.kotatsu.core.parser.ParserMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaSource +import javax.inject.Inject + +@AndroidEntryPoint +class DiscordAuthActivity : BaseBrowserActivity(), DiscordTokenWebClient.Callback { + + @Inject + lateinit var settings: AppSettings + + override fun onCreate2( + savedInstanceState: Bundle?, + source: MangaSource, + repository: ParserMangaRepository? + ) { + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + viewBinding.webView.settings.userAgentString = USER_AGENT + viewBinding.webView.webViewClient = DiscordTokenWebClient(this) + if (savedInstanceState == null) { + viewBinding.webView.loadUrl(BASE_URL) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + viewBinding.webView.stopLoading() + finishAfterTransition() + true + } + + else -> super.onOptionsItemSelected(item) + } + + override fun onTokenObtained(token: String) { + settings.discordToken = token + setResult(RESULT_OK) + finish() + } + + private companion object { + + const val BASE_URL = "https://discord.com/login" + private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt new file mode 100644 index 000000000..0d2066682 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt @@ -0,0 +1,170 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.content.Context +import androidx.annotation.AnyThread +import androidx.collection.ArrayMap +import com.my.kizzyrpc.KizzyRPC +import com.my.kizzyrpc.entities.presence.Activity +import com.my.kizzyrpc.entities.presence.Assets +import com.my.kizzyrpc.entities.presence.Metadata +import com.my.kizzyrpc.entities.presence.Timestamps +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.lifecycle.RetainedLifecycle +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import okio.utf8Size +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.model.appUrl +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository +import java.util.Collections +import javax.inject.Inject + +private const val STATUS_ONLINE = "online" +private const val STATUS_IDLE = "idle" +private const val BUTTON_TEXT_LIMIT = 32 + +@ViewModelScoped +class DiscordRpc @Inject constructor( + @LocalizedAppContext private val context: Context, + private val settings: AppSettings, + private val repository: DiscordRepository, + lifecycle: ViewModelLifecycle, +) : RetainedLifecycle.OnClearedListener { + + private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default + private val appId = context.getString(R.string.discord_app_id) + private val appName = context.getString(R.string.app_name) + private val appIcon = context.getString(R.string.app_icon_url) + private val mpCache = Collections.synchronizedMap(ArrayMap()) + + private var rpc: KizzyRPC? = null + + private var rpcUpdateJob: Job? = null + + @Volatile + private var lastActivity: Activity? = null + + init { + lifecycle.addOnClearedListener(this) + } + + override fun onCleared() { + clearRpc() + } + + fun clearRpc() = synchronized(this) { + rpc?.closeRPC() + rpc = null + } + + fun setIdle() { + lastActivity?.let { activity -> + getRpc()?.updateRpcAsync(activity, idle = true) + } + } + + @AnyThread + fun updateRpc(manga: Manga, state: ReaderUiState) { + getRpc()?.run { + if (settings.isDiscordRpcSkipNsfw && manga.isNsfw()) { + clearRpc() + return + } + updateRpcAsync( + activity = Activity( + applicationId = appId, + name = appName, + details = manga.title, + state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal), + type = 3, + timestamps = Timestamps( + start = lastActivity?.timestamps?.start ?: System.currentTimeMillis(), + ), + assets = Assets( + largeImage = manga.coverUrl, + largeText = context.getString(R.string.reading_s, manga.title), + smallText = context.getString(R.string.discord_rpc_description), + smallImage = appIcon, + ), + buttons = listOf( + context.getString(R.string.read_on_s, appName), + context.getString(R.string.read_on_s, manga.source.getTitle(context)), + ), + metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)), + ), + idle = false, + ) + } + } + + private fun KizzyRPC.updateRpcAsync(activity: Activity, idle: Boolean) { + val prevJob = rpcUpdateJob + rpcUpdateJob = coroutineScope.launch { + prevJob?.cancelAndJoin() + val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false + val mappedActivity = activity.copy( + assets = activity.assets?.let { + it.copy( + largeImage = it.largeImage?.toMediaProxyUrl(), + smallImage = it.smallImage?.toMediaProxyUrl(), + ) + }, + buttons = activity.buttons.takeUnless { hideButtons }, + metadata = activity.metadata.takeUnless { hideButtons }, + ) + lastActivity = mappedActivity + updateRPC( + activity = mappedActivity, + status = if (idle) STATUS_IDLE else STATUS_ONLINE, + since = activity.timestamps?.start ?: System.currentTimeMillis(), + ) + } + } + + suspend fun String.toMediaProxyUrl(): String? { + if (repository.isMediaProxyUrl(this)) { + return this + } + mpCache[this]?.let { + return it + } + return runCatchingCancellable { + repository.getMediaProxyUrl(this) + }.onSuccess { url -> + mpCache[this] = url + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + private fun getRpc(): KizzyRPC? { + rpc?.let { + return it + } + return synchronized(this) { + rpc?.let { + return@synchronized it + } + if (settings.isDiscordRpcEnabled) { + settings.discordToken?.let { KizzyRPC(it) } + } else { + null + }.also { + rpc = it + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt new file mode 100644 index 000000000..8a2cdb9a4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.graphics.Bitmap +import android.webkit.WebView +import org.koitharu.kotatsu.browser.BrowserCallback +import org.koitharu.kotatsu.browser.BrowserClient +import org.koitharu.kotatsu.parsers.util.removeSurrounding + +class DiscordTokenWebClient(private val callback: Callback) : BrowserClient(callback, null) { + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (view != null) { + checkToken(view) + } + } + + private fun checkToken(view: WebView) { + view.evaluateJavascript("window.localStorage.token") { result -> + val token = result + ?.replace("\\\"", "") + ?.removeSurrounding('"') + ?.takeUnless { it == "null" } + if (!token.isNullOrEmpty()) { + callback.onTokenObtained(token) + } + } + } + + interface Callback : BrowserCallback { + + fun onTokenObtained(token: String) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt deleted file mode 100644 index fc80ec7ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import android.view.View -import androidx.preference.EditTextPreference -import org.koitharu.kotatsu.R -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.settings.utils.EditTextFallbackSummaryProvider - -class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_discord) - findPreference(AppSettings.KEY_DISCORD_TOKEN)?.let { pref -> - pref.summaryProvider = EditTextFallbackSummaryProvider(R.string.discord_token_summary) - pref.setDialogMessage(R.string.discord_token_summary) - pref.setOnBindEditTextListener { - it.setHint(R.string.discord_token_hint) - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.observe( - AppSettings.KEY_DISCORD_RPC, - AppSettings.KEY_DISCORD_TOKEN, - ).observe(viewLifecycleOwner) { - bindTokenWarning() - } - } - - private fun bindTokenWarning() { - val pref = findPreference(AppSettings.KEY_DISCORD_TOKEN) ?: return - val shouldShowError = settings.isDiscordRpcEnabled && settings.discordToken == null - pref.icon = if (shouldShowError) { - getWarningIcon() - } else { - null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 45116c6ad..befd2b6dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment import org.koitharu.kotatsu.settings.search.SettingsItem import org.koitharu.kotatsu.settings.search.SettingsSearchFragment import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt new file mode 100644 index 000000000..3095fc5ea --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt @@ -0,0 +1,114 @@ +package org.koitharu.kotatsu.settings.discord + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference +import androidx.preference.EditTextPreferenceDialogFragmentCompat +import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +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.withArgs +import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity + +@AndroidEntryPoint +class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) { + + private val viewModel by viewModels() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_discord) + findPreference(AppSettings.Companion.KEY_DISCORD_TOKEN)?.let { pref -> + pref.dialogMessage = pref.context.getString( + R.string.discord_token_description, + pref.context.getString(R.string.sign_in), + ) + pref.setOnBindEditTextListener { + it.setHint(R.string.discord_token_hint) + it.inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.tokenState.observe(viewLifecycleOwner) { (state, token) -> + bindTokenPreference(state, token) + } + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) { + if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) { + return + } + val f = TokenDialogFragment.newInstance(preference.key) + @Suppress("DEPRECATION") + f.setTargetFragment(this, 0) + f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) + return + } + super.onDisplayPreferenceDialog(preference) + } + + private fun bindTokenPreference(state: TokenState, token: String?) { + val pref = findPreference(AppSettings.Companion.KEY_DISCORD_TOKEN) ?: return + when (state) { + TokenState.EMPTY -> { + pref.icon = null + pref.setSummary(R.string.discord_token_summary) + } + + TokenState.REQUIRED -> { + pref.icon = getWarningIcon() + pref.setSummary(R.string.discord_token_summary) + } + + TokenState.INVALID -> { + pref.icon = getWarningIcon() + pref.summary = getString(R.string.invalid_token, token) + } + + TokenState.VALID -> { + pref.icon = null + pref.summary = token + } + + TokenState.CHECKING -> { + pref.icon = null + pref.setSummary(R.string.loading_) + } + } + } + + class TokenDialogFragment : EditTextPreferenceDialogFragmentCompat() { + + override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { + super.onPrepareDialogBuilder(builder) + builder.setNeutralButton(R.string.sign_in) { _, _ -> + openSignIn() + } + } + + private fun openSignIn() { + activity?.run { + startActivity(Intent(this, DiscordAuthActivity::class.java)) + } + } + + companion object { + + const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG" + + fun newInstance(key: String) = TokenDialogFragment().withArgs(1) { + putString(ARG_KEY, key) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt new file mode 100644 index 000000000..c52e4cadf --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt @@ -0,0 +1,67 @@ +package org.koitharu.kotatsu.settings.discord + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.isNetworkError +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository +import javax.inject.Inject + +@HiltViewModel +class DiscordSettingsViewModel @Inject constructor( + private val settings: AppSettings, + private val repository: DiscordRepository, +) : BaseViewModel() { + + val tokenState: StateFlow> = settings.observe( + AppSettings.KEY_DISCORD_RPC, + AppSettings.KEY_DISCORD_TOKEN, + ).flatMapLatest { + checkToken() + }.stateIn( + viewModelScope + Dispatchers.Default, + SharingStarted.Eagerly, + TokenState.CHECKING to settings.discordToken, + ) + + private suspend fun checkToken(): Flow> = flow { + val token = settings.discordToken + if (!settings.isDiscordRpcEnabled) { + emit( + if (token == null) { + TokenState.EMPTY to null + } else { + TokenState.VALID to token + }, + ) + return@flow + } + if (token == null) { + emit(TokenState.REQUIRED to null) + return@flow + } + emit(TokenState.CHECKING to token) + if (validateToken(token)) { + emit(TokenState.VALID to token) + } else { + emit(TokenState.INVALID to token) + } + } + + private suspend fun validateToken(token: String) = runCatchingCancellable { + repository.checkToken(token) + }.fold( + onSuccess = { true }, + onFailure = { it.isNetworkError() }, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt new file mode 100644 index 000000000..448f4e12f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.settings.discord + +enum class TokenState { + + EMPTY, REQUIRED, INVALID, VALID, CHECKING +} diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 32dc09294..92057a3e6 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -22,6 +22,7 @@ 7455491254:AAHq5AJmizJJpVqFgx16pEAO4g0AX8V6NTY kotatsu_backup_bot 1395464028611940393 + https://raw.githubusercontent.com/KotatsuApp/Kotatsu/refs/heads/devel/metadata/en-US/icon.png -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e7c74a47..b3fc92f14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -864,6 +864,13 @@ Discord Rich Presence Discord Token Enter your Discord Token to enable Rich Presence + Enter your Discord Token or click %s to get it using browser Paste your Discord Token here Show your reading status on Discord + Obtain + Reading manga on Kotatsu - a manga reader app + Reading %s + Read on %s + Do not use RPC for adult content + Invalid token: %s diff --git a/app/src/main/res/xml/pref_discord.xml b/app/src/main/res/xml/pref_discord.xml index fd2390feb..5d55a7093 100644 --- a/app/src/main/res/xml/pref_discord.xml +++ b/app/src/main/res/xml/pref_discord.xml @@ -14,4 +14,10 @@ android:summary="@string/discord_token_summary" android:title="@string/discord_token" /> + + diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 40e059e2a..14e31fb24 100644 --- a/app/src/main/res/xml/pref_services.xml +++ b/app/src/main/res/xml/pref_services.xml @@ -70,7 +70,7 @@