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/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 @@
+
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/browser/BrowserClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
index 7af49e176..540135269 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
@@ -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
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..8e31d256a 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,16 @@ 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 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)
@@ -782,6 +792,9 @@ 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_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
+ 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..be97c2c6a 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.ui.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/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/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt
index 9f6aa10d7..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
@@ -149,6 +150,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/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/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..92057a3e6 100644
--- a/app/src/main/res/values/constants.xml
+++ b/app/src/main/res/values/constants.xml
@@ -21,6 +21,8 @@
org.koitharu.kotatsu.favourites
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 4b14b5788..b3fc92f14 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -860,4 +860,17 @@
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
+ 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
new file mode 100644
index 000000000..5d55a7093
--- /dev/null
+++ b/app/src/main/res/xml/pref_discord.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml
index 3d36ad3fe..14e31fb24 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" }