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