Discord RPC improvements
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user