diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a260fadc3..9adfec8ee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -216,6 +216,13 @@ android:label="EHentaiLogin" android:exported="false"/> + + + diff --git a/app/src/main/java/eu/kanade/domain/connections/service/ConnectionsPreferences.kt b/app/src/main/java/eu/kanade/domain/connections/service/ConnectionsPreferences.kt new file mode 100644 index 000000000..6cbd945d2 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/connections/service/ConnectionsPreferences.kt @@ -0,0 +1,67 @@ +// AM (CONNECTIONS) --> +package eu.kanade.domain.connections.service + +import eu.kanade.tachiyomi.data.connections.ConnectionsService +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore + +class ConnectionsPreferences( + private val preferenceStore: PreferenceStore, +) { + fun connectionsUsername(sync: ConnectionsService) = preferenceStore.getString( + connectionsUsername(sync.id), + "", + ) + + fun connectionsPassword(sync: ConnectionsService) = preferenceStore.getString( + connectionsPassword(sync.id), + "", + ) + + fun setConnectionsCredentials(sync: ConnectionsService, username: String, password: String) { + connectionsUsername(sync).set(username) + connectionsPassword(sync).set(password) + } + + fun connectionsToken(sync: ConnectionsService) = preferenceStore.getString( + Preference.privateKey(connectionsToken(sync.id)), + "", + ) + + fun enableDiscordRPC() = preferenceStore.getBoolean("pref_enable_discord_rpc", false) + + fun discordRPCStatus() = preferenceStore.getInt("pref_discord_rpc_status", 1) + + fun discordRPCIncognito() = preferenceStore.getBoolean("pref_discord_rpc_incognito", false) + + fun discordRPCIncognitoCategories() = preferenceStore.getStringSet( + "discord_rpc_incognito_categories", + emptySet(), + ) + + fun useChapterTitles() = preferenceStore.getBoolean("pref_discord_rpc_use_chapter_titles", false) + + fun discordCustomMessage() = preferenceStore.getString("pref_discord_custom_message", "") + + fun discordShowProgress() = preferenceStore.getBoolean("pref_discord_show_progress", true) + + fun discordShowTimestamp() = preferenceStore.getBoolean("pref_discord_show_timestamp", true) + + fun discordShowButtons() = preferenceStore.getBoolean("pref_discord_show_buttons", true) + + fun discordShowDownloadButton() = preferenceStore.getBoolean("pref_discord_show_download_button", true) + + fun discordShowDiscordButton() = preferenceStore.getBoolean("pref_discord_show_discord_button", true) + + fun discordAccounts() = preferenceStore.getString("discord_accounts", "") + + companion object { + + fun connectionsUsername(syncId: Long) = "pref_connections_username_$syncId" + + private fun connectionsPassword(syncId: Long) = "pref_connections_password_$syncId" + + private fun connectionsToken(syncId: Long) = "connection_token_$syncId" + } +} +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/presentation/connection/components/ConnectionLogoIcon.kt b/app/src/main/java/eu/kanade/presentation/connection/components/ConnectionLogoIcon.kt new file mode 100644 index 000000000..f41806912 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/connection/components/ConnectionLogoIcon.kt @@ -0,0 +1,45 @@ +// AM (CONNECTIONS) --> +package eu.kanade.presentation.connection.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.data.connections.ConnectionsService +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.clickableNoIndication + +@Composable +fun ConnectionLogoIcon( + service: ConnectionsService, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val modifier = if (onClick != null) { + modifier.clickableNoIndication(onClick = onClick) + } else { + modifier + } + + Box( + modifier = modifier + .size(48.dp) + .background(color = Color(service.getLogoColor()), shape = MaterialTheme.shapes.medium) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(service.getLogo()), + contentDescription = stringResource(service.nameStrRes()), + ) + } +} +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/Preference.kt b/app/src/main/java/eu/kanade/presentation/more/settings/Preference.kt index 13aa34b0b..95289ecb2 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/Preference.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/Preference.kt @@ -4,6 +4,7 @@ import androidx.annotation.IntRange import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector +import eu.kanade.tachiyomi.data.connections.ConnectionsService import eu.kanade.tachiyomi.data.track.Tracker import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -154,6 +155,23 @@ sealed class Preference { override val onValueChanged: suspend (value: String) -> Boolean = { true } } + // AM (CONNECTIONS) --> + /** + * A [PreferenceItem] for individual connections service. + */ + data class ConnectionPreference( + val service: ConnectionsService, + override val title: String, + val login: () -> Unit, + val openSettings: () -> Unit, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: ImageVector? = null + override val onValueChanged: suspend (newValue: String) -> Boolean = { true } + } + // <-- AM (CONNECTIONS) + data class InfoPreference( override val title: String, ) : PreferenceItem() { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt index 74515e98e..29e80c049 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/PreferenceItem.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import eu.kanade.presentation.more.settings.widget.ConnectionPreferenceWidget import eu.kanade.presentation.more.settings.widget.EditTextPreferenceWidget import eu.kanade.presentation.more.settings.widget.InfoWidget import eu.kanade.presentation.more.settings.widget.ListPreferenceWidget @@ -173,6 +174,17 @@ internal fun PreferenceItem( onClick = { if (isLoggedIn) item.logout() else item.login() }, ) } + // AM (CONNECTIONS) --> + is Preference.PreferenceItem.ConnectionPreference -> { + item.service.run { + ConnectionPreferenceWidget( + service = this, + checked = isLogged, + onClick = { if (isLogged) item.openSettings() else item.login() }, + ) + } + } + // <-- AM (CONNECTIONS) is Preference.PreferenceItem.InfoPreference -> { InfoWidget(text = item.title) } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsConnectionScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsConnectionScreen.kt new file mode 100644 index 000000000..85667c9c8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsConnectionScreen.kt @@ -0,0 +1,290 @@ +// AM (CONNECTIONS) --> +package eu.kanade.presentation.more.settings.screen + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import dev.icerock.moko.resources.StringResource +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.tachiyomi.data.connections.ConnectionsManager +import eu.kanade.tachiyomi.data.connections.ConnectionsService +import eu.kanade.tachiyomi.ui.setting.connections.DiscordLoginScreen +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.persistentListOf +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.withUIContext +import tachiyomi.i18n.MR +import tachiyomi.i18n.kmk.KMR +import tachiyomi.presentation.core.i18n.stringResource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +object SettingsConnectionScreen : SearchableSettings { + @Suppress("unused") + private fun readResolve(): Any = SettingsConnectionScreen + + @ReadOnlyComposable + @Composable + override fun getTitleRes() = KMR.strings.pref_category_connections + + @Composable + override fun getPreferences(): List { + val navigator = LocalNavigator.currentOrThrow + val connectionsManager = remember { Injekt.get() } + + var dialog by remember { mutableStateOf(null) } + dialog?.run { + when (this) { + is LoginConnectionDialog -> { + ConnectionsLoginDialog( + service = service, + uNameStringRes = uNameStringRes, + onDismissRequest = { dialog = null }, + ) + } + } + } + + return listOf( + Preference.PreferenceGroup( + title = stringResource(KMR.strings.special_services), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ConnectionPreference( + title = stringResource(connectionsManager.discord.nameStrRes()), + service = connectionsManager.discord, + login = { + navigator.push(DiscordLoginScreen()) + }, + openSettings = { navigator.push(SettingsDiscordScreen) }, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(KMR.strings.pref_discord_configuration), + enabled = connectionsManager.discord.isLogged, + onClick = { navigator.push(SettingsDiscordScreen) }, + ), + Preference.PreferenceItem.InfoPreference( + stringResource(KMR.strings.connections_discord_info, stringResource(MR.strings.app_name)), + ), + ), + ), + ) + } + + @Composable + @Suppress("LongMethod") + private fun ConnectionsLoginDialog( + service: ConnectionsService, + uNameStringRes: StringResource, + onDismissRequest: () -> Unit, + ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) } + var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) } + var processing by remember { mutableStateOf(false) } + var inputError by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource( + MR.strings.login_title, + service.nameStrRes(), + ), + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismissRequest) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(MR.strings.action_close), + ) + } + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = username, + onValueChange = { username = it }, + label = { Text(text = stringResource(uNameStringRes)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + singleLine = true, + isError = inputError && username.text.isEmpty(), + ) + + var hidePassword by remember { mutableStateOf(true) } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = password, + onValueChange = { password = it }, + label = { Text(text = stringResource(MR.strings.password)) }, + trailingIcon = { + IconButton(onClick = { hidePassword = !hidePassword }) { + Icon( + imageVector = if (hidePassword) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = null, + ) + } + }, + visualTransformation = if (hidePassword) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + singleLine = true, + isError = inputError && password.text.isEmpty(), + ) + } + }, + confirmButton = { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = !processing, + onClick = { + if (username.text.isEmpty() || password.text.isEmpty()) { + inputError = true + return@Button + } + scope.launchIO { + inputError = false + processing = true + val result = checkLogin( + context = context, + service = service, + username = username.text, + password = password.text, + ) + if (result) onDismissRequest() + processing = false + } + }, + ) { + val strRes = if (processing) MR.strings.loading else MR.strings.login + Text(text = stringResource(strRes)) + } + }, + ) + } + + @Suppress("SwallowedException", "TooGenericExceptionCaught") + private suspend fun checkLogin( + context: Context, + service: ConnectionsService, + username: String, + password: String, + ): Boolean { + return try { + service.login(username, password) + withUIContext { context.toast(MR.strings.login_success) } + true + } catch (e: Throwable) { + service.logout() + withUIContext { context.toast(e.message.toString()) } + false + } + } +} + +@Composable +internal fun ConnectionsLogoutDialog( + // KMK --> + serviceName: String, + onConfirmation: () -> Unit, + // KMK <-- + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text( + text = stringResource(MR.strings.logout_title, serviceName), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onDismissRequest, + ) { + Text(text = stringResource(MR.strings.action_cancel)) + } + Button( + modifier = Modifier.weight(1f), + onClick = { + // KMK --> + onConfirmation() + // KMK <-- + onDismissRequest() + context.toast(MR.strings.logout_success) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(text = stringResource(MR.strings.logout)) + } + } + }, + ) +} + +private data class LoginConnectionDialog( + val service: ConnectionsService, + val uNameStringRes: StringResource, +) + +internal data class LogoutConnectionDialog( + val service: ConnectionsService, +) +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordAccountsScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordAccountsScreen.kt new file mode 100644 index 000000000..853bb29b3 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordAccountsScreen.kt @@ -0,0 +1,277 @@ +package eu.kanade.presentation.more.settings.screen + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import coil3.compose.AsyncImage +import eu.kanade.domain.connections.service.ConnectionsPreferences +import eu.kanade.presentation.components.AppBar +import eu.kanade.tachiyomi.data.connections.ConnectionsManager +import eu.kanade.tachiyomi.data.connections.discord.DiscordAccount +import eu.kanade.tachiyomi.ui.setting.connections.DiscordLoginScreen +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import logcat.logcat +import tachiyomi.i18n.MR +import tachiyomi.i18n.kmk.KMR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +object DiscordAccountsScreen : Screen { + @Suppress("unused") + private fun readResolve(): Any = DiscordAccountsScreen + + @Composable + override fun Content() { + DiscordAccountsScreenContent() + } +} + +data class DiscordAccountsScreenState( + val accounts: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DiscordAccountsScreenContent() { + val navigator = LocalNavigator.currentOrThrow + val screenModel = remember { DiscordAccountsScreenModel() } + val state by screenModel.state.collectAsState() + + val noAccountsFoundString = stringResource(KMR.strings.no_accounts_found) + + Scaffold( + topBar = { + AppBar( + title = stringResource(KMR.strings.discord_accounts), + navigateUp = navigator::pop, + actions = { + IconButton( + onClick = { + navigator.push(DiscordLoginScreen()) + }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(MR.strings.action_add), + ) + } + }, + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + val (isLoading, error, accounts) = state.let { Triple(it.isLoading, it.error, it.accounts) } + val context = LocalContext.current + + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + error != null -> { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(accounts) { account -> + DiscordAccountItem( + account = account, + onRemove = { screenModel.removeAccount(account.id) }, + onSetActive = { screenModel.setActiveAccount(account.id, context) }, + ) + } + } + } + } + } + } + + LaunchedEffect(Unit) { + screenModel.setNoAccountsFoundString(noAccountsFoundString) + screenModel.refreshAccounts() + } +} + +class DiscordAccountsScreenModel : StateScreenModel(DiscordAccountsScreenState()) { + private val discord = Injekt.get().discord + private val connectionsPreferences = Injekt.get() + private var noAccountsFoundString: String = "" + + init { + screenModelScope.launch { + connectionsPreferences.discordAccounts().changes() + .collect { loadAccounts() } + } + loadAccounts() + } + + fun setNoAccountsFoundString(value: String) { + noAccountsFoundString = value + } + + private fun loadAccounts() { + screenModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + runCatching { + val accounts = discord.getAccounts() + logcat(logcat.LogPriority.DEBUG) { "Debug: Loaded accounts: $accounts" } // Debug log + if (accounts.isEmpty()) { + mutableState.update { + it.copy( + accounts = emptyList(), + isLoading = false, + error = noAccountsFoundString, + ) + } + } else { + mutableState.update { + it.copy( + accounts = accounts, + isLoading = false, + ) + } + } + }.onFailure { e -> + mutableState.update { + it.copy( + isLoading = false, + error = e.message ?: "Unknown error", + ) + } + } + } + } + + fun removeAccount(accountId: String) { + screenModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + runCatching { + discord.removeAccount(accountId) + loadAccounts() + }.onFailure { e -> + mutableState.update { it.copy(isLoading = false, error = e.message ?: "Unknown error") } + } + } + } + + fun setActiveAccount(accountId: String, context: Context) { + screenModelScope.launch { + mutableState.update { it.copy(isLoading = true, error = null) } + runCatching { + discord.setActiveAccount(accountId) + discord.restartRichPresence(context) + loadAccounts() + }.onFailure { e -> + mutableState.update { it.copy(isLoading = false, error = e.message ?: "Unknown error") } + } + } + } + + fun refreshAccounts() { + loadAccounts() + } +} + +@Composable +private fun DiscordAccountItem( + account: DiscordAccount, + onRemove: () -> Unit, + onSetActive: () -> Unit, +) { + Card( + onClick = onSetActive, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = account.avatarUrl, + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + ) { + Text( + text = account.username, + style = MaterialTheme.typography.titleMedium, + ) + if (account.isActive) { + Text( + text = stringResource(KMR.strings.active_account), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(MR.strings.action_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordScreen.kt new file mode 100644 index 000000000..b5c6ef786 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDiscordScreen.kt @@ -0,0 +1,268 @@ +// AM (DISCORD) --> +package eu.kanade.presentation.more.settings.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.connections.service.ConnectionsPreferences +import eu.kanade.presentation.category.visualName +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.tachiyomi.data.connections.ConnectionsManager +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.i18n.MR +import tachiyomi.i18n.kmk.KMR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +object SettingsDiscordScreen : SearchableSettings { + @Suppress("unused") + private fun readResolve(): Any = SettingsDiscordScreen + + @ReadOnlyComposable + @Composable + override fun getTitleRes() = KMR.strings.pref_category_connections + + @Composable + override fun RowScope.AppBarAction() { + val uriHandler = LocalUriHandler.current + IconButton(onClick = { uriHandler.openUri("https://tachiyomi.org/help/guides/tracking/") }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(MR.strings.tracking_guide), + ) + } + } + + @Composable + override fun getPreferences(): List { + val navigator = LocalNavigator.currentOrThrow + val connectionsPreferences = remember { Injekt.get() } + val connectionsManager = remember { Injekt.get() } + val enableDRPCPref = connectionsPreferences.enableDiscordRPC() + val useChapterTitlesPref = connectionsPreferences.useChapterTitles() + val discordRPCStatus = connectionsPreferences.discordRPCStatus() + val customMessagePref = connectionsPreferences.discordCustomMessage() + val showProgressPref = connectionsPreferences.discordShowProgress() + val showTimestampPref = connectionsPreferences.discordShowTimestamp() + val showButtonsPref = connectionsPreferences.discordShowButtons() + val showDownloadButtonPref = connectionsPreferences.discordShowDownloadButton() + val showDiscordButtonPref = connectionsPreferences.discordShowDiscordButton() + val showProgressEnabled by showProgressPref.collectAsState() + + val enableDRPC by enableDRPCPref.collectAsState() + val showButtons by showButtonsPref.collectAsState() + + var dialog by remember { mutableStateOf(null) } + dialog?.run { + when (this) { + is LogoutConnectionDialog -> { + ConnectionsLogoutDialog( + // KMK --> + serviceName = stringResource(service.nameStrRes()), + onConfirmation = { + enableDRPCPref.set(false) + service.logout() + navigator.pop() + }, + // KMK <-- + onDismissRequest = { + dialog = null + }, + ) + } + } + } + + var showCustomMessageDialog by rememberSaveable { mutableStateOf(false) } + var tempCustomMessage by rememberSaveable { mutableStateOf(customMessagePref.get()) } + + if (showCustomMessageDialog) { + AlertDialog( + onDismissRequest = { + showCustomMessageDialog = false + tempCustomMessage = customMessagePref.get() + }, + title = { Text(stringResource(KMR.strings.pref_discord_custom_message)) }, + text = { + Column { + OutlinedTextField( + value = tempCustomMessage, + onValueChange = { tempCustomMessage = it }, + label = { Text(stringResource(KMR.strings.pref_discord_custom_message_summary)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + TextButton( + onClick = { + customMessagePref.delete() + tempCustomMessage = "" + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(MR.strings.action_reset)) + } + } + }, + confirmButton = { + TextButton( + onClick = { + customMessagePref.set(tempCustomMessage) + showCustomMessageDialog = false + }, + ) { + Text(stringResource(MR.strings.action_ok)) + } + }, + dismissButton = { + TextButton( + onClick = { + showCustomMessageDialog = false + tempCustomMessage = customMessagePref.get() + }, + ) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + ) + } + + return listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(KMR.strings.discord_accounts), + onClick = { navigator.push(DiscordAccountsScreen) }, + ), + Preference.PreferenceGroup( + title = stringResource(KMR.strings.connections_discord), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = enableDRPCPref, + title = stringResource(KMR.strings.pref_enable_discord_rpc), + ), + Preference.PreferenceItem.ListPreference( + preference = discordRPCStatus, + title = stringResource(KMR.strings.pref_discord_status), + entries = persistentMapOf( + -1 to stringResource(KMR.strings.pref_discord_dnd), + 0 to stringResource(KMR.strings.pref_discord_idle), + 1 to stringResource(KMR.strings.pref_discord_online), + ), + enabled = enableDRPC, + ), + ), + ), + getRPCIncognitoGroup( + connectionsPreferences = connectionsPreferences, + enabled = enableDRPC, + ), + Preference.PreferenceGroup( + title = stringResource(KMR.strings.pref_category_discord_customization), + enabled = enableDRPC, + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(KMR.strings.pref_discord_custom_message), + subtitle = stringResource(KMR.strings.pref_discord_custom_message_summary), + onClick = { showCustomMessageDialog = true }, + ), + Preference.PreferenceItem.SwitchPreference( + preference = showProgressPref, + title = stringResource(KMR.strings.pref_discord_show_progress), + subtitle = stringResource(KMR.strings.pref_discord_show_progress_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = useChapterTitlesPref, + title = stringResource(KMR.strings.show_chapters_titles_title), + subtitle = stringResource(KMR.strings.show_chapters_titles_subtitle), + enabled = showProgressEnabled, + ), + Preference.PreferenceItem.SwitchPreference( + preference = showTimestampPref, + title = stringResource(KMR.strings.pref_discord_show_timestamp), + subtitle = stringResource(KMR.strings.pref_discord_show_timestamp_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = showButtonsPref, + title = stringResource(KMR.strings.pref_discord_show_buttons), + subtitle = stringResource(KMR.strings.pref_discord_show_buttons_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = showDownloadButtonPref, + title = stringResource(KMR.strings.pref_discord_show_download_button), + subtitle = stringResource(KMR.strings.pref_discord_show_download_button_summary), + enabled = showButtons, + ), + Preference.PreferenceItem.SwitchPreference( + preference = showDiscordButtonPref, + title = stringResource(KMR.strings.pref_discord_show_discord_button), + subtitle = stringResource(KMR.strings.pref_discord_show_discord_button_summary), + enabled = showButtons, + ), + ), + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.logout), + onClick = { dialog = LogoutConnectionDialog(connectionsManager.discord) }, + ), + ) + } + + @Composable + private fun getRPCIncognitoGroup( + connectionsPreferences: ConnectionsPreferences, + enabled: Boolean, + ): Preference.PreferenceGroup { + val getCategories = remember { Injekt.get() } + val allCategories by getCategories.subscribe().collectAsState(initial = emptyList()) + + val discordRPCIncognitoPref = connectionsPreferences.discordRPCIncognito() + val discordRPCIncognitoCategoriesPref = connectionsPreferences.discordRPCIncognitoCategories() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.categories), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = discordRPCIncognitoPref, + title = stringResource(KMR.strings.pref_discord_incognito), + subtitle = stringResource(KMR.strings.pref_discord_incognito_summary), + ), + Preference.PreferenceItem.MultiSelectListPreference( + preference = discordRPCIncognitoCategoriesPref, + entries = allCategories + .associate { it.id.toString() to it.visualName } + .toImmutableMap(), + title = stringResource(MR.strings.categories), + ), + Preference.PreferenceItem.InfoPreference( + stringResource(KMR.strings.pref_discord_incognito_categories_details), + ), + ), + enabled = enabled, + ) + } +} +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt index 34c24c8e1..81171fb4a 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.GetApp import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security @@ -49,12 +50,14 @@ import exh.assets.ehassets.EhLogo import exh.assets.ehassets.MangadexLogo import kotlinx.collections.immutable.persistentListOf import tachiyomi.i18n.MR +import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import cafe.adriel.voyager.core.screen.Screen as VoyagerScreen object SettingsMainScreen : Screen() { + @Suppress("unused") private fun readResolve(): Any = SettingsMainScreen @Composable @@ -210,6 +213,14 @@ object SettingsMainScreen : Screen() { icon = Icons.Outlined.Sync, screen = SettingsTrackingScreen, ), + // AM (CONNECTIONS) --> + Item( + titleRes = KMR.strings.pref_category_connections, + subtitleRes = KMR.strings.pref_connections_summary, + icon = Icons.Outlined.Link, + screen = SettingsConnectionScreen, + ), + // <-- AM (CONNECTIONS) Item( titleRes = MR.strings.browse, subtitleRes = MR.strings.pref_browse_summary, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt index e72ee75dc..03e80d00c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -291,6 +291,9 @@ private val settingScreens = listOf( SettingsReaderScreen, SettingsDownloadScreen, SettingsTrackingScreen, + // AM (CONNECTIONS) --> + SettingsConnectionScreen, + // <-- AM (CONNECTIONS) SettingsBrowseScreen, SettingsDataScreen, SettingsSecurityScreen, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/widget/ConnectionPreferenceWidget.kt b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ConnectionPreferenceWidget.kt new file mode 100644 index 000000000..6fe3333d6 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/widget/ConnectionPreferenceWidget.kt @@ -0,0 +1,66 @@ +// AM (CONNECTIONS) --> +package eu.kanade.presentation.more.settings.widget + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.connection.components.ConnectionLogoIcon +import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted +import eu.kanade.tachiyomi.data.connections.ConnectionsService +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +@Suppress("ModifierNotUsedAtRoot", "MagicNumber") +fun ConnectionPreferenceWidget( + service: ConnectionsService, + checked: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val highlighted = LocalPreferenceHighlighted.current + Box(modifier = Modifier.highlightBackground(highlighted)) { + Row( + modifier = modifier + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .fillMaxWidth() + .padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConnectionLogoIcon(service) + Text( + text = stringResource(service.nameStrRes()), + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + fontSize = TitleFontSize, + ) + if (checked) { + Icon( + imageVector = Icons.Outlined.Done, + modifier = Modifier + .padding(4.dp) + .size(32.dp), + tint = Color(0xFF4CAF50), + contentDescription = stringResource(MR.strings.login_success), + ) + } + } + } +} +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index e59a8156a..2eb059ee0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -50,6 +50,7 @@ import eu.kanade.tachiyomi.data.coil.MangaKeyer import eu.kanade.tachiyomi.data.coil.PagePreviewFetcher import eu.kanade.tachiyomi.data.coil.PagePreviewKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.sync.SyncDataJob import eu.kanade.tachiyomi.di.AppModule @@ -284,10 +285,18 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor if (syncPreferences.isSyncEnabled() && syncTriggerOpt.syncOnAppResume) { SyncDataJob.startNow(this@App) } + + // AM (DISCORD) --> + DiscordRPCService.start(applicationContext) + // <-- AM (DISCORD) } override fun onStop(owner: LifecycleOwner) { SecureActivityDelegate.onApplicationStopped() + + // AM (DISCORD) --> + DiscordRPCService.stop(applicationContext) + // <-- AM (DISCORD) } override fun getPackageName(): String { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsManager.kt new file mode 100644 index 000000000..e16128502 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsManager.kt @@ -0,0 +1,18 @@ +// AM (CONNECTIONS) --> +package eu.kanade.tachiyomi.data.connections + +import eu.kanade.tachiyomi.data.connections.discord.Discord + +class ConnectionsManager { + + companion object { + const val DISCORD = 201L + } + + val discord = Discord(DISCORD) + + private val services = listOf(discord) + + fun getService(id: Long) = services.find { it.id == id } +} +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsService.kt new file mode 100644 index 000000000..c1ea32c6a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/ConnectionsService.kt @@ -0,0 +1,61 @@ +// AM (CONNECTIONS) --> +package eu.kanade.tachiyomi.data.connections + +import androidx.annotation.CallSuper +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.connections.service.ConnectionsPreferences +import eu.kanade.tachiyomi.network.NetworkHelper +import okhttp3.OkHttpClient +import uy.kohesive.injekt.injectLazy + +abstract class ConnectionsService(val id: Long) { + + val connectionsPreferences: ConnectionsPreferences by injectLazy() + private val networkService: NetworkHelper by injectLazy() + + open val client: OkHttpClient + get() = networkService.client + + // Name of the connection service to display + abstract fun nameStrRes(): StringResource + + @DrawableRes + abstract fun getLogo(): Int + + @ColorInt + abstract fun getLogoColor(): Int + + @CallSuper + open fun logout() { + connectionsPreferences.setConnectionsCredentials(this, "", "") + connectionsPreferences.connectionsToken(this).set("") + } + + abstract suspend fun login(username: String, password: String) + + fun getUsername() = connectionsPreferences.connectionsUsername(this).get() + + fun getPassword() = connectionsPreferences.connectionsPassword(this).get() + + fun saveCredentials(username: String, password: String) { + connectionsPreferences.setConnectionsCredentials(this, username, password) + } + + /** + * Returns true if the service is considered logged in. + * Default implementation checks for non-empty username and password. + * For token-based services, override this property to check for token presence. + */ + open val isLogged: Boolean + get() = getUsername().isNotEmpty() && + getPassword().isNotEmpty() + + /** + * Override this method in token-based services to retrieve the token. + * Default implementation returns an empty string. + */ + protected open fun getToken(): String = "" +} +// <-- AM (CONNECTIONS) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/Discord.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/Discord.kt new file mode 100644 index 000000000..0170ad276 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/Discord.kt @@ -0,0 +1,106 @@ +// AM (DISCORD) --> + +// Taken from Animiru. Thank you Quickdev for permission! + +package eu.kanade.tachiyomi.data.connections.discord + +import android.graphics.Color +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.ConnectionsService +import exh.log.xLogE +import kotlinx.serialization.json.Json +import tachiyomi.i18n.kmk.KMR +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class Discord(id: Long) : ConnectionsService(id) { + + override fun nameStrRes() = KMR.strings.connections_discord + + override fun getLogo() = R.drawable.ic_discord_24dp + + @Suppress("MagicNumber") + override fun getLogoColor() = Color.rgb(88, 101, 242) + + override fun logout() { + super.logout() + connectionsPreferences.connectionsToken(this).delete() + } + + override suspend fun login(username: String, password: String) { + // Not Needed + } + + override val isLogged: Boolean + get() = getToken().isNotBlank() + + override fun getToken(): String { + return connectionsPreferences.connectionsToken(this).get() + } + + private val json = Injekt.get() + + fun getAccounts(): List { + val accountsJson = connectionsPreferences.discordAccounts().get() + return try { + if (accountsJson.isNotBlank()) { + json.decodeFromString>(accountsJson) + } else { + emptyList() + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse Discord accounts") + emptyList() + } + } + + fun addAccount(account: DiscordAccount) { + val accounts = getAccounts().toMutableList() + + if (account.isActive) { + accounts.replaceAll { it.copy(isActive = false) } + connectionsPreferences.connectionsToken(this).set(account.token) + } + + val index = accounts.indexOfFirst { it.id == account.id } + if (index >= 0) { + accounts[index] = account + } else { + accounts.add(account) + } + + saveAccounts(accounts) + } + + fun removeAccount(accountId: String) { + val accounts = getAccounts().toMutableList() + accounts.removeAll { it.id == accountId } + saveAccounts(accounts) + } + + fun setActiveAccount(accountId: String) { + val accounts = getAccounts().toMutableList() + accounts.replaceAll { it.copy(isActive = it.id == accountId) } + saveAccounts(accounts) + // Update active token (should restart RPC later) + accounts.find { it.id == accountId }?.let { account -> + connectionsPreferences.connectionsToken(this).set(account.token) + } + } + + fun restartRichPresence(context: android.content.Context) { + // Direct restart via service intent + DiscordRPCService.restart(context) + } + + private fun saveAccounts(accounts: List) { + try { + val accountsJson = json.encodeToString(accounts) + connectionsPreferences.discordAccounts().set(accountsJson) + } catch (e: Exception) { + xLogE("Failed to save Discord accounts", e) + } + } +} +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordAccount.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordAccount.kt new file mode 100644 index 000000000..f4d011806 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordAccount.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.data.connections.discord + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiscordAccount( + val id: String, + val username: String, + @SerialName("avatar") + val avatarId: String? = "", + val token: String = "", + val isActive: Boolean = false, +) { + val avatarUrl: String? + get() = "https://cdn.discordapp.com/avatars/$id/$avatarId.png?size=512" + .takeIf { !avatarId.isNullOrBlank() } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPC.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPC.kt new file mode 100644 index 000000000..22b874557 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPC.kt @@ -0,0 +1,41 @@ +// AM (DISCORD) --> +// Original library from https://github.com/dead8309/KizzyRPC (Thank you) +// Thank you to the 最高 man for the refactored and simplified code +// https://github.com/saikou-app/saikou +package eu.kanade.tachiyomi.data.connections.discord + +/** + * DiscordRPC is a class that implements Discord Rich Presence functionality using WebSockets. + * @param token discord account token + */ +class DiscordRPC(val token: String, val status: String) { + private val discordWebSocket: DiscordWebSocket = DiscordWebSocketImpl(token) + private var rpc: Presence? = null + + /** + * Closes the Rich Presence connection. + */ + fun closeRPC() { + discordWebSocket.close() + } + + /** + * Sets the activity for the Rich Presence. + * @param activity the activity to set. + * @param since the activity start time. + */ + suspend fun updateRPC( + activity: Activity, + since: Long? = null, + ) { + rpc = Presence( + activities = listOf(activity), + afk = status != "online", // Must set to true for both dnd & idle to work + since = since, + status = status, + ) + + rpc?.let { discordWebSocket.sendActivity(it) } + } +} +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCModels.kt new file mode 100644 index 000000000..d046a2e37 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCModels.kt @@ -0,0 +1,211 @@ +// AM (DISCORD) --> + +// Taken from Animiru. Thank you Quickdev for permission! +// Original library from https://github.com/dead8309/KizzyRPC (Thank you) +// Thank you to the 最高 man for the refactored and simplified code +// https://github.com/saikou-app/saikou +package eu.kanade.tachiyomi.data.connections.discord + +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.isPreviewBuildType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// Constant for logging tag +const val RICH_PRESENCE_TAG = "discord_rpc" + +// Constant for application id +internal const val RICH_PRESENCE_APPLICATION_ID = "1365208874440986685" + +val DOWNLOAD_BUTTON_LABEL_RES = R.string.discord_download_button +const val DOWNLOAD_BUTTON_URL = "https://komikku-app.github.io/download/" +const val DISCORD_BUTTON_LABEL = "Discord" +const val DISCORD_BUTTON_URL = "https://discord.gg/85jB7V5AJR" + +@Serializable +data class Activity( + @SerialName("application_id") + val applicationId: String? = RICH_PRESENCE_APPLICATION_ID, + val name: String? = null, + val details: String? = null, + val state: String? = null, + val type: Int? = null, + val timestamps: Timestamps? = null, + val assets: Assets? = null, + val buttons: List? = null, + val metadata: Metadata? = null, +) { + @Serializable + data class Assets( + @SerialName("large_image") + val largeImage: String? = null, + @SerialName("large_text") + val largeText: String? = null, + @SerialName("small_image") + val smallImage: String? = null, + @SerialName("small_text") + val smallText: String? = null, + ) + + @Serializable + data class Metadata( + @SerialName("button_urls") + val buttonUrls: List, + ) + + @Serializable + data class Timestamps( + val start: Long? = null, + val end: Long? = null, + val stop: Long? = null, + ) +} + +@Serializable +data class Presence( + val status: String? = null, + val afk: Boolean = true, + val activities: List = listOf(), + val since: Long? = null, +) { + @Serializable + data class Response( + val op: Long, + val d: Presence, + ) +} + +@Serializable +data class Identity( + val token: String, + val properties: Properties, + val compress: Boolean, + val intents: Long, +) { + + @Serializable + data class Response( + val op: Long, + val d: Identity, + ) + + @Serializable + data class Properties( + @SerialName("\$os") + val os: String, + + @SerialName("\$browser") + val browser: String, + + @SerialName("\$device") + val device: String, + ) +} + +@Serializable +data class Res( + val t: String?, + val s: Int?, + val op: Int, + val d: JsonElement, +) + +enum class ActivityType(val value: Int) { + /** Playing a game. */ + PLAYING(0), + + /** Streaming a game. */ + STREAMING(1), + + /** Listening to music. */ + LISTENING(2), + + /** Watching a video. */ + WATCHING(3), + + /** Competing in a game. */ + COMPETING(5), + + /** Custom activity type, not defined by Discord. */ + CUSTOM(4), +} + +@Suppress("MagicNumber") +enum class OpCode(val value: Int) { + /** An event was dispatched. */ + DISPATCH(0), + + /** Fired periodically by the client to keep the connection alive. */ + HEARTBEAT(1), + + /** Starts a new session during the initial handshake. */ + IDENTIFY(2), + + /** Update the client's presence. */ + PRESENCE_UPDATE(3), + + /** Joins/leaves or moves between voice channels. */ + VOICE_STATE(4), + + /** Resume a previous session that was disconnected. */ + RESUME(6), + + /** You should attempt to reconnect and resume immediately. */ + RECONNECT(7), + + /** Request information about offline guild members in a large guild. */ + REQUEST_GUILD_MEMBERS(8), + + /** The session has been invalidated. You should reconnect and identify/resume accordingly */ + INVALID_SESSION(9), + + /** Sent immediately after connecting, contains the heartbeat_interval to use. */ + HELLO(10), + + /** Sent in response to receiving a heartbeat to acknowledge that it has been received. */ + HEARTBEAT_ACK(11), + + /** For future use or unknown opcodes. */ + UNKNOWN(-1), +} + +data class ReaderData( + val incognitoMode: Boolean = false, + val mangaId: Long? = null, + val mangaTitle: String? = null, + val chapterNumber: String? = null, + val thumbnailUrl: String? = null, + val startTimestamp: Long? = null, +) + +// Enum class for standard Rich Presence in-app screens +enum class DiscordScreen( + @StringRes val text: Int, + @StringRes val details: Int, + val imageUrl: String, +) { + APP(R.string.app_name, R.string.discord_status_using, KOMIKKU_IMAGE), + LIBRARY(R.string.label_library, R.string.discord_status_browsing, LIBRARY_IMAGE_URL), + UPDATES(R.string.label_recent_updates, R.string.discord_status_scrolling, UPDATES_IMAGE_URL), + HISTORY(R.string.label_recent_manga, R.string.discord_status_scrolling, HISTORY_IMAGE_URL), + BROWSE(R.string.label_sources, R.string.discord_status_browsing, BROWSE_IMAGE_URL), + MORE(R.string.label_settings, R.string.discord_status_messing, MORE_IMAGE_URL), + WEBVIEW(R.string.action_web_view, R.string.discord_status_browsing, WEBVIEW_IMAGE_URL), + MANGA(R.string.manga, R.string.reading, MANGA_IMAGE_URL), +} + +// Constants for standard Rich Presence image urls +private const val KOMIKKU_IMAGE_URL = "emojis/1401719615536500916.webp?quality=lossless" +private const val KOMIKKU_PREVIEW_IMAGE_URL = "emojis/1401732831314575401.webp?quality=lossless" + +private val KOMIKKU_IMAGE = if (isPreviewBuildType) KOMIKKU_PREVIEW_IMAGE_URL else KOMIKKU_IMAGE_URL +private const val LIBRARY_IMAGE_URL = "emojis/1365262809050644591.webp?quality=lossless" +private const val UPDATES_IMAGE_URL = "emojis/1365261957883625492.webp?quality=lossless" +private const val HISTORY_IMAGE_URL = "emojis/1365262076787949598.webp?quality=lossless" +private const val BROWSE_IMAGE_URL = "emojis/1365263374992146576.webp?quality=lossless" +private const val MORE_IMAGE_URL = "emojis/1365261438276599849.webp?quality=lossless" +private const val WEBVIEW_IMAGE_URL = "emojis/1365262268811579443.webp?quality=lossless" +private const val MANGA_IMAGE_URL = "emojis/1365263962622529576.webp?quality=lossless" +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCService.kt new file mode 100644 index 000000000..5f7d9eb49 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordRPCService.kt @@ -0,0 +1,487 @@ +// AM (DISCORD) --> + +// Taken from Animiru. Thank you Quickdev for permission! +// Much improved by Cuong-Tran + +package eu.kanade.tachiyomi.data.connections.discord + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import androidx.compose.ui.util.fastAny +import androidx.core.content.ContextCompat +import eu.kanade.domain.connections.service.ConnectionsPreferences +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.ConnectionsManager +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.util.system.notificationBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.serialization.json.Json +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.domain.category.interactor.GetCategories +import tachiyomi.domain.category.model.Category.Companion.UNCATEGORIZED_ID +import tachiyomi.i18n.MR +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import kotlin.math.ceil +import kotlin.math.floor + +class DiscordRPCService : Service() { + + private val connectionsManager: ConnectionsManager by injectLazy() + + override fun onCreate() { + super.onCreate() + Timber.tag(TAG).i("Starting Discord RPC service") + + val token = connectionsPreferences.connectionsToken(connectionsManager.discord).get() + + // KMK --> + // Create RPC client only if token is valid + if (token.isBlank()) { + Timber.tag(TAG).w("Discord RPC disabled due to missing token") + connectionsPreferences.enableDiscordRPC().set(false) + stopSelf() + return + } + // KMK <-- + + val status = when (connectionsPreferences.discordRPCStatus().get()) { + -1 -> "dnd" + 0 -> "idle" + else -> "online" + } + + try { + rpc = DiscordRPC(token, status) + + try { + // KMK --> + discordScope.launchIO { setScreen(this@DiscordRPCService) } + // KMK <-- + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error setting initial screen: ${e.message}") + // KMK --> + stopSelf() + // KMK <-- + } + + notification(this) + // KMK --> + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to initialize Discord RPC: ${e.message}") + connectionsPreferences.enableDiscordRPC().set(false) + stopSelf() + } + // KMK <-- + } + + override fun onDestroy() { + NotificationReceiver.dismissNotification(this, Notifications.ID_DISCORD_RPC) + rpc?.run { + closeRPC() + rpc = null + } + super.onDestroy() + } + + override fun onBind(intent: Intent): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_RESTART -> restartRPC() + STOP_SERVICE -> { + Timber.tag(TAG).i("Stopping Discord RPC service") + stopSelf() + return START_NOT_STICKY + } + } + return START_STICKY + } + + private fun restartRPC() { + try { + Timber.tag(TAG).i("Restarting Discord RPC service") + // Close existing RPC connection + rpc?.closeRPC() + rpc = null + + // Get fresh token and status + val token = connectionsPreferences.connectionsToken(connectionsManager.discord).get() + if (token.isBlank()) { + Timber.tag(TAG).w("Discord RPC restart failed due to missing token") + stopSelf() + return + } + + val status = when (connectionsPreferences.discordRPCStatus().get()) { + -1 -> "dnd" + 0 -> "idle" + else -> "online" + } + + // Reinitialize RPC + rpc = DiscordRPC(token, status) + discordScope.launchIO { + setScreen(this@DiscordRPCService) + } + Timber.tag(TAG).i("Discord RPC restarted successfully") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to restart Discord RPC: ${e.message}") + stopSelf() + } + } + + private fun notification(context: Context) { + // KMK --> + val stopIntent = NotificationReceiver.stopDiscordRPCService(context) + // KMK <-- + + val builder = context.notificationBuilder(Notifications.CHANNEL_DISCORD_RPC) { + setSmallIcon(R.drawable.ic_discord_24dp) + setColor(ContextCompat.getColor(context, R.color.ic_launcher)) + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.komikku)) + setContentText(context.getString(R.string.pref_discord_rpc)) + // KMK --> + setContentTitle(context.getString(R.string.app_name)) + addAction(R.drawable.ic_close_24dp, context.getString(R.string.action_stop), stopIntent) + // KMK <-- + setAutoCancel(false) + setOngoing(true) + setUsesChronometer(true) + } + + startForeground(Notifications.ID_DISCORD_RPC, builder.build()) + } + + companion object { + + private val connectionsPreferences: ConnectionsPreferences by injectLazy() + + private var rpc: DiscordRPC? = null + private val handler = Handler(Looper.getMainLooper()) + private val job = SupervisorJob() + internal val discordScope = CoroutineScope(Dispatchers.IO + job) + + private const val ACTION_RESTART = "eu.kanade.tachiyomi.DISCORD_RPC_RESTART" + private const val STOP_SERVICE = "eu.kanade.tachiyomi.DISCORD_RPC_STOP" + + fun start(context: Context) { + handler.removeCallbacksAndMessages(null) + if (rpc == null && connectionsPreferences.enableDiscordRPC().get()) { + since = System.currentTimeMillis() + context.startForegroundService(Intent(context, DiscordRPCService::class.java)) + } + } + + fun stop(context: Context, delay: Long = 30000L) { + handler.removeCallbacksAndMessages(null) + if (delay > 0) { + handler.postDelayed({ + val stopIntent = Intent(context, DiscordRPCService::class.java).apply { + action = STOP_SERVICE + } + try { + context.startService(stopIntent) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to stop Discord RPC service: ${e.message}") + } + }, delay) + } else { + val stopIntent = Intent(context, DiscordRPCService::class.java).apply { + action = STOP_SERVICE + } + try { + context.startService(stopIntent) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to stop Discord RPC service: ${e.message}") + } + } + } + + fun restart(context: Context) { + if (connectionsPreferences.enableDiscordRPC().get()) { + val restartIntent = Intent(context, DiscordRPCService::class.java).apply { + action = ACTION_RESTART + } + try { + context.startForegroundService(restartIntent) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to send restart intent: ${e.message}") + // Fallback to stop/start if service isn't running + stop(context, 0L) + handler.postDelayed({ start(context) }, 1000L) + } + } + } + + private var since = 0L + + private var lastUsedScreen = DiscordScreen.APP + set(value) { + // Only update if the new screen is not a media/webview screen + if (value !in listOf(DiscordScreen.MANGA, DiscordScreen.WEBVIEW)) { + field = value + } + } + + private const val MP_PREFIX = "mp:" + private const val EXTERNAL_PREFIX = "external/" + private val json = Json { + encodeDefaults = true + allowStructuredMapKeys = true + ignoreUnknownKeys = true + } + + private const val TAG = "DiscordRPCService" + + internal suspend fun setScreen( + context: Context, + discordScreen: DiscordScreen = lastUsedScreen, + readerData: ReaderData = ReaderData(), + sinceTime: Long = since, + ) { + rpc ?: return + handler.removeCallbacksAndMessages(null) + + lastUsedScreen = discordScreen + + // KMK --> + val showProgress = connectionsPreferences.discordShowProgress().get() + val showTimestamp = connectionsPreferences.discordShowTimestamp().get() + + val (title, state, imageUrl) = when (discordScreen) { + DiscordScreen.MANGA -> Triple( + readerData.mangaTitle, + readerData.chapterNumber.takeIf { showProgress }, + readerData.thumbnailUrl ?: discordScreen.imageUrl, + ) + else -> Triple( + null, + context.getString(discordScreen.text), + discordScreen.imageUrl, + ) + } + + val timestamps = if (showTimestamp) { + when (discordScreen) { + DiscordScreen.MANGA -> Activity.Timestamps( + start = readerData.startTimestamp ?: since, + ) + else -> Activity.Timestamps(start = sinceTime) + } + } else { + null + } + // KMK <-- + + updateDiscordRPC( + context = context, + discordScreen = discordScreen, + // KMK --> + title = title, + state = state, + imageUrl = imageUrl, + timestamps = timestamps, + // KMK <-- + ) + } + + private suspend fun updateDiscordRPC( + context: Context, + discordScreen: DiscordScreen, + // KMK --> + title: String? = null, + state: String?, + imageUrl: String, + timestamps: Activity.Timestamps?, + sinceTime: Long = since, + appName: String = context.getString(R.string.app_name), + // KMK <-- + ) { + val customMessage = connectionsPreferences.discordCustomMessage().get() + val showButtons = connectionsPreferences.discordShowButtons().get() + val showDownloadButton = connectionsPreferences.discordShowDownloadButton().get() + val showDiscordButton = connectionsPreferences.discordShowDiscordButton().get() + + val name = title ?: appName + val details = customMessage.takeIf { it.isNotBlank() } + ?: title + ?: context.getString(discordScreen.details) + + // Build buttons only if needed + val buttonLabels = mutableListOf().apply { + if (showButtons) { + if (showDownloadButton) add(context.getString(DOWNLOAD_BUTTON_LABEL_RES, appName)) + if (showDiscordButton) add(DISCORD_BUTTON_LABEL) + } + } + + val buttonUrls = mutableListOf().apply { + if (showButtons) { + if (showDownloadButton) add(DOWNLOAD_BUTTON_URL) + if (showDiscordButton) add(DISCORD_BUTTON_URL) + } + } + + val metadata = if (buttonLabels.isNotEmpty()) { + Activity.Metadata(buttonUrls = buttonUrls) + } else { + null + } + + rpc?.updateRPC( + activity = Activity( + name = name, + details = details, + state = state, + type = ActivityType.WATCHING.value, + timestamps = timestamps, + assets = Activity.Assets( + largeImage = "$MP_PREFIX$imageUrl", + smallImage = "$MP_PREFIX${DiscordScreen.APP.imageUrl}", + largeText = context.getString( + R.string.discord_status_description, + context.getString(discordScreen.details), + title ?: context.getString(discordScreen.text), + ), + smallText = context.getString(R.string.discord_app_description), + ), + buttons = buttonLabels.takeIf { it.isNotEmpty() }, + metadata = metadata, + ), + since = sinceTime, + ) + } + + internal suspend fun setReaderActivity( + context: Context, + readerData: ReaderData = ReaderData(), + ) { + // Early return if any required data is missing + if (rpc == null) { + Timber.tag(TAG).w("RPC client is null, skipping reader activity update") + return + } + + if (readerData.thumbnailUrl == null || readerData.mangaId == null) { + Timber.tag(TAG).w("Missing required data for reader activity: thumbnailUrl=${readerData.thumbnailUrl}, mangaId=${readerData.mangaId}") + return + } + + try { + val categories = getCategories(readerData.mangaId) + val discordIncognito = isIncognito(categories, readerData.incognitoMode) + + val mangaTitle = readerData.mangaTitle.takeUnless { discordIncognito } + val chapterNumber = getFormattedChapterNumber(context, readerData, discordIncognito) + val (startTime, _) = getTimestamps(readerData) + + withIOContext { + val rpcExternalAsset = getRPCExternalAsset() + val mangaThumbnail = + getDiscordThumbnail(rpcExternalAsset, readerData.thumbnailUrl, discordIncognito) + + discordScope.launchIO { + setScreen( + context = context, + discordScreen = DiscordScreen.MANGA, + readerData = readerData.copy( + mangaTitle = mangaTitle, + chapterNumber = chapterNumber, + thumbnailUrl = mangaThumbnail, + startTimestamp = startTime, + ), + ) + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error setting reader activity: ${e.message}") + } + } + + // Helper functions + private suspend fun getCategories(id: Long): List = + Injekt.get() + .await(id) + .map { it.id.toString() } + .ifEmpty { listOf(UNCATEGORIZED_ID.toString()) } + + private fun isIncognito(categories: List, incognitoMode: Boolean): Boolean { + val discordIncognitoMode = connectionsPreferences.discordRPCIncognito().get() + val incognitoCategories = connectionsPreferences.discordRPCIncognitoCategories().get() + val incognitoCategory = categories.fastAny { it in incognitoCategories } + return discordIncognitoMode || incognitoMode || incognitoCategory + } + + private fun getFormattedChapterNumber(context: Context, readerData: ReaderData, discordIncognito: Boolean): String? { + if (discordIncognito) return null + + val chapterNumber = readerData.chapterNumber ?: return null + val chapterNumberDouble = chapterNumber.toDoubleOrNull() + val useChapterTitles = connectionsPreferences.useChapterTitles().get() + + return when { + useChapterTitles || chapterNumberDouble == null -> chapterNumber + ceil(chapterNumberDouble) == floor(chapterNumberDouble) -> { + context.stringResource(MR.strings.notification_chapters_single, "${chapterNumberDouble.toInt()}") + } + else -> context.stringResource(MR.strings.notification_chapters_single, chapterNumber) + } + } + + private fun getTimestamps(readerData: ReaderData): Pair = + Pair( + readerData.startTimestamp ?: System.currentTimeMillis(), + null, + ) + + private fun getRPCExternalAsset(): RPCExternalAsset { + val connectionsManager: ConnectionsManager by injectLazy() + val networkService: NetworkHelper by injectLazy() + + return RPCExternalAsset( + applicationId = RICH_PRESENCE_APPLICATION_ID, + token = connectionsPreferences.connectionsToken(connectionsManager.discord).get(), + client = networkService.client, + json = json, + ) + } + + private suspend fun getDiscordThumbnail( + rpcExternalAsset: RPCExternalAsset, + thumbnailUrl: String?, + incognito: Boolean, + ): String? { + if (incognito || thumbnailUrl == null) return null + + return try { + rpcExternalAsset.getDiscordUri(thumbnailUrl) + ?.takeIf { !it.contains("external/Not Found") } + ?.let { + it.substringAfter("\"id\": \"") + .substringBefore("\"}") + .split(EXTERNAL_PREFIX) + .getOrNull(1) + ?.let { id -> "$EXTERNAL_PREFIX$id" } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error getting Discord URI: ${e.message}") + null + } + } + } +} +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordWebsocket.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordWebsocket.kt new file mode 100644 index 000000000..cb9dc10de --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/DiscordWebsocket.kt @@ -0,0 +1,187 @@ +// AM (DISCORD) --> + +// Original library from https://github.com/dead8309/KizzyRPC (Thank you) +// Thank you to the 最高 man for the refactored and simplified code +// https://github.com/saikou-app/saikou +package eu.kanade.tachiyomi.data.connections.discord + +import exh.log.xLogE +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import timber.log.Timber +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +sealed interface DiscordWebSocket : CoroutineScope { + suspend fun sendActivity(presence: Presence) + fun close() +} + +open class DiscordWebSocketImpl( + private val token: String, +) : DiscordWebSocket { + + private val json = Json { + encodeDefaults = true + allowStructuredMapKeys = true + ignoreUnknownKeys = true + } + + companion object { + private const val TAG = "DiscordWebSocket" + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + } + + private val request = Request.Builder() + .url("wss://gateway.discord.gg/?encoding=json&v=10") + .build() + + private var webSocket: WebSocket? = client.newWebSocket(request, Listener()) + + private var connected = false + + private val connectionState = MutableStateFlow(false) + + override val coroutineContext: CoroutineContext + get() = SupervisorJob() + Dispatchers.IO + + private fun sendIdentify() { + val response = Identity.Response( + op = 2, + d = Identity( + token = token, + properties = Identity.Properties( + os = "windows", + browser = "Chrome", + device = "disco", + ), + compress = false, + intents = 0, + ), + ) + webSocket?.send(json.encodeToString(response)) + } + + @Suppress("MagicNumber") + override fun close() { + Timber.tag(TAG).i("Closing Discord WebSocket, sending offline status") + webSocket?.send( + json.encodeToString( + Presence.Response( + op = OpCode.DISPATCH.value.toLong(), + d = Presence(status = "offline"), + ), + ), + ) + webSocket?.close(4000, "Interrupt") + connected = false + connectionState.value = false + } + + override suspend fun sendActivity(presence: Presence) { + try { + // Wait for connection with a 30-second timeout + withTimeout(30_000) { + connectionState.filter { it }.first() + } + Timber.tag(TAG).i("Sending ${OpCode.PRESENCE_UPDATE}") + val response = Presence.Response( + op = OpCode.PRESENCE_UPDATE.value.toLong(), + d = presence, + ) + val message = json.encodeToString(response) + Timber.tag(TAG).d("Sending message: $message") + val rtn = webSocket?.send(message) + if (rtn != true) xLogE("Failed to send ${OpCode.PRESENCE_UPDATE}") + } catch (e: TimeoutCancellationException) { + Timber.tag(TAG).e("Timeout waiting for Discord connection - skipping activity update: ${e.message}") + } catch (e: Exception) { + Timber.tag(TAG).e("Error sending Discord activity: ${e.message}") + } + } + + inner class Listener : WebSocketListener() { + private var seq: Int? = null + private var heartbeatInterval: Long? = null + + var scope = CoroutineScope(coroutineContext) + + private fun sendHeartBeat(sendIdentify: Boolean) { + scope.cancel() + scope = CoroutineScope(coroutineContext) + scope.launch { + delay(heartbeatInterval!!) + webSocket?.send("{\"op\":1, \"d\":$seq}") + } + if (sendIdentify) sendIdentify() + } + + @Suppress("MagicNumber") + override fun onMessage(webSocket: WebSocket, text: String) { + Timber.tag(TAG).d("Message received : $text") + + val map = json.decodeFromString(text) + seq = map.s + + when (map.op) { + OpCode.HELLO.value -> { + map.d + heartbeatInterval = map.d.jsonObject["heartbeat_interval"]!!.jsonPrimitive.long + sendHeartBeat(true) + } + OpCode.DISPATCH.value -> if (map.t == "READY") { + connected = true + connectionState.value = true + } + OpCode.HEARTBEAT.value -> { + if (scope.isActive) scope.cancel() + webSocket.send("{\"op\":1, \"d\":$seq}") + } + + OpCode.HEARTBEAT_ACK.value -> sendHeartBeat(false) + OpCode.RECONNECT.value -> webSocket.close(400, "Reconnect") + OpCode.INVALID_SESSION.value -> sendHeartBeat(true) + } + } + + @Suppress("MagicNumber") + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Timber.tag(TAG).i("Server Closed : $code $reason") + if (code == 4000) { + scope.cancel() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Timber.tag(TAG).e("Failure : ${t.message}") + if (t.message != "Interrupt") { + this@DiscordWebSocketImpl.webSocket = client.newWebSocket(request, Listener()) + } + } + } +} +// <-- AM (DISCORD) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/RPCExternalAsset.kt b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/RPCExternalAsset.kt new file mode 100644 index 000000000..5332ba349 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/connections/discord/RPCExternalAsset.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.data.connections.discord + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class RPCExternalAsset( + applicationId: String, + private val token: String, + private val client: OkHttpClient, + private val json: Json, +) { + companion object { + private const val TAG = "RPCExternalAsset" + } + + @Serializable + data class ExternalAsset( + val url: String? = null, + @SerialName("external_asset_path") + val externalAssetPath: String? = null, + ) + + private val api = "https://discord.com/api/v9/applications/$applicationId/external-assets" + suspend fun getDiscordUri(imageUrl: String): String? { + if (imageUrl.startsWith("mp:")) return imageUrl + val request = Request.Builder().url(api).header("Authorization", token) + .post("{\"urls\":[\"$imageUrl\"]}".toRequestBody("application/json".toMediaType())) + .build() + return try { + val res = client.newCall(request).await() + if (res.code == 429) { + // Rate limit hit + Timber.tag(TAG).e("Discord API rate limit reached: ${res.body.string()}") + return null + } + if (!res.isSuccessful) { + Timber.tag(TAG).e("Discord API error: HTTP ${res.code} - ${res.body.string()}") + return null + } + json.decodeFromString>(res.body.string()) + .firstOrNull()?.externalAssetPath?.let { "mp:$it" } + } catch (e: Exception) { + Timber.tag(TAG).e("Exception while fetching Discord external asset: ${e.message}") + null + } + } + + private suspend inline fun Call.await(): Response { + return suspendCoroutine { + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + it.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + it.resume(response) + } + }) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 3cc29a049..531992d67 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -81,6 +81,12 @@ class NotificationReceiver : BroadcastReceiver() { ACTION_START_APP_UPDATE -> startDownloadAppUpdate(context, intent) // Cancel downloading app update ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context) + + // KMK --> + // Stop Discord RPC service + ACTION_STOP_DISCORD_RPC -> stopDiscordRPC(context) + // <-- KMK + // Open reader activity ACTION_OPEN_CHAPTER -> { openChapter( @@ -244,6 +250,19 @@ class NotificationReceiver : BroadcastReceiver() { } } + // KMK --> + /** + * Stop the Discord RPC service + * + * @param context context of application + */ + private fun stopDiscordRPC(context: Context) { + val serviceIntent = Intent(context, eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService::class.java) + context.stopService(serviceIntent) + context.cancelNotification(Notifications.ID_DISCORD_RPC) + } + // <-- KMK + companion object { private const val NAME = "NotificationReceiver" @@ -270,6 +289,10 @@ class NotificationReceiver : BroadcastReceiver() { private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION" + // KMK --> + private const val ACTION_STOP_DISCORD_RPC = "$ID.$NAME.STOP_DISCORD_RPC" + // <-- KMK + private const val EXTRA_URI = "$ID.$NAME.URI" private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID" @@ -690,5 +713,25 @@ class NotificationReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } + + // KMK --> + /** + * Returns a [PendingIntent] that stops the Discord RPC service + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun stopDiscordRPCService(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_STOP_DISCORD_RPC + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + // KMK <-- } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index d0c7004e1..c044e8628 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -5,6 +5,8 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.RICH_PRESENCE_TAG import eu.kanade.tachiyomi.util.system.buildNotificationChannel import eu.kanade.tachiyomi.util.system.buildNotificationChannelGroup import tachiyomi.core.common.i18n.stringResource @@ -70,6 +72,14 @@ object Notifications { const val CHANNEL_INCOGNITO_MODE = "incognito_mode_channel" const val ID_INCOGNITO_MODE = -701 + // AM (DISCORD) --> + /** + * Notification channel used for Discord RPC + */ + const val CHANNEL_DISCORD_RPC = "${RICH_PRESENCE_TAG}_channel" + const val ID_DISCORD_RPC = -1701 + // <-- AM (DISCORD) + /** * Notification channel and ids used for app and extension updates. */ @@ -184,6 +194,11 @@ object Notifications { setGroup(GROUP_APK_UPDATES) setName(context.stringResource(MR.strings.channel_ext_updates)) }, + // AM (DISCORD) --> + buildNotificationChannel(CHANNEL_DISCORD_RPC, IMPORTANCE_LOW) { + setName(context.getString(R.string.pref_discord_rpc)) + }, + // <-- AM (DISCORD) // SY --> buildNotificationChannel(CHANNEL_LIBRARY_EHENTAI, IMPORTANCE_LOW) { setName("EHentai") diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 7c72f1960..63f98f5db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.data.SyncStatus import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.data.connections.ConnectionsManager import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider @@ -181,6 +182,10 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { LibraryUpdateStatus() } // KMK <-- + // AM (CONNECTIONS) --> + addSingletonFactory { ConnectionsManager() } + // <-- AM (CONNECTIONS) + // Asynchronously init expensive components for a faster cold start ContextCompat.getMainExecutor(app).execute { get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt index 483dd4131..7446e80f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/PreferenceModule.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.di import android.app.Application import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.connections.service.ConnectionsPreferences import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.sync.SyncPreferences import eu.kanade.domain.track.service.TrackPreferences @@ -71,6 +72,9 @@ class PreferenceModule(val app: Application) : InjektModule { addSingletonFactory { BasePreferences(app, get()) } + // AM (CONNECTIONS) --> + addSingletonFactory { ConnectionsPreferences(get()) } + // <-- AM (CONNECTIONS) addSingletonFactory { SyncPreferences(get()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt index 8ea7b5ebe..6f5f827d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt @@ -20,6 +20,8 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.components.TabbedScreen import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel import eu.kanade.tachiyomi.ui.browse.extension.extensionsTab import eu.kanade.tachiyomi.ui.browse.feed.FeedScreenModel @@ -33,6 +35,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import uy.kohesive.injekt.Injekt @@ -138,6 +141,12 @@ data object BrowseTab : Tab { LaunchedEffect(Unit) { (context as? MainActivity)?.ready = true + + // AM (DISCORD) --> + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.BROWSE) } + } + // <-- AM (DISCORD) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt index 2b6ff25b6..125af6f68 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt @@ -27,6 +27,8 @@ import eu.kanade.presentation.history.components.HistoryDeleteDialog import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -37,6 +39,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.receiveAsFlow import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.domain.chapter.model.Chapter import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource @@ -153,6 +156,12 @@ data object HistoryTab : Tab { LaunchedEffect(state.list) { if (state.list != null) { (context as? MainActivity)?.ready = true + + // AM (DISCORD) --> + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.HISTORY) } + } + // <-- AM (DISCORD) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index f5a7d491a..69a39f6ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -41,6 +41,8 @@ import eu.kanade.presentation.manga.components.LibraryBottomActionMenu import eu.kanade.presentation.more.onboarding.GETTING_STARTED_URL import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.sync.SyncDataJob @@ -441,6 +443,12 @@ data object LibraryTab : Tab { LaunchedEffect(state.isLoading) { if (!state.isLoading) { (context as? MainActivity)?.ready = true + + // AM (DISCORD) --> + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.LIBRARY) } + } + // <-- AM (DISCORD) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 68e0fc5e8..8e6b1242e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -52,6 +52,7 @@ import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.connections.service.ConnectionsPreferences import eu.kanade.domain.source.interactor.GetIncognitoState import eu.kanade.domain.sync.SyncPreferences import eu.kanade.presentation.components.AppStateBanners @@ -74,6 +75,7 @@ import eu.kanade.tachiyomi.data.LibraryUpdateStatus import eu.kanade.tachiyomi.data.SyncStatus import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.coil.MangaCoverMetadata +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.updater.AppUpdateChecker @@ -153,6 +155,10 @@ class MainActivity : BaseActivity() { private var navigator: Navigator? = null + // AM (CONNECTIONS) --> + private val connectionsPreferences: ConnectionsPreferences by injectLazy() + // <-- AM (CONNECTIONS) + init { registerSecureActivity(this) } @@ -344,6 +350,28 @@ class MainActivity : BaseActivity() { } } .launchIn(this) + + // AM (DISCORD) --> + connectionsPreferences.enableDiscordRPC().changes() + .drop(1) + .onEach { + if (it) { + DiscordRPCService.start(this@MainActivity.applicationContext) + } else { + DiscordRPCService.stop(this@MainActivity.applicationContext, 0L) + } + }.launchIn(this) + + connectionsPreferences.discordRPCStatus().changes() + .drop(1) + .onEach { + with(DiscordRPCService) { + discordScope.launchIO { + restart(this@MainActivity.applicationContext) + } + } + }.launchIn(this) + // <-- AM (DISCORD) } HandleOnNewIntent(context = context, navigator = navigator) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt index bfda3e456..ecbef75ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreTab.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -22,6 +23,8 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.more.MoreScreen import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen @@ -92,6 +95,14 @@ data object MoreTab : Tab { onClickLibraryUpdateErrors = { navigator.push(LibraryUpdateErrorScreen()) }, // KMK <-- ) + + // AM (DISCORD) --> + LaunchedEffect(Unit) { + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.MORE) } + } + } + // <-- AM (DISCORD) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 313118772..2d7dc8f89 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -64,6 +64,7 @@ import com.materialkolor.dynamicColorScheme import dev.chrisbanes.insetter.applyInsetter import eu.kanade.core.util.ifSourcesLoaded import eu.kanade.domain.base.BasePreferences +import eu.kanade.domain.connections.service.ConnectionsPreferences import eu.kanade.domain.manga.model.readingMode import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.reader.ChapterListDialog @@ -79,6 +80,8 @@ import eu.kanade.presentation.reader.settings.ReaderSettingsDialog import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.ReaderData import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.databinding.ReaderActivityBinding @@ -174,6 +177,10 @@ class ReaderActivity : BaseActivity() { private val themeCoverBasedStyle = uiPreferences.themeCoverBasedStyle().get() // KMK <-- + // AM (CONNECTIONS) --> + private val connectionsPreferences: ConnectionsPreferences = Injekt.get() + // <-- AM (CONNECTIONS) + lateinit var binding: ReaderActivityBinding val viewModel by viewModels() @@ -316,6 +323,11 @@ class ReaderActivity : BaseActivity() { override fun onPause() { viewModel.flushReadTimer() + + // AM (DISCORD) --> + updateDiscordRPC(exitingReader = true) + // <-- AM (DISCORD) + super.onPause() } @@ -326,6 +338,11 @@ class ReaderActivity : BaseActivity() { override fun onResume() { super.onResume() viewModel.restartReadTimer() + + // AM (DISCORD) --> + updateDiscordRPC(exitingReader = false) + // <-- AM (DISCORD) + setMenuVisibility(viewModel.state.value.menuVisible) } @@ -1107,6 +1124,10 @@ class ReaderActivity : BaseActivity() { assistUrl = url } } + + // AM (DISCORD) --> + updateDiscordRPC(exitingReader = false) + // <-- AM (DISCORD) } /** @@ -1526,4 +1547,46 @@ class ReaderActivity : BaseActivity() { binding.viewerContainer.setLayerType(LAYER_TYPE_HARDWARE, paint) } } + + /** + * Updates the Discord Rich Presence (RPC) status based on the current reader activity. + * + * @param exitingReader A boolean flag indicating whether the user is exiting the reader. + * If true, the Discord RPC status is set to the last used screen. + * If false, the Discord RPC status is set to the current reader activity, displaying details such as the manga title, chapter number, and chapter title. + */ + private fun updateDiscordRPC(exitingReader: Boolean) { + if (!connectionsPreferences.enableDiscordRPC().get()) return + + DiscordRPCService.discordScope.launchIO { + try { + if (!exitingReader) { + val manga = viewModel.manga ?: return@launchIO + val chapter = viewModel.currentChapter ?: return@launchIO + + DiscordRPCService.setReaderActivity( + context = this@ReaderActivity, + ReaderData( + incognitoMode = viewModel.incognitoMode, + mangaId = manga.id, + mangaTitle = manga.ogTitle, + thumbnailUrl = manga.thumbnailUrl, + chapterNumber = if (connectionsPreferences.useChapterTitles().get()) { + chapter.name + } else { + chapter.chapterNumber.toString() + }, + startTimestamp = System.currentTimeMillis(), + ), + ) + } else { + with(DiscordRPCService) { + setScreen(this@ReaderActivity) + } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error updating Discord RPC: ${e.message}" } + } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 4e6f9369b..defd9fddc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -153,6 +153,9 @@ class ReaderViewModel @JvmOverloads constructor( val manga: Manga? get() = state.value.manga + val currentChapter: Chapter? + get() = state.value.currentChapter?.chapter?.toDomainChapter() + /** * The chapter id of the currently loaded chapter. Used to restore from process kill. */ @@ -334,7 +337,7 @@ class ReaderViewModel @JvmOverloads constructor( .map(::ReaderChapter) } - private val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) } + val incognitoMode: Boolean by lazy { getIncognitoState.await(manga?.source) } private val downloadAheadAmount = downloadPreferences.autoDownloadWhileReading().get() init { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/connections/DiscordLoginScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/connections/DiscordLoginScreen.kt new file mode 100644 index 000000000..970640205 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/connections/DiscordLoginScreen.kt @@ -0,0 +1,325 @@ +// AM (DISCORD) --> + +// Original library from https://github.com/dead8309/KizzyRPC (Thank you) +// Thank you to the 最高 man for the refactored and simplified code +// https://github.com/saikou-app/saikou +package eu.kanade.tachiyomi.ui.setting.connections + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ApplicationInfo +import android.graphics.Bitmap +import android.webkit.JavascriptInterface +import android.webkit.JsResult +import android.webkit.WebResourceRequest +import android.webkit.WebStorage +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.kevinnzou.web.AccompanistWebChromeClient +import com.kevinnzou.web.AccompanistWebViewClient +import com.kevinnzou.web.LoadingState +import com.kevinnzou.web.WebView +import com.kevinnzou.web.rememberWebViewNavigator +import com.kevinnzou.web.rememberWebViewState +import eu.kanade.domain.connections.service.ConnectionsPreferences +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.data.connections.ConnectionsManager +import eu.kanade.tachiyomi.data.connections.discord.DiscordAccount +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.util.system.isDebugBuildType +import eu.kanade.tachiyomi.util.system.setDefaultSettings +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import logcat.LogPriority +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.system.logcat +import tachiyomi.i18n.MR +import tachiyomi.i18n.kmk.KMR +import tachiyomi.presentation.core.i18n.stringResource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +private const val DISCORD_DOMAIN = "https://discord.com" +private const val DISCORD_LOGIN_URL = "$DISCORD_DOMAIN/login" +private const val DISCORD_USER_API_URL = "$DISCORD_DOMAIN/api/v10/users/@me" +private const val JS_TOKEN_NULL = "null" +private const val JS_TOKEN_ERROR = "error" + +@SuppressLint("SetJavaScriptEnabled") +class DiscordLoginScreen : Screen() { + + @Composable + override fun Content() { + val state = rememberWebViewState(url = DISCORD_LOGIN_URL) + val navigator = LocalNavigator.currentOrThrow + val webViewNavigator = rememberWebViewNavigator() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + var currentUrl by remember { mutableStateOf(DISCORD_LOGIN_URL) } + + val webViewClient = remember { + object : AccompanistWebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + url?.let { + if (url.contains("/channels/@me") || url.contains("/app")) { + view.evaluateJavascript( + """ + (function() { + function fallbackTokenAlert() { + // fallback to alert (kizzy's logic) + try { + var i = document.createElement('iframe'); + document.body.appendChild(i); + setTimeout(function() { + try { + var alt = i.contentWindow.localStorage.token; + if (alt) { + alert(alt.slice(1, -1)); + } else { + alert("$JS_TOKEN_NULL"); + } + } catch (e) { + alert("$JS_TOKEN_ERROR"); + } + }, 1000); + } catch (e) { + alert("$JS_TOKEN_ERROR"); + } + } + try { + var token = localStorage.getItem("token"); + if (token) { + Android.onRetrieveToken(token.slice(1, -1)); + } else { + fallbackTokenAlert(); + } + } catch (e) { + fallbackTokenAlert(); + } + })(); + """.trimIndent(), + null, + ) + } + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean = false + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + url?.let { + currentUrl = it + if (url.contains(DISCORD_LOGIN_URL)) { + view.evaluateJavascript( + """localStorage.clear()""", + null, + ) + } + } + } + + override fun doUpdateVisitedHistory( + view: WebView, + url: String?, + isReload: Boolean, + ) { + super.doUpdateVisitedHistory(view, url, isReload) + url?.let { + currentUrl = it + } + } + } + } + + val webChromeClient = remember { + object : AccompanistWebChromeClient() { + override fun onJsAlert( + view: WebView, + url: String, + message: String, + result: JsResult, + ): Boolean { + if (message != JS_TOKEN_NULL && message != JS_TOKEN_ERROR) { + login(message, context) + scope.launch(Dispatchers.Main) { + view.loadUrl("about:blank") + navigator.pop() + } + } + result.confirm() + return true + } + } + } + + Scaffold( + topBar = { + Box { + Column { + AppBar( + title = stringResource(MR.strings.login_title, stringResource(KMR.strings.connections_discord)), + subtitle = currentUrl, + navigateUp = { navigator.pop() }, + actions = { + AppBarActions( + persistentListOf( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_webview_refresh), + onClick = { webViewNavigator.reload() }, + ), + AppBar.OverflowAction( + title = stringResource(MR.strings.pref_clear_cookies), + onClick = { clearCookies(currentUrl) }, + ), + ), + ) + }, + ) + } + + when (val loadingState = state.loadingState) { + is LoadingState.Initializing -> LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + is LoadingState.Loading -> LinearProgressIndicator( + progress = { loadingState.progress }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + else -> {} + } + } + }, + ) { contentPadding -> + WebView( + state = state, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + navigator = webViewNavigator, + onCreated = { webView -> + webView.setDefaultSettings() + + // Debug mode (chrome://inspect/#devices) + if (isDebugBuildType && + 0 != webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE + ) { + WebView.setWebContentsDebuggingEnabled(true) + } + + clearCookies(DISCORD_LOGIN_URL) + + WebStorage.getInstance().deleteOrigin(DISCORD_DOMAIN) + + webView.addJavascriptInterface( + object { + @Suppress("unused") + @JavascriptInterface + fun onRetrieveToken(token: String) { + if (token != JS_TOKEN_NULL && token != JS_TOKEN_ERROR) { + login(token, context) + scope.launch(Dispatchers.Main) { + webView.loadUrl("about:blank") + navigator.pop() + } + } + } + }, + "Android", + ) + }, + client = webViewClient, + chromeClient = webChromeClient, + ) + } + } + + private fun login(token: String, context: Context) { + val connectionsManager: ConnectionsManager by lazy { Injekt.get() } + val connectionsPreferences: ConnectionsPreferences by lazy { Injekt.get() } + val networkHelper: NetworkHelper by lazy { Injekt.get() } + val json: Json by lazy { Injekt.get() } + + @Suppress("OPT_IN_USAGE") + launchIO { + try { + networkHelper.client.newCall( + okhttp3.Request.Builder() + .url(DISCORD_USER_API_URL) + .addHeader("Authorization", token) + .build(), + ).execute().use { response -> + if (response.isSuccessful) { + val body = response.body.string() + val account = json.decodeFromString(body) + .copy( + token = token, + isActive = true, + ) + connectionsManager.discord.addAccount(account) + + // Move preference writes into background coroutine + connectionsPreferences.connectionsToken(connectionsManager.discord).set(token) + connectionsPreferences.setConnectionsCredentials( + connectionsManager.discord, + "Discord", + "Logged In", + ) + } + } + // Show toast on main thread + withContext(Dispatchers.Main) { + context.toast(MR.strings.login_success) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Discord login error: ${e.message}" } + // Show toast on main thread + withContext(Dispatchers.Main) { + context.toast(KMR.strings.login_failed) + } + } + } + } + + private fun clearCookies(url: String) { + val networkHelper: NetworkHelper by lazy { Injekt.get() } + + url.toHttpUrlOrNull()?.let { + val cleared = networkHelper.cookieJar.remove(it) + logcat { "Cleared $cleared cookies for: $url" } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt index a8294f36c..4e71b6db2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesTab.kt @@ -23,6 +23,8 @@ import eu.kanade.presentation.updates.UpdateScreen import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog import eu.kanade.presentation.util.Tab import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.ui.download.DownloadQueueScreen import eu.kanade.tachiyomi.ui.home.HomeScreen import eu.kanade.tachiyomi.ui.main.MainActivity @@ -32,6 +34,7 @@ import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event import kotlinx.coroutines.flow.collectLatest import mihon.feature.upcoming.UpcomingScreen import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import uy.kohesive.injekt.Injekt @@ -140,6 +143,12 @@ data object UpdatesTab : Tab { LaunchedEffect(state.isLoading) { if (!state.isLoading) { (context as? MainActivity)?.ready = true + + // AM (DISCORD) --> + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.UPDATES) } + } + // <-- AM (DISCORD) } } DisposableEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt index e21fc0b76..b89431ce6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -10,6 +10,8 @@ import android.widget.Toast import androidx.core.net.toUri import eu.kanade.presentation.webview.WebViewScreenContent import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.activity.BaseActivity @@ -20,6 +22,7 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setComposeContent import logcat.LogPriority import okhttp3.HttpUrl.Companion.toHttpUrl +import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR @@ -79,8 +82,22 @@ class WebViewActivity : BaseActivity() { onClearCookies = this::clearCookies, ) } + // AM (DISCORD) --> + with(DiscordRPCService) { + discordScope.launchIO { setScreen(this@WebViewActivity, DiscordScreen.WEBVIEW) } + } + // <-- AM (DISCORD) } + // AM (DISCORD) --> + override fun onDestroy() { + with(DiscordRPCService) { + discordScope.launchIO { setScreen(this@WebViewActivity) } + } + super.onDestroy() + } + // <-- AM (DISCORD) + override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) assistUrl?.let { outContent.webUri = it.toUri() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt index dbb514794..1a70ee63d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewScreen.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.webview import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -8,6 +9,9 @@ import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.Screen import eu.kanade.presentation.webview.WebViewScreenContent +import eu.kanade.tachiyomi.data.connections.discord.DiscordRPCService +import eu.kanade.tachiyomi.data.connections.discord.DiscordScreen +import tachiyomi.core.common.util.lang.launchIO class WebViewScreen( private val url: String, @@ -35,5 +39,13 @@ class WebViewScreen( onOpenInBrowser = { screenModel.openInBrowser(context, it) }, onClearCookies = screenModel::clearCookies, ) + + // KMK --> + LaunchedEffect(Unit) { + with(DiscordRPCService) { + discordScope.launchIO { setScreen(context, DiscordScreen.WEBVIEW) } + } + } + // <-- KMK } } diff --git a/app/src/main/res/drawable/ic_discord_24dp.xml b/app/src/main/res/drawable/ic_discord_24dp.xml new file mode 100644 index 000000000..e61a231da --- /dev/null +++ b/app/src/main/res/drawable/ic_discord_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml index e9ef688fd..7f6baff6d 100644 --- a/i18n-kmk/src/commonMain/moko-resources/base/strings.xml +++ b/i18n-kmk/src/commonMain/moko-resources/base/strings.xml @@ -164,4 +164,52 @@ To be updated Sponsor Me Support: MangaDex, E-H, ExH, nH, 8Muses, Tsumino + + + Special Services + Connections + Discord, more to come.. + Configure Discord RPC + Discord + This Discord Service works through token logging. Use at your own risk. Your token is stored locally and %s does not share it anywhere else + Discord Rich Presence + Show Chapter Titles + Show the title of the chapter you are reading + Enable Discord Rich Presence + Discord Status + ⛔ Do Not Disturb + 🌙 Idle + 🟢 Online + RPC Incognito + Discord Incognito mode + Overridden when Incognito Mode is enabled + Entries in included categories will not be displayed in Discord + Discord Accounts + Active Account + No accounts found + Failed to login + + + %1$s %2$s + Using + Browsing + Scrolling through + Messing around with + Read on %s + Komikku - a manga reader app + + + Discord Customization + Custom Message + Custom message that will be displayed on Discord + Show Progress + Display the current reading chapter + Show Time + Display activity time + Show Buttons + Display buttons to download the app or join the Discord server + Show Download Button + Displays a button to download the app + Show Discord Button + Displays a button to join the Discord server