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/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt index 916421f53..cfe59c1fd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.text.format.DateUtils import android.view.View import androidx.activity.result.ActivityResultCallback -import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels import androidx.preference.EditTextPreference import androidx.preference.Preference @@ -86,9 +85,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi else -> path } preference.icon = if (path == null) { - ContextCompat.getDrawable(preference.context, R.drawable.ic_alert_outline)?.also { - it.setTint(ContextCompat.getColor(preference.context, R.color.warning)) - } + getWarningIcon() } else { null } 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..876f9c371 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,12 @@ 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 discordToken: String? + get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty() + val isPeriodicalBackupEnabled: Boolean get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) @@ -782,6 +788,8 @@ 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_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..e5a7135ba 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.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/DiscordRpc.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt new file mode 100644 index 000000000..5d0df34d9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/discord/DiscordRpc.kt @@ -0,0 +1,105 @@ +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, + ) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt new file mode 100644 index 000000000..fc80ec7ab --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DiscordSettingsFragment.kt @@ -0,0 +1,44 @@ +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 9f6aa10d7..45116c6ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -149,6 +149,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/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..32dc09294 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -21,6 +21,7 @@ org.koitharu.kotatsu.favourites 7455491254:AAHq5AJmizJJpVqFgx16pEAO4g0AX8V6NTY kotatsu_backup_bot + 1395464028611940393 -1 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b14b5788..9e7c74a47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -860,4 +860,10 @@ 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 + Paste your Discord Token here + Show your reading status on Discord 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..fd2390feb --- /dev/null +++ b/app/src/main/res/xml/pref_discord.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/xml/pref_services.xml b/app/src/main/res/xml/pref_services.xml index 3d36ad3fe..40e059e2a 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" }