diff --git a/app/build.gradle b/app/build.gradle index 0f51ebe02..8b8940a85 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -172,6 +172,7 @@ dependencies { implementation libs.ssiv implementation libs.disk.lru.cache implementation libs.markwon + implementation libs.kizzyrpc implementation libs.acra.http implementation libs.acra.dialog diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 158500059..2fa91911a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -287,6 +287,9 @@ + path } preference.icon = if (path == null) { - ContextCompat.getDrawable(preference.context, R.drawable.ic_alert_outline)?.also { - it.setTint(ContextCompat.getColor(preference.context, R.color.warning)) - } + getWarningIcon() } else { null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt index 7af49e176..540135269 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.browser +import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Looper import android.webkit.WebResourceRequest @@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream open class BrowserClient( private val callback: BrowserCallback, - private val adBlock: AdBlock, + private val adBlock: AdBlock?, ) : WebViewClient() { /** @@ -47,7 +48,7 @@ open class BrowserClient( override fun shouldInterceptRequest( view: WebView?, url: String? - ): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) { + ): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) { super.shouldInterceptRequest(view, url) } else { emptyResponse() @@ -57,15 +58,17 @@ open class BrowserClient( override fun shouldInterceptRequest( view: WebView?, request: WebResourceRequest? - ): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) { - super.shouldInterceptRequest(view, request) - } else { - emptyResponse() - } + ): WebResourceResponse? = + if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) { + super.shouldInterceptRequest(view, request) + } else { + emptyResponse() + } private fun emptyResponse(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf())) + @SuppressLint("WrongThread") @AnyThread private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) { url diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt index fcab7686f..12ff405f5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -281,6 +281,10 @@ class AppRouter private constructor( startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) } + fun openDiscordSettings() { + startActivity(discordSettingsIntent(contextOrNull() ?: return)) + } + fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) fun openScrobblerSettings(scrobbler: ScrobblerService) { @@ -745,6 +749,10 @@ class AppRouter private constructor( Intent(context, SettingsActivity::class.java) .setAction(ACTION_PERIODIC_BACKUP) + fun discordSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DISCORD) + fun proxySettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_PROXY) @@ -827,6 +835,7 @@ class AppRouter private constructor( const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" + const val ACTION_MANAGE_DISCORD = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DISCORD" const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 3ddd8b1ae..8e31d256a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -517,6 +517,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val is32BitColorsEnabled: Boolean get() = prefs.getBoolean(KEY_32BIT_COLOR, false) + val isDiscordRpcEnabled: Boolean + get() = prefs.getBoolean(KEY_DISCORD_RPC, false) + + val isDiscordRpcSkipNsfw: Boolean + get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false) + + var discordToken: String? + get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty() + set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) } + val isPeriodicalBackupEnabled: Boolean get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) @@ -782,6 +792,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" const val KEY_MANGA_LIST_BADGES = "manga_list_badges" const val KEY_TAGS_WARNINGS = "tags_warnings" + const val KEY_DISCORD_RPC = "discord_rpc" + const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw" + const val KEY_DISCORD_TOKEN = "discord_token" // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index 06113c780..af0ae7ff1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -1,9 +1,11 @@ package org.koitharu.kotatsu.core.ui import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -86,6 +88,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : (activity as? SettingsActivity)?.setSectionTitle(title) } + protected fun getWarningIcon(): Drawable? = context?.let { ctx -> + ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also { + it.setTint(ContextCompat.getColor(ctx, R.color.warning)) + } + } + private fun focusPreference(key: String) { val pref = findPreference(key) if (pref == null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 9a522da11..9debc305d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -190,6 +190,11 @@ class ReaderActivity : viewModel.onPause() } + override fun onStop() { + super.onStop() + viewModel.onStop() + } + override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -201,6 +206,7 @@ class ReaderActivity : override fun onIdle() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) + viewModel.onIdle() } override fun onVisibilityChanged(v: View, visibility: Int) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index fba48bc93..be97c2c6a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -63,6 +63,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordRpc import org.koitharu.kotatsu.stats.domain.StatsCollector import java.time.Instant import javax.inject.Inject @@ -84,6 +85,7 @@ class ReaderViewModel @Inject constructor( private val historyUpdateUseCase: HistoryUpdateUseCase, private val detectReaderModeUseCase: DetectReaderModeUseCase, private val statsCollector: StatsCollector, + private val discordRpc: DiscordRpc, @LocalStorageChanges localStorageChanges: SharedFlow, interactor: DetailsInteractor, deleteLocalMangaUseCase: DeleteLocalMangaUseCase, @@ -210,6 +212,14 @@ class ReaderViewModel @Inject constructor( } } + fun onStop() { + discordRpc.clearRpc() + } + + fun onIdle() { + discordRpc.setIdle() + } + fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(getMangaOrNull()) @@ -450,6 +460,7 @@ class ReaderViewModel @Inject constructor( uiState.value = newState if (isIncognitoMode.value == false) { statsCollector.onStateChanged(m.id, state) + discordRpc.updateRpc(m.toManga(), newState) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt new file mode 100644 index 000000000..600c13c00 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/data/DiscordRepository.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu.scrobbling.discord.data + +import android.content.Context +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.closeQuietly +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.BaseHttpClient +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.ensureSuccess +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.parseRaw +import javax.inject.Inject + +private const val SCHEME_MP = "mp:" + +@Reusable +class DiscordRepository @Inject constructor( + @ApplicationContext context: Context, + private val settings: AppSettings, + @BaseHttpClient private val httpClient: OkHttpClient, +) { + + private val appId = context.getString(R.string.discord_app_id) + + suspend fun getMediaProxyUrl(url: String): String? { + if (isMediaProxyUrl(url)) { + return url + } + val token = checkNotNull(settings.discordToken) { + "Discord token is missing" + } + val request = Request.Builder() + .url("https://discord.com/api/v10/applications/${appId}/external-assets") + .header(CommonHeaders.AUTHORIZATION, token) + .post("{\"urls\":[\"${url}\"]}".toRequestBody("application/json".toMediaType())) + .build() + val body = httpClient.newCall(request).await().parseRaw() + when (val json = Json.parseToJsonElement(body)) { + is JsonObject -> throw RuntimeException(json.jsonObject["message"]?.jsonPrimitive?.content) + is JsonArray -> { + val externalAssetPath = json.firstOrNull() + ?.jsonObject + ?.get("external_asset_path") + ?.toString() + ?.replace("\"", "") + return externalAssetPath?.let { SCHEME_MP + it } + } + else -> throw RuntimeException("Unexpected response: $json") + } + } + + fun isMediaProxyUrl(url: String) = url.startsWith(SCHEME_MP) + + suspend fun checkToken(token: String) { + val request = Request.Builder() + .url("https://discord.com/api/v10/users/@me") + .header(CommonHeaders.AUTHORIZATION, token) + .get() + .build() + httpClient.newCall(request).await().ensureSuccess().closeQuietly() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt new file mode 100644 index 000000000..26fefd648 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordAuthActivity.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.os.Bundle +import android.view.MenuItem +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.browser.BaseBrowserActivity +import org.koitharu.kotatsu.core.parser.ParserMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaSource +import javax.inject.Inject + +@AndroidEntryPoint +class DiscordAuthActivity : BaseBrowserActivity(), DiscordTokenWebClient.Callback { + + @Inject + lateinit var settings: AppSettings + + override fun onCreate2( + savedInstanceState: Bundle?, + source: MangaSource, + repository: ParserMangaRepository? + ) { + setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true) + viewBinding.webView.settings.userAgentString = USER_AGENT + viewBinding.webView.webViewClient = DiscordTokenWebClient(this) + if (savedInstanceState == null) { + viewBinding.webView.loadUrl(BASE_URL) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + viewBinding.webView.stopLoading() + finishAfterTransition() + true + } + + else -> super.onOptionsItemSelected(item) + } + + override fun onTokenObtained(token: String) { + settings.discordToken = token + setResult(RESULT_OK) + finish() + } + + private companion object { + + const val BASE_URL = "https://discord.com/login" + private const val USER_AGENT = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt new file mode 100644 index 000000000..0d2066682 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordRpc.kt @@ -0,0 +1,170 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.content.Context +import androidx.annotation.AnyThread +import androidx.collection.ArrayMap +import com.my.kizzyrpc.KizzyRPC +import com.my.kizzyrpc.entities.presence.Activity +import com.my.kizzyrpc.entities.presence.Assets +import com.my.kizzyrpc.entities.presence.Metadata +import com.my.kizzyrpc.entities.presence.Timestamps +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.lifecycle.RetainedLifecycle +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import okio.utf8Size +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.model.appUrl +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository +import java.util.Collections +import javax.inject.Inject + +private const val STATUS_ONLINE = "online" +private const val STATUS_IDLE = "idle" +private const val BUTTON_TEXT_LIMIT = 32 + +@ViewModelScoped +class DiscordRpc @Inject constructor( + @LocalizedAppContext private val context: Context, + private val settings: AppSettings, + private val repository: DiscordRepository, + lifecycle: ViewModelLifecycle, +) : RetainedLifecycle.OnClearedListener { + + private val coroutineScope = lifecycle.lifecycleScope + Dispatchers.Default + private val appId = context.getString(R.string.discord_app_id) + private val appName = context.getString(R.string.app_name) + private val appIcon = context.getString(R.string.app_icon_url) + private val mpCache = Collections.synchronizedMap(ArrayMap()) + + private var rpc: KizzyRPC? = null + + private var rpcUpdateJob: Job? = null + + @Volatile + private var lastActivity: Activity? = null + + init { + lifecycle.addOnClearedListener(this) + } + + override fun onCleared() { + clearRpc() + } + + fun clearRpc() = synchronized(this) { + rpc?.closeRPC() + rpc = null + } + + fun setIdle() { + lastActivity?.let { activity -> + getRpc()?.updateRpcAsync(activity, idle = true) + } + } + + @AnyThread + fun updateRpc(manga: Manga, state: ReaderUiState) { + getRpc()?.run { + if (settings.isDiscordRpcSkipNsfw && manga.isNsfw()) { + clearRpc() + return + } + updateRpcAsync( + activity = Activity( + applicationId = appId, + name = appName, + details = manga.title, + state = context.getString(R.string.chapter_d_of_d, state.chapterNumber, state.chaptersTotal), + type = 3, + timestamps = Timestamps( + start = lastActivity?.timestamps?.start ?: System.currentTimeMillis(), + ), + assets = Assets( + largeImage = manga.coverUrl, + largeText = context.getString(R.string.reading_s, manga.title), + smallText = context.getString(R.string.discord_rpc_description), + smallImage = appIcon, + ), + buttons = listOf( + context.getString(R.string.read_on_s, appName), + context.getString(R.string.read_on_s, manga.source.getTitle(context)), + ), + metadata = Metadata(listOf(manga.appUrl.toString(), manga.publicUrl)), + ), + idle = false, + ) + } + } + + private fun KizzyRPC.updateRpcAsync(activity: Activity, idle: Boolean) { + val prevJob = rpcUpdateJob + rpcUpdateJob = coroutineScope.launch { + prevJob?.cancelAndJoin() + val hideButtons = activity.buttons?.any { it != null && it.utf8Size() > BUTTON_TEXT_LIMIT } ?: false + val mappedActivity = activity.copy( + assets = activity.assets?.let { + it.copy( + largeImage = it.largeImage?.toMediaProxyUrl(), + smallImage = it.smallImage?.toMediaProxyUrl(), + ) + }, + buttons = activity.buttons.takeUnless { hideButtons }, + metadata = activity.metadata.takeUnless { hideButtons }, + ) + lastActivity = mappedActivity + updateRPC( + activity = mappedActivity, + status = if (idle) STATUS_IDLE else STATUS_ONLINE, + since = activity.timestamps?.start ?: System.currentTimeMillis(), + ) + } + } + + suspend fun String.toMediaProxyUrl(): String? { + if (repository.isMediaProxyUrl(this)) { + return this + } + mpCache[this]?.let { + return it + } + return runCatchingCancellable { + repository.getMediaProxyUrl(this) + }.onSuccess { url -> + mpCache[this] = url + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + } + + private fun getRpc(): KizzyRPC? { + rpc?.let { + return it + } + return synchronized(this) { + rpc?.let { + return@synchronized it + } + if (settings.isDiscordRpcEnabled) { + settings.discordToken?.let { KizzyRPC(it) } + } else { + null + }.also { + rpc = it + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt new file mode 100644 index 000000000..8a2cdb9a4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/ui/DiscordTokenWebClient.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.scrobbling.discord.ui + +import android.graphics.Bitmap +import android.webkit.WebView +import org.koitharu.kotatsu.browser.BrowserCallback +import org.koitharu.kotatsu.browser.BrowserClient +import org.koitharu.kotatsu.parsers.util.removeSurrounding + +class DiscordTokenWebClient(private val callback: Callback) : BrowserClient(callback, null) { + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (view != null) { + checkToken(view) + } + } + + private fun checkToken(view: WebView) { + view.evaluateJavascript("window.localStorage.token") { result -> + val token = result + ?.replace("\\\"", "") + ?.removeSurrounding('"') + ?.takeUnless { it == "null" } + if (!token.isNullOrEmpty()) { + callback.onTokenObtained(token) + } + } + } + + interface Callback : BrowserCallback { + + fun onTokenObtained(token: String) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 9f6aa10d7..befd2b6dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment import org.koitharu.kotatsu.settings.search.SettingsItem import org.koitharu.kotatsu.settings.search.SettingsSearchFragment import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel @@ -149,6 +150,7 @@ class SettingsActivity : AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment() AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() + AppRouter.ACTION_MANAGE_DISCORD -> DiscordSettingsFragment() AppRouter.ACTION_PROXY -> ProxySettingsFragment() AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt new file mode 100644 index 000000000..3095fc5ea --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsFragment.kt @@ -0,0 +1,114 @@ +package org.koitharu.kotatsu.settings.discord + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference +import androidx.preference.EditTextPreferenceDialogFragmentCompat +import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity + +@AndroidEntryPoint +class DiscordSettingsFragment : BasePreferenceFragment(R.string.discord) { + + private val viewModel by viewModels() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_discord) + findPreference(AppSettings.Companion.KEY_DISCORD_TOKEN)?.let { pref -> + pref.dialogMessage = pref.context.getString( + R.string.discord_token_description, + pref.context.getString(R.string.sign_in), + ) + pref.setOnBindEditTextListener { + it.setHint(R.string.discord_token_hint) + it.inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.tokenState.observe(viewLifecycleOwner) { (state, token) -> + bindTokenPreference(state, token) + } + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + if (preference is EditTextPreference && preference.key == AppSettings.Companion.KEY_DISCORD_TOKEN) { + if (parentFragmentManager.findFragmentByTag(TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) != null) { + return + } + val f = TokenDialogFragment.newInstance(preference.key) + @Suppress("DEPRECATION") + f.setTargetFragment(this, 0) + f.show(parentFragmentManager, TokenDialogFragment.Companion.DIALOG_FRAGMENT_TAG) + return + } + super.onDisplayPreferenceDialog(preference) + } + + private fun bindTokenPreference(state: TokenState, token: String?) { + val pref = findPreference(AppSettings.Companion.KEY_DISCORD_TOKEN) ?: return + when (state) { + TokenState.EMPTY -> { + pref.icon = null + pref.setSummary(R.string.discord_token_summary) + } + + TokenState.REQUIRED -> { + pref.icon = getWarningIcon() + pref.setSummary(R.string.discord_token_summary) + } + + TokenState.INVALID -> { + pref.icon = getWarningIcon() + pref.summary = getString(R.string.invalid_token, token) + } + + TokenState.VALID -> { + pref.icon = null + pref.summary = token + } + + TokenState.CHECKING -> { + pref.icon = null + pref.setSummary(R.string.loading_) + } + } + } + + class TokenDialogFragment : EditTextPreferenceDialogFragmentCompat() { + + override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { + super.onPrepareDialogBuilder(builder) + builder.setNeutralButton(R.string.sign_in) { _, _ -> + openSignIn() + } + } + + private fun openSignIn() { + activity?.run { + startActivity(Intent(this, DiscordAuthActivity::class.java)) + } + } + + companion object { + + const val DIALOG_FRAGMENT_TAG: String = "androidx.preference.PreferenceFragment.DIALOG" + + fun newInstance(key: String) = TokenDialogFragment().withArgs(1) { + putString(ARG_KEY, key) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt new file mode 100644 index 000000000..c52e4cadf --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/DiscordSettingsViewModel.kt @@ -0,0 +1,67 @@ +package org.koitharu.kotatsu.settings.discord + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.isNetworkError +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.scrobbling.discord.data.DiscordRepository +import javax.inject.Inject + +@HiltViewModel +class DiscordSettingsViewModel @Inject constructor( + private val settings: AppSettings, + private val repository: DiscordRepository, +) : BaseViewModel() { + + val tokenState: StateFlow> = settings.observe( + AppSettings.KEY_DISCORD_RPC, + AppSettings.KEY_DISCORD_TOKEN, + ).flatMapLatest { + checkToken() + }.stateIn( + viewModelScope + Dispatchers.Default, + SharingStarted.Eagerly, + TokenState.CHECKING to settings.discordToken, + ) + + private suspend fun checkToken(): Flow> = flow { + val token = settings.discordToken + if (!settings.isDiscordRpcEnabled) { + emit( + if (token == null) { + TokenState.EMPTY to null + } else { + TokenState.VALID to token + }, + ) + return@flow + } + if (token == null) { + emit(TokenState.REQUIRED to null) + return@flow + } + emit(TokenState.CHECKING to token) + if (validateToken(token)) { + emit(TokenState.VALID to token) + } else { + emit(TokenState.INVALID to token) + } + } + + private suspend fun validateToken(token: String) = runCatchingCancellable { + repository.checkToken(token) + }.fold( + onSuccess = { true }, + onFailure = { it.isNetworkError() }, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt new file mode 100644 index 000000000..448f4e12f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/discord/TokenState.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.settings.discord + +enum class TokenState { + + EMPTY, REQUIRED, INVALID, VALID, CHECKING +} diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml new file mode 100644 index 000000000..73fde1989 --- /dev/null +++ b/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 80fed50f0..92057a3e6 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -21,6 +21,8 @@ org.koitharu.kotatsu.favourites 7455491254:AAHq5AJmizJJpVqFgx16pEAO4g0AX8V6NTY kotatsu_backup_bot + 1395464028611940393 + https://raw.githubusercontent.com/KotatsuApp/Kotatsu/refs/heads/devel/metadata/en-US/icon.png -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b14b5788..b3fc92f14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -860,4 +860,17 @@ Show floating Continue button Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty Corrupted ZIP archive (%s) + Discord + Discord Rich Presence + Discord Token + Enter your Discord Token to enable Rich Presence + Enter your Discord Token or click %s to get it using browser + Paste your Discord Token here + Show your reading status on Discord + Obtain + Reading manga on Kotatsu - a manga reader app + Reading %s + Read on %s + Do not use RPC for adult content + Invalid token: %s diff --git a/app/src/main/res/xml/pref_discord.xml b/app/src/main/res/xml/pref_discord.xml new file mode 100644 index 000000000..5d55a7093 --- /dev/null +++ b/app/src/main/res/xml/pref_discord.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 3d36ad3fe..14e31fb24 100644 --- a/app/src/main/res/xml/pref_services.xml +++ b/app/src/main/res/xml/pref_services.xml @@ -45,24 +45,36 @@ + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71646c68b..76d64c64c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ json = "20250517" junit = "4.13.2" junitKtx = "1.2.1" kotlin = "2.1.21" +kizzyRpc = "ad8f2e32eb" ksp = "2.1.21-2.0.1" leakcanary = "3.0-alpha-8" lifecycle = "2.9.1" @@ -94,6 +95,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } json = { module = "org.json:json", version.ref = "json" } junit = { module = "junit:junit", version.ref = "junit" } +kizzyrpc = { module = "com.github.dead8309:KizzyRPC", version.ref = "kizzyRpc" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" }