Discord RPC

This commit is contained in:
Koitharu
2025-07-20 15:18:11 +03:00
parent b667e32598
commit 4d4c9c7a48
16 changed files with 243 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Preference>(key)
if (pref == null) {

View File

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

View File

@@ -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<LocalManga?>,
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)
}
}

View File

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

View File

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

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

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.27,5.33C17.94,4.71 16.5,4.26 15,4c-0.03,0 -0.05,0.01 -0.07,0.03c-0.18,0.33 -0.39,0.76 -0.53,1.09c-1.61,-0.24 -3.22,-0.24 -4.8,0C9.46,4.78 9.25,4.36 9.06,4.03C9.05,4.01 9.02,4 8.99,4c-1.5,0.26 -2.93,0.71 -4.27,1.33c-0.01,0 -0.02,0.01 -0.03,0.02c-2.72,4.07 -3.47,8.03 -3.1,11.95c0,0.02 0.01,0.04 0.03,0.05c1.8,1.32 3.53,2.12 5.24,2.65c0.03,0.01 0.06,0 0.07,-0.02c0.4,-0.55 0.76,-1.13 1.07,-1.74c0.02,-0.04 0,-0.08 -0.04,-0.09c-0.57,-0.22 -1.11,-0.48 -1.64,-0.78c-0.04,-0.02 -0.04,-0.08 -0.01,-0.11c0.11,-0.08 0.22,-0.17 0.33,-0.25c0.02,-0.02 0.05,-0.02 0.07,-0.01c3.44,1.57 7.15,1.57 10.55,0c0.02,-0.01 0.05,-0.01 0.07,0.01c0.11,0.09 0.22,0.17 0.33,0.26c0.04,0.03 0.04,0.09 -0.01,0.11c-0.52,0.31 -1.07,0.56 -1.64,0.78c-0.04,0.01 -0.05,0.06 -0.04,0.09c0.32,0.61 0.68,1.19 1.07,1.74C17.07,20 17.1,20.01 17.13,20c1.72,-0.53 3.45,-1.33 5.25,-2.65c0.02,-0.01 0.03,-0.03 0.03,-0.05c0.44,-4.53 -0.73,-8.46 -3.1,-11.95C19.3,5.34 19.29,5.33 19.27,5.33zM8.52,14.91c-1.03,0 -1.89,-0.95 -1.89,-2.12s0.84,-2.12 1.89,-2.12c1.06,0 1.9,0.96 1.89,2.12C10.41,13.96 9.57,14.91 8.52,14.91zM15.49,14.91c-1.03,0 -1.89,-0.95 -1.89,-2.12s0.84,-2.12 1.89,-2.12c1.06,0 1.9,0.96 1.89,2.12C17.38,13.96 16.55,14.91 15.49,14.91z" />
</vector>

View File

@@ -21,6 +21,7 @@
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.favourites</string>
<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-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -860,4 +860,10 @@
<string name="main_screen_fab">Show floating Continue button</string>
<string name="main_screen_fab_summary">Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty</string>
<string name="error_corrupted_zip">Corrupted ZIP archive (%s)</string>
<string name="discord" translatable="false">Discord</string>
<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_hint">Paste your Discord Token here</string>
<string name="discord_rpc_summary">Show your reading status on Discord</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="discord_rpc"
android:layout="@layout/preference_toggle_header"
android:title="@string/discord_rpc" />
<EditTextPreference
android:dependency="discord_rpc"
android:key="discord_token"
android:summary="@string/discord_token_summary"
android:title="@string/discord_token" />
</androidx.preference.PreferenceScreen>

View File

@@ -45,24 +45,36 @@
<Preference
android:key="anilist"
android:summary="@string/loading_"
android:title="@string/anilist"
app:icon="@drawable/ic_anilist" />
<Preference
android:key="kitsu"
android:summary="@string/loading_"
android:title="@string/kitsu"
app:icon="@drawable/ic_kitsu" />
<Preference
android:key="mal"
android:summary="@string/loading_"
android:title="@string/mal"
app:icon="@drawable/ic_mal" />
<Preference
android:key="shikimori"
android:summary="@string/loading_"
android:title="@string/shikimori"
app:icon="@drawable/ic_shikimori" />
</PreferenceCategory>
<Preference
android:fragment="org.koitharu.kotatsu.settings.DiscordSettingsFragment"
android:key="discord_rpc"
android:summary="@string/discord_rpc_summary"
android:title="@string/discord_rpc"
app:allowDividerAbove="true"
app:icon="@drawable/ic_discord" />
</PreferenceScreen>

View File

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