diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7fdb6ddf0f..87c3740a33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -285,4 +285,5 @@ dependencies { coreLibraryDesugaring(libs.desugaring) implementation(libs.timber) + implementation(libs.security.crypto) } diff --git a/app/generate_proto.sh b/app/generate_proto.sh old mode 100644 new mode 100755 diff --git a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt index 875927be51..8db93d5229 100644 --- a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt +++ b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt @@ -161,6 +161,13 @@ val DiscordActivityTypeKey = stringPreferencesKey("discordActivityType") val DiscordActivityNameKey = stringPreferencesKey("discordActivityName") val DiscordAdvancedModeKey = booleanPreferencesKey("discordAdvancedMode") +// Matrix RPC +val MatrixAccountsKey = stringPreferencesKey("matrixAccounts") +val EnableMatrixRPCKey = booleanPreferencesKey("matrixRPCEnable") +val MatrixStatusFormatKey = stringPreferencesKey("matrixStatusFormat") +val MatrixUpdateIntervalKey = intPreferencesKey("matrixUpdateInterval") +val MatrixClearStatusKey = booleanPreferencesKey("matrixClearStatus") + // Google Cast val EnableGoogleCastKey = booleanPreferencesKey("enableGoogleCast") diff --git a/app/src/main/kotlin/com/metrolist/music/models/MatrixAccount.kt b/app/src/main/kotlin/com/metrolist/music/models/MatrixAccount.kt new file mode 100644 index 0000000000..653d7a2f48 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/models/MatrixAccount.kt @@ -0,0 +1,18 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MatrixAccount( + val homeserver: String, + val userId: String, +) { + override fun toString(): String { + return "MatrixAccount(homeserver='$homeserver', userId='$userId')" + } +} diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt index 2517821619..0e2a67736c 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt @@ -97,7 +97,6 @@ import com.metrolist.music.constants.DisableLoadMoreWhenRepeatAllKey import com.metrolist.music.constants.DiscordActivityNameKey import com.metrolist.music.constants.DiscordActivityTypeKey import com.metrolist.music.constants.DiscordAdvancedModeKey -import com.metrolist.music.constants.DiscordAvatarKey import com.metrolist.music.constants.DiscordButton1TextKey import com.metrolist.music.constants.DiscordButton1VisibleKey import com.metrolist.music.constants.DiscordButton2TextKey @@ -106,6 +105,13 @@ import com.metrolist.music.constants.DiscordStatusKey import com.metrolist.music.constants.DiscordTokenKey import com.metrolist.music.constants.DiscordUseDetailsKey import com.metrolist.music.constants.EnableDiscordRPCKey +import com.metrolist.music.constants.EnableMatrixRPCKey +import com.metrolist.music.constants.MatrixAccountsKey +import com.metrolist.music.constants.MatrixStatusFormatKey +import com.metrolist.music.constants.MatrixUpdateIntervalKey +import com.metrolist.music.constants.MatrixClearStatusKey +import com.metrolist.music.models.MatrixAccount +import kotlinx.serialization.json.Json import com.metrolist.music.constants.EnableLastFMScrobblingKey import com.metrolist.music.constants.EnableSongCacheKey import com.metrolist.music.constants.HideExplicitKey @@ -141,7 +147,6 @@ import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.Event import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.LyricsEntity -import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.RelatedSongMap import com.metrolist.music.db.entities.Song import com.metrolist.music.di.DownloadCache @@ -176,6 +181,11 @@ import com.metrolist.music.playback.queues.filterExplicit import com.metrolist.music.playback.queues.filterVideoSongs import com.metrolist.music.utils.CoilBitmapLoader import com.metrolist.music.utils.DiscordRPC +import com.metrolist.music.utils.MatrixRPC +import com.metrolist.music.utils.MatrixTokenStore +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import com.metrolist.music.utils.get import com.metrolist.music.utils.NetworkConnectivityObserver import com.metrolist.music.utils.ScrobbleManager import com.metrolist.music.utils.SyncUtils @@ -374,9 +384,17 @@ class MusicService : private var loudnessEnhancer: LoudnessEnhancer? = null private var discordRpc: DiscordRPC? = null + private var matrixRpcClients = mutableListOf() + private val matrixRpcClientsMutex = Mutex() + private val matrixRpcUpdateMutex = Mutex() private var lastPlaybackSpeed = 1.0f private var discordUpdateJob: kotlinx.coroutines.Job? = null + private var matrixUpdateJob: kotlinx.coroutines.Job? = null + private var matrixToastJob: kotlinx.coroutines.Job? = null + + private val matrixJsonConfig = Json { ignoreUnknownKeys = true } + // MediaSession components private var scrobbleManager: ScrobbleManager? = null val automixItems = MutableStateFlow>(emptyList()) @@ -408,27 +426,23 @@ class MusicService : var castConnectionHandler: CastConnectionHandler? = null private set - private val screenStateReceiver = - object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - when (intent.action) { - Intent.ACTION_SCREEN_OFF -> { - if (!player.isPlaying) { - scope.launch(Dispatchers.IO) { - discordRpc?.closeRPC() - } + private val screenStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_SCREEN_OFF -> { + if (!player.isPlaying) { + scope.launch(Dispatchers.IO) { + discordRpc?.closeRPC() + clearMatrixRPC() } } - Intent.ACTION_SCREEN_ON -> { - if (player.isPlaying) { - scope.launch { - currentSong.value?.let { song -> - updateDiscordRPC(song) - } + Intent.ACTION_SCREEN_ON -> { + if (player.isPlaying) { + scope.launch { + currentSong.value?.let { song -> + updateDiscordRPC(song) + updateMatrixRPC(song) } } } @@ -624,6 +638,14 @@ class MusicService : } } } + if (isConnected && matrixRpcClientsMutex.withLock { matrixRpcClients.isNotEmpty() } && player.isPlaying) { + val mediaId = player.currentMetadata?.id + if (mediaId != null) { + database.song(mediaId).first()?.let { song -> + updateMatrixRPC(song) + } + } + } } } @@ -800,6 +822,76 @@ class MusicService : } } + // Matrix Account Sync: Always keep clients loaded regardless of toggle + dataStore.data + .map { it[MatrixAccountsKey] } + .distinctUntilChanged() + .collect(scope) { accountsJson -> + val actualAccountsJson = accountsJson ?: "[]" + matrixRpcClientsMutex.withLock { + matrixRpcClients.clear() + val accounts = try { + matrixJsonConfig.decodeFromString>(actualAccountsJson) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to decode Matrix accounts (JSON: $actualAccountsJson)") + emptyList() + } + + accounts.forEach { account -> + val token = MatrixTokenStore.getToken(this@MusicService, account.homeserver, account.userId) + if (!token.isNullOrEmpty()) { + matrixRpcClients.add( + MatrixRPC( + homeserver = account.homeserver, + userId = account.userId, + accessToken = token, + listeningPrefix = getString(R.string.matrix_listening_prefix), + pausedPrefix = getString(R.string.matrix_paused_prefix) + ) + ) + } + } + } + // Trigger update/clear based on current state + if (dataStore.get(EnableMatrixRPCKey, false) == true) { + updateMatrixRPC(currentSong.value) + } else { + clearMatrixRPC() + } + } + + // Matrix Toggle: Gate network calls + dataStore.data + .map { it[EnableMatrixRPCKey] ?: false } + .distinctUntilChanged() + .collect(scope) { enabled -> + if (enabled) { + updateMatrixRPC(currentSong.value) + } else { + clearMatrixRPC() + } + } + + dataStore.data + .map { it[MatrixStatusFormatKey] } + .debounce(300) + .distinctUntilChanged() + .collect(scope) { + if (player.playbackState == Player.STATE_READY && player.playWhenReady) { + updateMatrixRPC(currentSong.value) + } + } + + dataStore.data + .map { it[MatrixClearStatusKey] ?: false } + .distinctUntilChanged() + .collect(scope) { clearRequested -> + if (clearRequested) { + clearMatrixRPC(showToast = true) + dataStore.edit { it[MatrixClearStatusKey] = false } + } + } + // Watch all Discord customization preferences dataStore.data .map { @@ -2162,6 +2254,7 @@ class MusicService : if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { scrobbleManager?.onSongStop() + clearMatrixRPC() } } @@ -2240,6 +2333,7 @@ class MusicService : ) { scope.launch { discordRpc?.close() + updateMatrixRPC(currentSong.value) } } } @@ -2256,6 +2350,7 @@ class MusicService : // Fetch song from database to get full info database.song(mediaId).first()?.let { song -> updateDiscordRPC(song) + updateMatrixRPC(song) } } } @@ -2361,13 +2456,12 @@ class MusicService : discordUpdateJob?.cancel() // update scheduling thingy - discordUpdateJob = - scope.launch { - delay(1000) - if (player.playWhenReady && player.playbackState == Player.STATE_READY) { - currentSong.value?.let { song -> - updateDiscordRPC(song) - } + discordUpdateJob = scope.launch { + delay(1000) + if (player.playWhenReady && player.playbackState == Player.STATE_READY) { + currentSong.value?.let { song -> + updateDiscordRPC(song) + updateMatrixRPC(song) } } } @@ -2902,10 +2996,127 @@ class MusicService : } } - private fun updateDiscordRPC( - song: Song, - showFeedback: Boolean = false, - ) { + /** + * Updates the status on all connected Matrix RPC clients. + * This handles debouncing and respects the global Matrix RPC enabled toggle. + * + * @param song The [Song] metadata to broadcast, or null to clear. + */ + private fun updateMatrixRPC(song: Song?) { + val current = song ?: currentSong.value ?: return + + // IMMEDIATE GATE: If presence is disabled, clear once and stop everything. + if (dataStore.get(EnableMatrixRPCKey, false) != true) { + clearMatrixRPC() + return + } + + matrixUpdateJob?.cancel() + matrixUpdateJob = scope.launch { + // Debounce rapid metadata/playback changes without holding the mutex. + delay(1000) + + // RE-CHECK AFTER DELAY: Just in case it was disabled during the 1s wait. + if (dataStore.get(EnableMatrixRPCKey, false) != true) { + clearMatrixRPC() + return@launch + } + + var repeatUpdate = false + var updateIntervalSeconds = 15 + + val statusState = matrixRpcUpdateMutex.withLock { + val presence = if (player.isPlaying) "online" else "unavailable" + val statusFormat = dataStore.get(MatrixStatusFormatKey, "").ifEmpty { getString(R.string.matrix_status_format_default) } + val intervalSeconds = dataStore.get(MatrixUpdateIntervalKey, 15) + + val clients = matrixRpcClientsMutex.withLock { matrixRpcClients.toList() } + + if (player.playWhenReady && player.playbackState == Player.STATE_READY && player.isPlaying) { + repeatUpdate = true + updateIntervalSeconds = intervalSeconds + } + + Triple(clients, presence, statusFormat) + } + + val (clients, presence, statusFormat) = statusState + clients.forEach { client -> + client.updateSong( + song = current, + currentPositionMs = player.currentPosition, + statusFormat = statusFormat, + presence = presence, + ).onFailure { + Timber.tag(TAG).w(it, "Matrix RPC update failed") + } + } + + if (repeatUpdate) { + delay(updateIntervalSeconds * 1000L) + updateMatrixRPC(current) + } + } + } + + /** + * Clears the current Matrix status on all connected clients. + * + * @param showToast Whether to show a localized toast message when status is cleared. + */ + private fun clearMatrixRPC(showToast: Boolean = false) { + matrixUpdateJob?.cancel() + matrixUpdateJob = scope.launch { + var count = 0 + val clientsToClear = matrixRpcUpdateMutex.withLock { + matrixRpcClientsMutex.withLock { + if (matrixRpcClients.isEmpty()) { + // Always attempt to load accounts when clearing, even if disabled + val accountsJson = dataStore.get(MatrixAccountsKey, "[]") + val accounts = try { + matrixJsonConfig.decodeFromString>(accountsJson) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to decode Matrix accounts for clearing (JSON: $accountsJson)") + emptyList() + } + + accounts.forEach { account -> + val token = MatrixTokenStore.getToken(this@MusicService, account.homeserver, account.userId) + if (!token.isNullOrEmpty()) { + matrixRpcClients.add( + MatrixRPC( + homeserver = account.homeserver, + userId = account.userId, + accessToken = token, + listeningPrefix = getString(R.string.matrix_listening_prefix), + pausedPrefix = getString(R.string.matrix_paused_prefix) + ) + ) + } + } + } + + matrixRpcClients.toList() + } + } + + count = clientsToClear.size + clientsToClear.forEach { it.clearStatus() } + + if (showToast && count > 0) { + matrixToastJob?.cancel() + matrixToastJob = scope.launch(Dispatchers.Main) { + android.widget.Toast.makeText( + this@MusicService, + resources.getQuantityString(R.plurals.matrix_status_cleared, count, count), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private fun updateDiscordRPC(song: Song, showFeedback: Boolean = false) { val useDetails = dataStore.get(DiscordUseDetailsKey, false) val advancedMode = dataStore.get(DiscordAdvancedModeKey, false) diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/NavigationBuilder.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/NavigationBuilder.kt index 946a1e9b9a..9b918de402 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/NavigationBuilder.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/NavigationBuilder.kt @@ -60,6 +60,7 @@ import com.metrolist.music.ui.screens.settings.integrations.DiscordSettings import com.metrolist.music.ui.screens.settings.integrations.IntegrationScreen import com.metrolist.music.ui.screens.settings.integrations.LastFMSettings import com.metrolist.music.ui.screens.settings.integrations.ListenTogetherSettings +import com.metrolist.music.ui.screens.settings.integrations.MatrixSettings import com.metrolist.music.ui.screens.wrapped.WrappedScreen import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference @@ -393,6 +394,10 @@ fun NavGraphBuilder.navigationBuilder( DiscordSettings(navController, snackbarHostState) } + composable("settings/integrations/matrix") { + MatrixSettings(navController) + } + composable("settings/integrations/lastfm") { LastFMSettings(navController) } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/IntegrationScreen.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/IntegrationScreen.kt index 76b8fcb8a7..1eec6d17b6 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/IntegrationScreen.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/IntegrationScreen.kt @@ -48,6 +48,13 @@ fun IntegrationScreen( navController.navigate("settings/integrations/discord") } ), + IntegrationCardItem( + icon = painterResource(R.drawable.matrix_icon), + title = { Text(stringResource(R.string.matrix_integration)) }, + onClick = { + navController.navigate("settings/integrations/matrix") + } + ), IntegrationCardItem( icon = painterResource(R.drawable.music_note), title = { Text(stringResource(R.string.lastfm_integration)) }, diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/MatrixSettings.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/MatrixSettings.kt new file mode 100644 index 0000000000..c441ab02d4 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/MatrixSettings.kt @@ -0,0 +1,476 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.ui.screens.settings.integrations + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.unit.dp +import androidx.navigation.NavController +import com.metrolist.music.LocalPlayerAwareWindowInsets +import com.metrolist.music.R +import com.metrolist.music.constants.EnableMatrixRPCKey +import com.metrolist.music.constants.MatrixAccountsKey +import com.metrolist.music.constants.MatrixStatusFormatKey +import com.metrolist.music.constants.MatrixUpdateIntervalKey +import com.metrolist.music.constants.MatrixClearStatusKey +import com.metrolist.music.models.MatrixAccount +import com.metrolist.music.ui.component.IconButton +import com.metrolist.music.ui.component.InfoLabel +import com.metrolist.music.ui.component.Material3SettingsGroup +import com.metrolist.music.ui.component.Material3SettingsItem +import com.metrolist.music.ui.component.TextFieldDialog +import com.metrolist.music.ui.utils.backToMain +import com.metrolist.music.utils.MatrixTokenStore +import com.metrolist.music.utils.rememberPreference +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +val matrixJsonSerializer = Json { ignoreUnknownKeys = true } + +@Composable +fun MatrixAccountDialog( + initialAccount: MatrixAccount?, + error: String? = null, + onDismiss: () -> Unit, + onSave: (MatrixAccount, String) -> Unit, + onDelete: (() -> Unit)? = null +) { + val context = LocalContext.current + var homeserver by rememberSaveable { mutableStateOf(initialAccount?.homeserver ?: "") } + var userId by rememberSaveable { mutableStateOf(initialAccount?.userId ?: "") } + var accessToken by remember { + mutableStateOf( + initialAccount?.let { + MatrixTokenStore.getToken(context, it.homeserver, it.userId) + } ?: "" + ) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (initialAccount == null) stringResource(R.string.matrix_add_account) else stringResource( + R.string.matrix_edit_account + ) + ) + }, + text = { + Column { + if (error != null) { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + OutlinedTextField( + value = homeserver, + onValueChange = { homeserver = it }, + label = { Text(stringResource(R.string.matrix_homeserver)) }, + placeholder = { Text(stringResource(R.string.matrix_homeserver_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = userId, + onValueChange = { userId = it }, + label = { Text(stringResource(R.string.matrix_user_id)) }, + placeholder = { Text(stringResource(R.string.matrix_user_id_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = accessToken, + onValueChange = { accessToken = it }, + label = { Text(stringResource(R.string.matrix_access_token)) }, + placeholder = { Text(stringResource(R.string.matrix_access_token_hint)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password + ), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val trimmedServer = homeserver.trim() + val normalizedHomeserver = when { + trimmedServer.isBlank() -> trimmedServer + trimmedServer.startsWith("https://", ignoreCase = true) -> trimmedServer + trimmedServer.startsWith("http://", ignoreCase = true) -> + "https://" + trimmedServer.substring(7) + else -> "https://$trimmedServer" + } + + onSave(MatrixAccount(normalizedHomeserver, userId.trim()), accessToken.trim()) + }, + enabled = homeserver.isNotBlank() && userId.isNotBlank() && accessToken.isNotBlank() + ) { + Text(stringResource(R.string.matrix_account_save)) + } + }, + dismissButton = { + Row { + if (onDelete != null) { + TextButton(onClick = onDelete) { + Text( + stringResource(R.string.matrix_account_delete), + color = MaterialTheme.colorScheme.error + ) + } + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.matrix_account_cancel)) + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MatrixSettings( + navController: NavController, +) { + val context = LocalContext.current + val (matrixRPC, onMatrixRPCChange) = rememberPreference(EnableMatrixRPCKey, false) + var accountsJson by rememberPreference(MatrixAccountsKey, "[]") + var statusFormat by rememberPreference(MatrixStatusFormatKey, "") + val (updateInterval, onUpdateIntervalChange) = rememberPreference(MatrixUpdateIntervalKey, 15) + val (_, onClearStatusChange) = rememberPreference(MatrixClearStatusKey, false) + + val (accounts, accountsParseError) = remember(accountsJson) { + try { + val parsed = matrixJsonSerializer.decodeFromString>(accountsJson) + parsed to null + } catch (e: Exception) { + Timber.tag("MatrixSettings").e(e, "Failed to decode Matrix accounts") + emptyList() to e.localizedMessage + } + } + + var showStatusFormatDialog by rememberSaveable { mutableStateOf(false) } + var editingIndex by rememberSaveable { mutableStateOf(null) } + var isAdding by rememberSaveable { mutableStateOf(false) } + var dialogError by rememberSaveable { mutableStateOf(null) } + + if (isAdding || editingIndex != null) { + val duplicateErrorMessage = stringResource(R.string.matrix_account_duplicate_error) + MatrixAccountDialog( + initialAccount = editingIndex?.let { accounts.getOrNull(it) }, + error = dialogError, + onDismiss = { + isAdding = false + editingIndex = null + dialogError = null + }, + onSave = onSave@{ account, token -> + val duplicateIndex = accounts.indexOfFirst { + it.homeserver == account.homeserver && it.userId == account.userId + } + if (duplicateIndex != -1 && duplicateIndex != editingIndex) { + dialogError = duplicateErrorMessage + return@onSave + } + dialogError = null + + val oldAccount = editingIndex?.let { accounts.getOrNull(it) } + if (oldAccount != null && (oldAccount.homeserver != account.homeserver || oldAccount.userId != account.userId)) { + MatrixTokenStore.removeToken(context, oldAccount.homeserver, oldAccount.userId) + } + + MatrixTokenStore.saveToken(context, account.homeserver, account.userId, token) + + val newAccounts = accounts.toMutableList() + if (isAdding) { + newAccounts.add(account) + } else { + editingIndex?.let { newAccounts[it] = account } + } + accountsJson = matrixJsonSerializer.encodeToString>(newAccounts) + isAdding = false + editingIndex = null + }, + onDelete = if (editingIndex != null) { + { + val newAccounts = accounts.toMutableList() + editingIndex?.let { index -> + val account = newAccounts.removeAt(index) + MatrixTokenStore.removeToken(context, account.homeserver, account.userId) + } + accountsJson = matrixJsonSerializer.encodeToString>(newAccounts) + editingIndex = null + } + } else null + ) + } + + if (showStatusFormatDialog) { + TextFieldDialog( + onDismiss = { showStatusFormatDialog = false }, + onDone = { + statusFormat = it + showStatusFormatDialog = false + }, + singleLine = true, + initialTextFieldValue = TextFieldValue(statusFormat.ifEmpty { stringResource(R.string.matrix_status_format_default) }), + extraContent = { + InfoLabel(text = stringResource(R.string.matrix_status_format_description)) + }, + ) + } + + Column( + modifier = Modifier + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + ), + ) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer( + Modifier.windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top), + ), + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.matrix_information_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + val accountItems = accounts.mapIndexed { index, account -> + Material3SettingsItem( + title = { Text(account.homeserver.ifEmpty { stringResource(R.string.matrix_new_account) }) }, + description = { Text(account.userId) }, + onClick = { editingIndex = index } + ) + }.toMutableList() + + if (accountsParseError != null) { + accountItems.add(0, Material3SettingsItem( + title = { Text(stringResource(R.string.matrix_account_sync_error), color = MaterialTheme.colorScheme.error) }, + description = { Text(accountsParseError, color = MaterialTheme.colorScheme.error) }, + trailingContent = { + TextButton(onClick = { accountsJson = "[]" }) { + Text(stringResource(R.string.action_reset), color = MaterialTheme.colorScheme.error) + } + }, + onClick = {} + )) + } + + if (accounts.size < 3) { + accountItems.add( + Material3SettingsItem( + title = { Text(stringResource(R.string.matrix_add_account)) }, + onClick = { isAdding = true } + ) + ) + } + + Material3SettingsGroup( + title = stringResource(R.string.matrix_integration), + items = listOf( + Material3SettingsItem( + title = { Text(stringResource(R.string.enable_matrix_rpc)) }, + trailingContent = { + Switch( + checked = matrixRPC, + onCheckedChange = onMatrixRPCChange, + ) + }, + onClick = { + onMatrixRPCChange(!matrixRPC) + }, + ) + ) + accountItems + ) + + Spacer(Modifier.height(8.dp)) + + Material3SettingsGroup( + title = stringResource(R.string.options), + items = listOf( + Material3SettingsItem( + title = { Text(stringResource(R.string.matrix_clear_status)) }, + description = { Text(stringResource(R.string.matrix_clear_status_description)) }, + onClick = { onClearStatusChange(true) }, + icon = painterResource(R.drawable.clear_all) + ), + ) + ) + + Spacer(Modifier.height(8.dp)) + + AnimatedVisibility(visible = matrixRPC) { + Column(modifier = Modifier.animateContentSize()) { + Material3SettingsGroup( + title = null, + items = listOf( + Material3SettingsItem( + title = { Text(stringResource(R.string.matrix_status_format)) }, + description = { + Text(statusFormat.ifEmpty { stringResource(R.string.matrix_status_format_default) }) + }, + onClick = { showStatusFormatDialog = true }, + ), + Material3SettingsItem( + title = { Text(stringResource(R.string.matrix_update_interval)) }, + description = { + Column { + Text(stringResource(R.string.matrix_update_interval_description)) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.Slider( + value = updateInterval.toFloat(), + onValueChange = { onUpdateIntervalChange(it.toInt()) }, + valueRange = 5f..120f, + steps = 22, // (120-5)/5 - 1 = 22 + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = pluralStringResource(R.plurals.seconds_unit, updateInterval, updateInterval), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(80.dp), + ) + } + } + }, + ), + ), + ) + + Card( + colors = + CardDefaults + .cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.matrix_status_format_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + } + + Spacer(Modifier.height(24.dp)) + } + + TopAppBar( + title = { Text(stringResource(R.string.matrix_integration)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) +} diff --git a/app/src/main/kotlin/com/metrolist/music/utils/MatrixRPC.kt b/app/src/main/kotlin/com/metrolist/music/utils/MatrixRPC.kt new file mode 100644 index 0000000000..c97b4dac89 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/utils/MatrixRPC.kt @@ -0,0 +1,177 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.utils + +import com.metrolist.music.db.entities.Song +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import timber.log.Timber + +private val sharedOkHttpClient: OkHttpClient = OkHttpClient() + +/** + * Handles Matrix Rich Presence (RPC) updates by setting the user's presence status + * to reflect the currently playing song. + * + * @property homeserver The Matrix homeserver URL. + * @property userId The Matrix user ID (e.g., @user:example.com). + * @property accessToken The Matrix access token for authentication. + * @property listeningPrefix The localized prefix used when a song is playing (e.g., "Listening to:"). + * @property pausedPrefix The localized prefix used when a song is paused (e.g., "Paused:"). + * @property client The [OkHttpClient] used for network requests. + */ +class MatrixRPC( + private val homeserver: String, + private val userId: String, + private val accessToken: String, + private val listeningPrefix: String, + private val pausedPrefix: String, + private val client: OkHttpClient = sharedOkHttpClient +) { + private var lastStatusMsg: String? = null + private var lastPresence: String? = null + + /** + * Updates the user's Matrix status message with the current song information. + * + * @param song The [Song] being played, or null to clear the status. + * @param currentPositionMs The current playback position in milliseconds. + * @param statusFormat The format string for the status message (e.g., "{song_name} by {artist_name}"). + * @param presence The Matrix presence state (e.g., "online", "unavailable", "offline"). + * @return A [Result] indicating success or failure of the update operation. + */ + suspend fun updateSong( + song: Song?, + currentPositionMs: Long = 0, + statusFormat: String = "", + presence: String = "online" + ) = runCatching { + Timber.tag("MatrixRPC").d("updateSong: user=$userId, presence=$presence, hasSong=${song != null}") + + if (homeserver.isEmpty() || userId.isEmpty() || accessToken.isEmpty()) { + Timber.tag("MatrixRPC").w("Missing credentials for $userId") + return@runCatching + } + + val resolvedText = if (song != null) { + val resolved = resolveVariables( + statusFormat, + song, + currentPositionMs + ) + if (presence == "unavailable") { + if (resolved.startsWith(listeningPrefix, ignoreCase = true)) { + pausedPrefix + resolved.substring(listeningPrefix.length) + } else { + "$pausedPrefix $resolved" + } + } else { + resolved + } + } else { + "" + } + + if (resolvedText == lastStatusMsg && presence == lastPresence) { + return@runCatching + } + + val jsonBody = JSONObject().apply { + put("presence", presence) + put("status_msg", resolvedText) + }.toString() + + Timber.tag("MatrixRPC").d("Updating status for $userId (len=${resolvedText.length}): $jsonBody") + + val url = homeserver.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegment("_matrix") + ?.addPathSegment("client") + ?.addPathSegment("v3") + ?.addPathSegment("presence") + ?.addPathSegment(userId) + ?.addPathSegment("status") + ?.build() ?: return@runCatching + + if (!url.isHttps) { + Timber.tag("MatrixRPC").e("Refusing to send Matrix access token over non-HTTPS: $url") + return@runCatching + } + + val urlString = url.toString() + + val request = Request.Builder() + .url(urlString) + .addHeader("Authorization", "Bearer $accessToken") + .put(jsonBody.toRequestBody("application/json".toMediaType())) + .build() + + kotlinx.coroutines.suspendCancellableCoroutine { continuation -> + val call = client.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + + call.enqueue(object : okhttp3.Callback { + override fun onFailure(call: okhttp3.Call, e: java.io.IOException) { + continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { + response.use { + if (!response.isSuccessful) { + val errorBody = response.body?.string() + val errorMsg = "Failed to update status: ${response.code} ${response.message} - Body: $errorBody" + Timber.tag("MatrixRPC").e(errorMsg) + continuation.resumeWith(Result.failure(Exception(errorMsg))) + } else { + Timber.tag("MatrixRPC").d("Successfully updated status") + lastStatusMsg = resolvedText + lastPresence = presence + continuation.resumeWith(Result.success(Unit)) + } + } + } + }) + } + }.onFailure { throwable -> + if (throwable is kotlinx.coroutines.CancellationException) throw throwable + Timber.tag("MatrixRPC").e(throwable, "Failed to update Matrix presence via updateSong") + } + + /** + * Clears the Matrix status by setting it to empty and presence to offline. + */ + suspend fun clearStatus() { + updateSong(song = null, statusFormat = "", presence = "offline") + } + + companion object { + /** + * Resolves template variables within a string using the current song's metadata. + * Supported variables: {song_name}, {artist_name}, {album_name}, {current_time}, {total_time}. + * + * @param text The template string to process. + * @param song The [Song] metadata to use for resolution. + * @param currentPositionMs The current position to use for {current_time}. + * @return The resolved status string. + */ + fun resolveVariables(text: String, song: Song, currentPositionMs: Long = 0): String { + val resolved = text + .replace("{song_name}", song.song.title) + .replace("{artist_name}", song.artists.joinToString { it.name }) + .replace("{album_name}", song.album?.title ?: "") + .replace("{current_time}", makeTimeString(currentPositionMs)) + .replace("{total_time}", makeTimeString(if (song.song.duration > 0) song.song.duration * 1000L else 0L)) + + Timber.tag("MatrixRPC").d("resolveVariables: input='$text', output='$resolved'") + return resolved + } + } +} diff --git a/app/src/main/kotlin/com/metrolist/music/utils/MatrixTokenStore.kt b/app/src/main/kotlin/com/metrolist/music/utils/MatrixTokenStore.kt new file mode 100644 index 0000000000..12df71069e --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/utils/MatrixTokenStore.kt @@ -0,0 +1,85 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.utils + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +object MatrixTokenStore { + private const val PREFS_FILE = "matrix_tokens" + private const val TAG = "MatrixTokenStore" + + @Volatile + private var prefs: SharedPreferences? = null + + private fun getPrefs(context: Context): SharedPreferences? { + val cached = prefs + if (cached != null) { + return cached + } + + return synchronized(this) { + val recheck = prefs + if (recheck != null) { + recheck + } else { + try { + val appContext = context.applicationContext + val masterKey = MasterKey.Builder(appContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val created = EncryptedSharedPreferences.create( + appContext, + PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + prefs = created + created + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize EncryptedSharedPreferences", e) + null + } + } + } + } + + private fun createKey(homeserver: String, userId: String): String { + return "${homeserver.length}:$homeserver|${userId.length}:$userId" + } + + fun saveToken(context: Context, homeserver: String, userId: String, token: String) { + val sharedPrefs = getPrefs(context) + if (sharedPrefs == null) { + Log.w(TAG, "saveToken: encrypted storage unavailable, token not persisted") + return + } + sharedPrefs.edit().putString(createKey(homeserver, userId), token).apply() + } + + fun getToken(context: Context, homeserver: String, userId: String): String? { + val sharedPrefs = getPrefs(context) + if (sharedPrefs == null) { + Log.w(TAG, "getToken: encrypted storage unavailable, returning null") + return null + } + return sharedPrefs.getString(createKey(homeserver, userId), null) + } + + fun removeToken(context: Context, homeserver: String, userId: String) { + val sharedPrefs = getPrefs(context) + if (sharedPrefs == null) { + Log.w(TAG, "removeToken: encrypted storage unavailable, nothing to remove") + return + } + sharedPrefs.edit().remove(createKey(homeserver, userId)).apply() + } +} diff --git a/app/src/main/res/drawable/matrix_icon.xml b/app/src/main/res/drawable/matrix_icon.xml new file mode 100644 index 0000000000..162339fda6 --- /dev/null +++ b/app/src/main/res/drawable/matrix_icon.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/metrolist_strings.xml b/app/src/main/res/values/metrolist_strings.xml index fe2bd6288c..8fd2e091ae 100644 --- a/app/src/main/res/values/metrolist_strings.xml +++ b/app/src/main/res/values/metrolist_strings.xml @@ -774,6 +774,47 @@ Recently converted Column %d + + Matrix integration + Enable Matrix presence + Add account + Edit account + Save + Cancel + Delete + An account with this homeserver and user ID already exists. + Homeserver + https://matrix.server.domain + User ID + e.g. @user:matrix.org + @user:server.domain + Access Token + Your Matrix access token + Status format + Variables: {song_name}, {artist_name}, {album_name}, {current_time}, {total_time} + Listening to: {song_name} by {artist_name} [{current_time} / {total_time}] | Metrolist Android + Listening to: + Paused: + e.g. Listening to {song_name} + + Update interval + How often to update your Matrix status (seconds) + + %1$d second + %1$d seconds + + New Account + Account sync error + Reset + Clear status & set offline + Clears your presence status message and sets you as offline for all accounts + + Status cleared for %1$d account + Status cleared for %1$d accounts + + This feature updates your Matrix presence using the standard Client-Server API. Your access token is stored locally and sent only to your specified homeserver. + + Status Online diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ec27d06c8..e7b450d670 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ brotli = "0.1.2" desugaring = "2.1.5" junit = "4.13.2" timber = "5.0.1" +securityCrypto = "1.1.0-alpha06" materialKolor = "4.1.1" kuromojiIpadic = "0.9.0" newpipeextractor = "v0.26.0" @@ -106,6 +107,7 @@ desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = junit = { module = "junit:junit", version.ref = "junit" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } newpipeextractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "newpipeextractor" }