Merge branch 'feature/discord_rpc' into devel

master
Koitharu 9 months ago
commit d81173bf76
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -21,6 +21,8 @@
<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 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>

@ -860,4 +860,17 @@
<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_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>

@ -0,0 +1,23 @@
<?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" />
<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>

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

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

Loading…
Cancel
Save