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:
parent
5280caa60d
commit
c41d5fb847
38 changed files with 2895 additions and 1 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -291,6 +291,9 @@ private val settingScreens = listOf(
|
|||
SettingsReaderScreen,
|
||||
SettingsDownloadScreen,
|
||||
SettingsTrackingScreen,
|
||||
// AM (CONNECTIONS) -->
|
||||
SettingsConnectionScreen,
|
||||
// <-- AM (CONNECTIONS)
|
||||
SettingsBrowseScreen,
|
||||
SettingsDataScreen,
|
||||
SettingsSecurityScreen,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <--
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
app/src/main/res/drawable/ic_discord_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_discord_24dp.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue