feat(discord): Discord RPC from Anikku (#1100)

* Add RPC [Barebone, barely works]

Co-Authored-By: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

* refactor connections

* RPC (#223)

* Add error handling and refactor DiscordRPCService methods

* Add Discord RPC customization options and preferences

* Update Discord RPC button labels and clean up comments

* Refactor DiscordRPCService to improve readability by adding braces for null checks

* Refactor ConnectionsPreferences to improve code clarity and organization

* Refactor ConnectionsPreferences to improve code clarity and organization

(cherry picked from commit df42d196368d28c3c9dcee3692f3f9140da7f2a1)

* Feat: implement Discord account management enhancements and RPC restart functionality (#245)

* fix: correct indentation in CastManager for better readability

* feat: implement Discord account management enhancements and RPC restart functionality

(cherry picked from commit b7c50f1f0184dd717c5cc4dee0310053bfb8d613)

* Notification color & icon for DiscordRPC

Co-Authored-By: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

* Change foss variant application id suffix to '.foss' and more (mihonapp/mihon#1831)

- Remove `BuildConfig.PREVIEW`
- Rename `BuildConfig.INCLUDE_ANALYTICS` -> `BuildConfig.ANALYTICS_INCLUDED`
- Rename `BuildConfig.INCLUDE_UPDATER` -> `BuildConfig.UPDATER_ENABLED`
- Rename build property `with-analytics` -> `include-analytics`
- Rename build property `with-updater` -> `enable-updater`
- Add build property to disable code shrink
- Add build property to include dependency info in apk/app bundle

(cherry picked from commit 0893609ad2c4791dc404d72a5e69a2e0373517ae)

* feat(discord): update App/icon & fully works with all screens

* update Discord Rich Presence application ID and icon URLs

* Fix updateDiscordRPC for all screens (also reuse code)

* integrate Discord Rich Presence for WebViewScreen

* feat: ghibli style images for Discord RPC

* optimize code

* further optimize

* supprest useless warning

* refactor: simplify setScreen calls related to lastUsedScreen

* optimize whole file

* a

Co-Authored-By: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

* fix(discord): update download button URL to point to the official website

* feat(discord): allow stop Discord RPC from notification

also improve error handling

* fix(i18n): Torrent server "stop" multi-lang

* Start DiscordRPCService as foreground service on Android O+

This commit modifies the `DiscordRPCService` to start as a foreground service on Android O (API level 26) and above. This change ensures the service continues to run in the background, especially when the app is not in the foreground.

- Added a conditional check using `Build.VERSION.SDK_INT` to detect if the device is running Android O or later.
- If the device is running Android O or later, `startForegroundService` is called instead of `startService` to ensure it runs as foreground service.
- If the device is running a version prior to Android O, `startService` is called.

(cherry picked from commit 869617d8cac3aa8461158f1f9a34b9e29147e8f0)

* fix(discord): episode/chapter formating

* fix(discord): Fix & improve Discord login/accounts

* New webview with DiscordLoginScreen

* Add appbar

* Using Webview directly instead of AndroidView and add clearCookies, browsing navigation

* Using coroutine instead of thread

* Revert service

* use response block

* save preference in coroutine too

* no web navigator

* address comments for DiscordLoginScreen

* Refactor Settings screen & dialog

* Using serialization to parse JSON to avoid blocking API

* Don't clear all cookies and web storage aggressively

* Extract constant

* Fix login

Co-Authored-By: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>

* fix(discord): Fix screen crash & token retrievement

* Update images to my liking

* spotless

* small fixes

* Update strings.xml

* Revert "Update images to my liking"

This reverts commit 4ad3b9f84fdada45ee480ae7dc242b6e1b4bc18a.

* small fixes

* hopeful fix + chapter progress

* Update ReaderActivity.kt

* Update ReaderActivity.kt

* remove timestamps

* Remove isNSFW check

because 95% of tachi extensions are rated as +18, even when there is sfw content

* Clean up

* Fix ReaderActivity

* Cleanup Settings

* Fix login

* Fix lag when getting categories

* Fix jump to pages

* Remove old SDK check

* Fix status when back from reading

* Remove pages progress

* Only set necessary action in ReaderActivity

* Using IO coroutine instead

* Add back timestamps

* Fix strings & settings

* Fix crash

* ActivityType

* Adjust description

* refactor Discord settings menu

* Using its own scope

* Optimize the connection waiting

* remove source.isNSFW

* Using screenModelScope

* Fix state access

* add modifier

* sharing OkHttpClient instance for better resource management

* Restart RPC directly via service intent instead of toggling

* remove lint suppression

* add back showProgress

* Fix set Discord status

* Fix Discord login no avatar

* Handle API rate limits and errors in getDiscordUri

* Token-based isLogged checking

* Make Discord token sensitive data and not in backup

* Minor fix

* remove debug message

---------

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
Co-authored-by: Cuong-Tran <cuongtran.tm@gmail.com>
Co-authored-by: Dark25 <nadiecaca2000@gmail.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
Co-authored-by: Jery <jery99961@gmail.com>
This commit is contained in:
kana-shii 2025-09-04 02:40:32 -03:00 committed by GitHub
parent 5280caa60d
commit c41d5fb847
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2895 additions and 1 deletions

View file

@ -216,6 +216,13 @@
android:label="EHentaiLogin"
android:exported="false"/>
<service
android:name=".data.connections.discord.DiscordRPCService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync">
</service>
<receiver
android:name=".data.notification.NotificationReceiver"
android:exported="false" />

View file

@ -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)

View file

@ -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)

View file

@ -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<String>() {
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<String>() {

View file

@ -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)
}

View file

@ -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<Preference> {
val navigator = LocalNavigator.currentOrThrow
val connectionsManager = remember { Injekt.get<ConnectionsManager>() }
var dialog by remember { mutableStateOf<Any?>(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)

View file

@ -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<DiscordAccount> = 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>(DiscordAccountsScreenState()) {
private val discord = Injekt.get<ConnectionsManager>().discord
private val connectionsPreferences = Injekt.get<ConnectionsPreferences>()
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,
)
}
}
}
}

View file

@ -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<Preference> {
val navigator = LocalNavigator.currentOrThrow
val connectionsPreferences = remember { Injekt.get<ConnectionsPreferences>() }
val connectionsManager = remember { Injekt.get<ConnectionsManager>() }
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<Any?>(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<GetCategories>() }
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)

View file

@ -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,

View file

@ -291,6 +291,9 @@ private val settingScreens = listOf(
SettingsReaderScreen,
SettingsDownloadScreen,
SettingsTrackingScreen,
// AM (CONNECTIONS) -->
SettingsConnectionScreen,
// <-- AM (CONNECTIONS)
SettingsBrowseScreen,
SettingsDataScreen,
SettingsSecurityScreen,

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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)

View file

@ -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<Json>()
fun getAccounts(): List<DiscordAccount> {
val accountsJson = connectionsPreferences.discordAccounts().get()
return try {
if (accountsJson.isNotBlank()) {
json.decodeFromString<List<DiscordAccount>>(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<DiscordAccount>) {
try {
val accountsJson = json.encodeToString(accounts)
connectionsPreferences.discordAccounts().set(accountsJson)
} catch (e: Exception) {
xLogE("Failed to save Discord accounts", e)
}
}
}
// <-- AM (DISCORD)

View file

@ -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() }
}

View file

@ -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)

View file

@ -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<String>? = 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<String>,
)
@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<Activity> = 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)

View file

@ -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<String>().apply {
if (showButtons) {
if (showDownloadButton) add(context.getString(DOWNLOAD_BUTTON_LABEL_RES, appName))
if (showDiscordButton) add(DISCORD_BUTTON_LABEL)
}
}
val buttonUrls = mutableListOf<String>().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<String> =
Injekt.get<GetCategories>()
.await(id)
.map { it.id.toString() }
.ifEmpty { listOf(UNCATEGORIZED_ID.toString()) }
private fun isIncognito(categories: List<String>, 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<Long?, Long?> =
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)

View file

@ -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<Res>(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)

View file

@ -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<List<ExternalAsset>>(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)
}
})
}
}
}

View file

@ -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 <--
}
}

View file

@ -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")

View file

@ -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<NetworkHelper>()

View file

@ -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())

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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<ReaderViewModel>()
@ -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}" }
}
}
}
}

View file

@ -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 {

View file

@ -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<Json>() }
@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<DiscordAccount>(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" }
}
}
}

View file

@ -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) {

View file

@ -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() }

View file

@ -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
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20.349,4.389a0.06,0.06 0,0 0,-0.032 -0.029,19.77 19.77,0 0,0 -4.885,-1.515 0.075,0.075 0,0 0,-0.079 0.037c-0.209,0.375 -0.444,0.865 -0.607,1.249a18.35,18.35 0,0 0,-5.487 0,12.583 12.583,0 0,0 -0.617,-1.249 0.078,0.078 0,0 0,-0.079 -0.037,19.746 19.746,0 0,0 -4.886,1.515 0.06,0.06 0,0 0,-0.031 0.028c-3.111,4.648 -3.964,9.183 -3.545,13.66a0.082,0.082 0,0 0,0.031 0.057,19.912 19.912,0 0,0 5.993,3.03 0.075,0.075 0,0 0,0.084 -0.028c0.461,-0.631 0.873,-1.296 1.226,-1.994a0.076,0.076 0,0 0,-0.042 -0.106,13.172 13.172,0 0,1 -1.872,-0.892 0.077,0.077 0,0 1,-0.007 -0.128c0.125,-0.094 0.251,-0.192 0.371,-0.292a0.078,0.078 0,0 1,0.078 -0.011c3.928,1.794 8.179,1.794 12.062,0a0.073,0.073 0,0 1,0.077 0.01c0.122,0.099 0.247,0.199 0.373,0.293a0.078,0.078 0,0 1,-0.007 0.128c-0.597,0.349 -1.22,0.645 -1.873,0.892a0.075,0.075 0,0 0,-0.04 0.106c0.359,0.697 0.771,1.362 1.225,1.993a0.077,0.077 0,0 0,0.085 0.029,19.842 19.842,0 0,0 6.003,-3.03 0.077,0.077 0,0 0,0.03 -0.056c0.499,-5.177 -0.839,-9.674 -3.549,-13.66zM8.02,15.322c-1.183,0 -2.157,-1.087 -2.157,-2.419 0,-1.334 0.956,-2.42 2.157,-2.42 1.21,0 2.176,1.095 2.157,2.42 0,1.332 -0.955,2.419 -2.157,2.419zM15.995,15.322c-1.184,0 -2.157,-1.087 -2.157,-2.419 0,-1.334 0.955,-2.42 2.157,-2.42 1.211,0 2.175,1.095 2.156,2.42 0,1.332 -0.945,2.419 -2.156,2.419z" />
</vector>

View file

@ -164,4 +164,52 @@
<string name="label_to_be_updated">To be updated</string>
<string name="sponsor_me">Sponsor Me</string>
<string name="batch_add_description">Support: MangaDex, E-H, ExH, nH, 8Muses, Tsumino</string>
<!-- Connections section -->
<string name="special_services">Special Services</string>
<string name="pref_category_connections">Connections</string>
<string name="pref_connections_summary">Discord, more to come..</string>
<string name="pref_discord_configuration">Configure Discord RPC</string>
<string name="connections_discord">Discord</string>
<string name="connections_discord_info">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</string>
<string name="pref_discord_rpc">Discord Rich Presence</string>
<string name="show_chapters_titles_title">Show Chapter Titles</string>
<string name="show_chapters_titles_subtitle">Show the title of the chapter you are reading</string>
<string name="pref_enable_discord_rpc">Enable Discord Rich Presence</string>
<string name="pref_discord_status">Discord Status</string>
<string name="pref_discord_dnd">⛔ Do Not Disturb</string>
<string name="pref_discord_idle">🌙 Idle</string>
<string name="pref_discord_online">🟢 Online</string>
<string name="pref_category_discord_incognito">RPC Incognito</string>
<string name="pref_discord_incognito">Discord Incognito mode</string>
<string name="pref_discord_incognito_summary">Overridden when Incognito Mode is enabled</string>
<string name="pref_discord_incognito_categories_details">Entries in included categories will not be displayed in Discord</string>
<string name="discord_accounts">Discord Accounts</string>
<string name="active_account">Active Account</string>
<string name="no_accounts_found">No accounts found</string>
<string name="login_failed">Failed to login</string>
<!-- Discord section -->
<string name="discord_status_description">%1$s %2$s</string>
<string name="discord_status_using">Using</string>
<string name="discord_status_browsing">Browsing</string>
<string name="discord_status_scrolling">Scrolling through</string>
<string name="discord_status_messing">Messing around with</string>
<string name="discord_download_button">Read on %s</string>
<string name="discord_app_description">Komikku - a manga reader app</string>
<!-- Discord RPC Customization -->
<string name="pref_category_discord_customization">Discord Customization</string>
<string name="pref_discord_custom_message">Custom Message</string>
<string name="pref_discord_custom_message_summary">Custom message that will be displayed on Discord</string>
<string name="pref_discord_show_progress">Show Progress</string>
<string name="pref_discord_show_progress_summary">Display the current reading chapter</string>
<string name="pref_discord_show_timestamp">Show Time</string>
<string name="pref_discord_show_timestamp_summary">Display activity time</string>
<string name="pref_discord_show_buttons">Show Buttons</string>
<string name="pref_discord_show_buttons_summary">Display buttons to download the app or join the Discord server</string>
<string name="pref_discord_show_download_button">Show Download Button</string>
<string name="pref_discord_show_download_button_summary">Displays a button to download the app</string>
<string name="pref_discord_show_discord_button">Show Discord Button</string>
<string name="pref_discord_show_discord_button_summary">Displays a button to join the Discord server</string>
</resources>