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