Merge branch 'feature/discord_rpc' into devel
commit
d81173bf76
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
Loading…
Reference in New Issue