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 @@ + - 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, - ) - } - } -} 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/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt deleted file mode 100644 index fc80ec7ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt +++ /dev/null @@ -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(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(AppSettings.KEY_DISCORD_TOKEN) ?: return - val shouldShowError = settings.isDiscordRpcEnabled && settings.discordToken == null - pref.icon = if (shouldShowError) { - getWarningIcon() - } else { - null - } - } -} 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 45116c6ad..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 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/values/constants.xml b/app/src/main/res/values/constants.xml index 32dc09294..92057a3e6 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -22,6 +22,7 @@ 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 9e7c74a47..b3fc92f14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -864,6 +864,13 @@ 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 index fd2390feb..5d55a7093 100644 --- a/app/src/main/res/xml/pref_discord.xml +++ b/app/src/main/res/xml/pref_discord.xml @@ -14,4 +14,10 @@ android:summary="@string/discord_token_summary" android:title="@string/discord_token" /> + + diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 40e059e2a..14e31fb24 100644 --- a/app/src/main/res/xml/pref_services.xml +++ b/app/src/main/res/xml/pref_services.xml @@ -70,7 +70,7 @@