Discord RPC improvements

This commit is contained in:
Koitharu
2025-07-22 13:05:27 +03:00
parent 4d4c9c7a48
commit 896452a096
18 changed files with 552 additions and 159 deletions

View File

@@ -287,6 +287,9 @@
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
android:label="@string/discord" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Looper
import android.webkit.WebResourceRequest
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
open class BrowserClient(
private val callback: BrowserCallback,
private val adBlock: AdBlock,
private val adBlock: AdBlock?,
) : WebViewClient() {
/**
@@ -47,7 +48,7 @@ open class BrowserClient(
override fun shouldInterceptRequest(
view: WebView?,
url: String?
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) {
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, url)
} else {
emptyResponse()
@@ -57,15 +58,17 @@ open class BrowserClient(
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
): WebResourceResponse? =
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
super.shouldInterceptRequest(view, request)
} else {
emptyResponse()
}
private fun emptyResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
@SuppressLint("WrongThread")
@AnyThread
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
url

View File

@@ -520,8 +520,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDiscordRpcEnabled: Boolean
get() = prefs.getBoolean(KEY_DISCORD_RPC, false)
val discordToken: String?
val isDiscordRpcSkipNsfw: Boolean
get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false)
var discordToken: String?
get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty()
set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) }
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
@@ -789,6 +793,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
const val KEY_DISCORD_TOKEN = "discord_token"
// keys for non-persistent preferences

View File

@@ -63,7 +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.scrobbling.discord.ui.DiscordRpc
import org.koitharu.kotatsu.stats.domain.StatsCollector
import java.time.Instant
import javax.inject.Inject

View File

@@ -1,105 +0,0 @@
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,
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<EditTextPreference>(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<EditTextPreference>(AppSettings.KEY_DISCORD_TOKEN) ?: return
val shouldShowError = settings.isDiscordRpcEnabled && settings.discordToken == null
pref.icon = if (shouldShowError) {
getWarningIcon()
} else {
null
}
}
}

View File

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

View File

@@ -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<DiscordSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_discord)
findPreference<EditTextPreference>(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<EditTextPreference>(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)
}
}
}
}

View File

@@ -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<Pair<TokenState, String?>> = 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<Pair<TokenState, String?>> = 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() },
)
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.settings.discord
enum class TokenState {
EMPTY, REQUIRED, INVALID, VALID, CHECKING
}

View File

@@ -22,6 +22,7 @@
<string name="tg_backup_bot_token" translatable="false">7455491254:AAHq5AJmizJJpVqFgx16pEAO4g0AX8V6NTY</string>
<string name="tg_backup_bot_name" translatable="false">kotatsu_backup_bot</string>
<string name="discord_app_id" translatable="false">1395464028611940393</string>
<string name="app_icon_url" translatable="false">https://raw.githubusercontent.com/KotatsuApp/Kotatsu/refs/heads/devel/metadata/en-US/icon.png</string>
<string-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -864,6 +864,13 @@
<string name="discord_rpc">Discord Rich Presence</string>
<string name="discord_token">Discord Token</string>
<string name="discord_token_summary">Enter your Discord Token to enable Rich Presence</string>
<string name="discord_token_description">Enter your Discord Token or click %s to get it using browser</string>
<string name="discord_token_hint">Paste your Discord Token here</string>
<string name="discord_rpc_summary">Show your reading status on Discord</string>
<string name="obtain">Obtain</string>
<string name="discord_rpc_description">Reading manga on Kotatsu - a manga reader app</string>
<string name="reading_s">Reading %s</string>
<string name="read_on_s">Read on %s</string>
<string name="rpc_skip_nsfw_summary">Do not use RPC for adult content</string>
<string name="invalid_token">Invalid token: %s</string>
</resources>

View File

@@ -14,4 +14,10 @@
android:summary="@string/discord_token_summary"
android:title="@string/discord_token" />
<SwitchPreferenceCompat
android:dependency="discord_rpc"
android:key="discord_rpc_skip_nsfw"
android:summary="@string/rpc_skip_nsfw_summary"
android:title="@string/disable_nsfw" />
</androidx.preference.PreferenceScreen>

View File

@@ -70,7 +70,7 @@
</PreferenceCategory>
<Preference
android:fragment="org.koitharu.kotatsu.settings.DiscordSettingsFragment"
android:fragment="org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment"
android:key="discord_rpc"
android:summary="@string/discord_rpc_summary"
android:title="@string/discord_rpc"