diff --git a/.gitignore b/.gitignore index b13f1d4e..078214a6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,8 @@ revoltbuild.properties sentry.properties /.kotlin/sessions app/src/main/assets/embedded -/app/src/main/jniLibs/arm64-v8a/ -/app/src/main/jniLibs/armeabi-v7a/ -/app/src/main/jniLibs/x86/ -/app/src/main/jniLibs/x86_64/ +# /app/src/main/jniLibs/arm64-v8a/ +# /app/src/main/jniLibs/armeabi-v7a/ +# /app/src/main/jniLibs/x86/ +# /app/src/main/jniLibs/x86_64/ app/release-key.keystore diff --git a/README.md b/README.md index 8e339c7c..d61d84dc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Revolt for Android (Forked Version!) - +[![Open in DevPod!](https://devpod.sh/assets/open-in-devpod.svg)](https://devpod.sh/open#https://github.com/alexjyong/android)

Revolt for Android (Forked Version!)

Forked Version of the Revolt Android app.

@@ -16,11 +16,11 @@ NOTE: This is a forked version of the Android app for the [Revolt](https://revolt.chat) chat platform. I am not affilated with the Revolt Team, nor is this an official Revolt product. -I made this for some QOL changes that aren't present in the current version at the time of writing. +I made this for some QOL changes that aren't present in the current version at the time of writing such as notification support, jump to reply, voice messages, and more! **This app also works on de-googled phones as well!** -Feel free to use this for whatever, but note that this is NOT the official Revolt android app. :) +Feel free to use this for whatever, but note that this is NOT the official Revolt Android app. :) You can download the latest APK [here](https://github.com/alexjyong/android/releases/latest). @@ -28,6 +28,11 @@ For support, discussion, updates and other things, visit our support server on [ ## Features Added Tap a card to expand. +
+Notification Support!!!πŸŽ‰πŸŽ‰ + +![Notification support preview](https://github.com/user-attachments/assets/8123962e-e2d3-4690-87e3-44e09724a29c) +
Voice messages diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9908636..ca46b15c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,7 +80,7 @@ android { minSdk = 24 targetSdk = 36 versionCode = Integer.parseInt("001_003_206".replace("_", ""), 10) - versionName = "1.3.6bg-forked" + versionName = "1.3.6bf-forked" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 723b39a3..6f575a08 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + + Log.d("MainActivity", "Processing pending navigation to channel: $channelId") + + if (!isReady.value) { + viewModelScope.launch { + isReady.first { it } + processPendingNavigationNow() + } + return + } + + processPendingNavigationNow() + } ?: run { + System.out.println("REVOLT_DEBUG: processPendingNavigation called but no pending channelId") + } + } + + private fun processPendingNavigationNow() { + pendingChannelId?.let { channelId -> + viewModelScope.launch { + try { + // First establish server context for proper back navigation + val channel = RevoltAPI.channelCache[channelId] + val serverId = channel?.server + + if (serverId != null) { + Log.d("MainActivity", "Building navigation stack - Server: $serverId, Channel: $channelId") + + // First navigate to server context (establishes proper back navigation) + ActionChannel.send(Action.ChatNavigate(ChatRouterDestination.NoCurrentChannel(serverId))) + + // Small delay to ensure server context is established + delay(100) + + // Then navigate to the specific channel + val messageId = pendingMessageId + if (messageId != null) { + ActionChannel.send(Action.SwitchChannelAndHighlight(channelId, messageId)) + Log.d("MainActivity", "Successfully built navigation stack with highlighting: Server($serverId) -> Channel($channelId) -> Message($messageId)") + } else { + ActionChannel.send(Action.SwitchChannel(channelId)) + Log.d("MainActivity", "Successfully built navigation stack: Server($serverId) -> Channel($channelId)") + } + } else { + Log.w("MainActivity", "Could not determine server for channel $channelId, falling back to direct navigation") + // Fallback to direct navigation if we can't determine server + val messageId = pendingMessageId + if (messageId != null) { + ActionChannel.send(Action.SwitchChannelAndHighlight(channelId, messageId)) + } else { + ActionChannel.send(Action.SwitchChannel(channelId)) + } + } + + pendingChannelId = null + pendingMessageId = null + } catch (e: Exception) { + Log.e("MainActivity", "Failed to navigate to channel from notification: $channelId", e) + } + } + } + } + val activeAlert = MutableStateFlow(null) val isAlertActive = MutableStateFlow(false) @@ -334,6 +418,15 @@ class MainActivity : AppCompatActivity() { DynamicColors.applyToActivitiesIfAvailable(RevoltApplication.instance) @Suppress("DEPRECATION") // We are fixing a bug in the splash screen window.statusBarColor = Color.Transparent.toArgb() + + // Set app as foreground for notification filtering + CurrentChannelState.setAppForegroundState(true) + } + + override fun onPause() { + super.onPause() + // Set app as background for notification filtering + CurrentChannelState.setAppForegroundState(false) } // Same as above for configuration changes (rotation, dark mode, etc.) @@ -345,6 +438,26 @@ class MainActivity : AppCompatActivity() { window.statusBarColor = Color.Transparent.toArgb() } + override fun onNewIntent(intent: Intent) { + Log.d("MainActivity", "onNewIntent called") + super.onNewIntent(intent) + processNotificationIntent(intent) + viewModel.processPendingNavigation() + } + + private fun processNotificationIntent(intent: Intent) { + val channelId = intent.getStringExtra("channelId") + val messageId = intent.getStringExtra("messageId") + Log.d("MainActivity", "Channel ID from intent: $channelId, Message ID: $messageId") + + if (channelId != null) { + Log.d("MainActivity", "Found notification deep link to channel: $channelId, messageId: $messageId") + viewModel.setPendingChannelNavigation(channelId, messageId) + } else { + Log.w("MainActivity", "No channelId found in intent extras") + } + } + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -360,6 +473,8 @@ class MainActivity : AppCompatActivity() { RevoltAPI.hydrateFromPersistentCache() + processNotificationIntent(intent) + setContent { val windowSizeClass = calculateWindowSizeClass(this) AppEntrypoint( @@ -373,7 +488,8 @@ class MainActivity : AppCompatActivity() { viewModel::onDismissHealthAlert, viewModel::onDismissLoginError, viewModel::checkLoggedInState, - viewModel::updateNextDestination + viewModel::updateNextDestination, + viewModel::processPendingNavigation ) } @@ -448,7 +564,8 @@ fun AppEntrypoint( onDismissHealthAlert: () -> Unit = {}, onDismissLoginError: () -> Unit = {}, onRetryConnection: () -> Unit, - onUpdateNextDestination: (String) -> Unit = {} + onUpdateNextDestination: (String) -> Unit = {}, + onProcessPendingNavigation: () -> Unit = {} ) { var showVoiceUI by rememberSaveable { mutableStateOf(false) } var voiceChannelId by rememberSaveable { mutableStateOf(null) } @@ -624,6 +741,11 @@ fun AppEntrypoint( ) + fadeIn(animationSpec = RevoltTweenFloat) } ) { + + LaunchedEffect(Unit) { + onProcessPendingNavigation() + } + ChatRouterScreen( navController, windowSizeClass, @@ -665,6 +787,10 @@ fun AppEntrypoint( ) } ) { + LaunchedEffect(Unit) { + onProcessPendingNavigation() + } + MainScreen(navController) } composable( @@ -712,6 +838,7 @@ fun AppEntrypoint( composable("settings/sessions") { SessionSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/chat") { ChatSettingsScreen(navController) } + composable("settings/notifications") { NotificationSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/experiments") { ExperimentsSettingsScreen(navController) } composable("settings/changelogs") { ChangelogsSettingsScreen(navController) } diff --git a/app/src/main/java/chat/revolt/api/internals/CurrentChannelState.kt b/app/src/main/java/chat/revolt/api/internals/CurrentChannelState.kt new file mode 100644 index 00000000..22556a4e --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/CurrentChannelState.kt @@ -0,0 +1,39 @@ +package chat.revolt.api.internals + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Singleton to track the currently active channel and app state for smart notification filtering + */ +object CurrentChannelState { + private val _currentChannelId = MutableStateFlow(null) + val currentChannelId: StateFlow = _currentChannelId.asStateFlow() + + private val _isAppInForeground = MutableStateFlow(false) + val isAppInForeground: StateFlow = _isAppInForeground.asStateFlow() + + fun setCurrentChannel(channelId: String?) { + _currentChannelId.value = channelId + } + + fun getCurrentChannel(): String? = _currentChannelId.value + + fun setAppForegroundState(isInForeground: Boolean) { + _isAppInForeground.value = isInForeground + // Clear current channel when app goes to background + if (!isInForeground) { + _currentChannelId.value = null + } + } + + fun isAppInForeground(): Boolean = _isAppInForeground.value + + /** + * Should filter notifications only if app is in foreground AND user is viewing the specific channel + */ + fun shouldFilterNotification(channelId: String): Boolean { + return isAppInForeground() && getCurrentChannel() == channelId + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Settings.kt b/app/src/main/java/chat/revolt/api/schemas/Settings.kt index 18e37570..6091a161 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Settings.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Settings.kt @@ -9,17 +9,37 @@ data class OrderingSettings( val servers: List = emptyList() ) +enum class NotificationState(val value: String) { + ALL("all"), + MENTION("mention"), + NONE("none"); + + companion object { + fun fromString(value: String?): NotificationState? { + return entries.find { it.value == value } + } + } +} + +@Serializable +data class MuteState( + val until: Long? = null +) @Serializable data class NotificationSettings( val channel: Map = emptyMap(), - val server: Map = emptyMap() + val server: Map = emptyMap(), + val channel_mutes: Map = emptyMap(), + val server_mutes: Map = emptyMap() ) @Serializable -data class _NotificationSettingsToParse( // quirk +data class _NotificationSettingsToParse( val channel: Map = emptyMap(), - val server: Map = emptyMap() + val server: Map = emptyMap(), + val channel_mutes: Map = emptyMap(), + val server_mutes: Map = emptyMap() ) @Serializable diff --git a/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt b/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt index 899b3913..a1467fe1 100644 --- a/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt +++ b/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt @@ -1,12 +1,158 @@ package chat.revolt.api.settings +import chat.revolt.api.schemas.NotificationState +import chat.revolt.api.schemas.MuteState +import chat.revolt.persistence.Database +import chat.revolt.persistence.SqlStorage + +object DefaultNotificationStates { + const val SAVED_MESSAGES = "all" + const val DIRECT_MESSAGE = "all" + const val GROUP = "all" + const val TEXT_CHANNEL = "mention" + const val VOICE_CHANNEL = "mention" + const val SERVER_DEFAULT = "mention" +} + object NotificationSettingsProvider { + + private fun getCurrentTime() = System.currentTimeMillis() + + fun isServerMuted(serverId: String): Boolean { + val muteState = SyncedSettings.notifications.server_mutes[serverId] ?: return false + return muteState.until == null || muteState.until > getCurrentTime() + } + + fun isChannelMuted(channelId: String): Boolean { + val muteState = SyncedSettings.notifications.channel_mutes[channelId] ?: return false + return muteState.until == null || muteState.until > getCurrentTime() + } + fun isChannelMuted(channelId: String, serverId: String?): Boolean { + if (serverId != null && isServerMuted(serverId)) return true + return isChannelMuted(channelId) + } + + fun getServerNotificationState(serverId: String): NotificationState { + val setting = SyncedSettings.notifications.server[serverId] + return NotificationState.fromString(setting) ?: NotificationState.MENTION + } + + fun getChannelNotificationState(channelId: String, serverId: String? = null): NotificationState { + val channelSetting = SyncedSettings.notifications.channel[channelId] + if (channelSetting != null) { + return NotificationState.fromString(channelSetting) ?: getDefaultForChannel(channelId) + } + if (serverId != null) { - // When the server is muted, all channels are muted - if (SyncedSettings.notifications.server[serverId] == "muted") return true + return getServerNotificationState(serverId) + } + return getDefaultForChannel(channelId) + } + + private fun getDefaultForChannel(channelId: String): NotificationState { + return try { + val db = Database(SqlStorage.driver) + val channel = db.channelQueries.findById(channelId).executeAsOneOrNull() + + when (channel?.channelType) { + "SavedMessages" -> NotificationState.ALL + "DirectMessage" -> NotificationState.ALL + "Group" -> NotificationState.ALL + "TextChannel", "VoiceChannel" -> NotificationState.MENTION + else -> NotificationState.MENTION + } + } catch (e: Exception) { + NotificationState.MENTION } + } + + fun shouldNotify(channelId: String, serverId: String?, isMention: Boolean): Boolean { + if (isChannelMuted(channelId, serverId)) return false + val state = getChannelNotificationState(channelId, serverId) + + return when (state) { + NotificationState.ALL -> true + NotificationState.MENTION -> isMention + NotificationState.NONE -> false + } + } + + suspend fun setServerNotificationState(serverId: String, state: NotificationState?) { + val current = SyncedSettings.notifications + val newServerMap = current.server.toMutableMap() + + if (state != null) { + newServerMap[serverId] = state.value + } else { + newServerMap.remove(serverId) + } + + SyncedSettings.updateNotifications( + current.copy(server = newServerMap) + ) + } + + suspend fun setChannelNotificationState(channelId: String, state: NotificationState?) { + val current = SyncedSettings.notifications + val newChannelMap = current.channel.toMutableMap() + + if (state != null) { + newChannelMap[channelId] = state.value + } else { + newChannelMap.remove(channelId) + } + + SyncedSettings.updateNotifications( + current.copy(channel = newChannelMap) + ) + } + + suspend fun muteServer(serverId: String, until: Long? = null) { + val current = SyncedSettings.notifications + val newMutes = current.server_mutes.toMutableMap() + newMutes[serverId] = MuteState(until) + + SyncedSettings.updateNotifications( + current.copy(server_mutes = newMutes) + ) + } + + suspend fun muteChannel(channelId: String, until: Long? = null) { + val current = SyncedSettings.notifications + val newMutes = current.channel_mutes.toMutableMap() + newMutes[channelId] = MuteState(until) + + SyncedSettings.updateNotifications( + current.copy(channel_mutes = newMutes) + ) + } + + suspend fun unmuteServer(serverId: String) { + val current = SyncedSettings.notifications + val newMutes = current.server_mutes.toMutableMap() + newMutes.remove(serverId) + + SyncedSettings.updateNotifications( + current.copy(server_mutes = newMutes) + ) + } + + suspend fun unmuteChannel(channelId: String) { + val current = SyncedSettings.notifications + val newMutes = current.channel_mutes.toMutableMap() + newMutes.remove(channelId) + + SyncedSettings.updateNotifications( + current.copy(channel_mutes = newMutes) + ) + } + + fun getServerMute(serverId: String): MuteState? { + return SyncedSettings.notifications.server_mutes[serverId] + } - return SyncedSettings.notifications.channel[channelId] == "muted" + fun getChannelMute(channelId: String): MuteState? { + return SyncedSettings.notifications.channel_mutes[channelId] } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt index 2e226abe..e569da1e 100644 --- a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt +++ b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt @@ -6,6 +6,7 @@ import chat.revolt.api.RevoltJson import chat.revolt.api.routes.sync.getKeys import chat.revolt.api.routes.sync.setKey import chat.revolt.api.schemas.AndroidSpecificSettings +import chat.revolt.api.schemas.MuteState import chat.revolt.api.schemas.NotificationSettings import chat.revolt.api.schemas.OrderingSettings import chat.revolt.api.schemas._NotificationSettingsToParse @@ -71,8 +72,6 @@ object SyncedSettings { } settings["notifications"]?.let { - // This is to fix a quirk where the web client sometimes leaves sub-objects in one of the objects - // Because it is written in typescript and does what it wants _notifications.value = parseNotificationSettings(it.value) } } catch (e: Exception) { @@ -85,7 +84,6 @@ object SyncedSettings { var intermediate = RevoltJson.decodeFromString(_NotificationSettingsToParse.serializer(), value) - // Throw out any value of intermediate.server and .channel that isn't a string intermediate = intermediate.copy( server = intermediate.server.filterValues { it != null } .filterValues { it is JsonPrimitive } @@ -95,10 +93,61 @@ object SyncedSettings { .filterValues { it!!.jsonPrimitive.isString } ) - // Convert the intermediate to a NotificationSettings + val channelMutes = mutableMapOf() + intermediate.channel_mutes.forEach { (channelId, element) -> + try { + if (element != null) { + val muteState = RevoltJson.decodeFromJsonElement(MuteState.serializer(), element) + val now = System.currentTimeMillis() + if (muteState.until == null || muteState.until > now) { + channelMutes[channelId] = muteState + } + } + } catch (e: Exception) { + logcat(LogPriority.WARN) { "Failed to parse channel mute for $channelId: ${e.message}" } + } + } + + val serverMutes = mutableMapOf() + intermediate.server_mutes.forEach { (serverId, element) -> + try { + if (element != null) { + val muteState = RevoltJson.decodeFromJsonElement(MuteState.serializer(), element) + val now = System.currentTimeMillis() + if (muteState.until == null || muteState.until > now) { + serverMutes[serverId] = muteState + } + } + } catch (e: Exception) { + logcat(LogPriority.WARN) { "Failed to parse server mute for $serverId: ${e.message}" } + } + } + + val serverSettings = intermediate.server.mapValues { entry -> + when (entry.value!!.jsonPrimitive.content) { + "muted" -> { + serverMutes[entry.key] = MuteState() + "mention" + } + else -> entry.value!!.jsonPrimitive.content + } + } + + val channelSettings = intermediate.channel.mapValues { entry -> + when (entry.value!!.jsonPrimitive.content) { + "muted" -> { + channelMutes[entry.key] = MuteState() + "all" + } + else -> entry.value!!.jsonPrimitive.content + } + } + NotificationSettings( - server = intermediate.server.mapValues { it.value!!.jsonPrimitive.content }, - channel = intermediate.channel.mapValues { it.value!!.jsonPrimitive.content } + server = serverSettings, + channel = channelSettings, + server_mutes = serverMutes, + channel_mutes = channelMutes ) } catch (e: Exception) { logcat(LogPriority.ERROR) { e.asLog() } diff --git a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt index c8f0d630..f158c19b 100644 --- a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.channels.Channel sealed class Action { data class OpenUserSheet(val userId: String, val serverId: String?) : Action() data class SwitchChannel(val channelId: String) : Action() + data class SwitchChannelAndHighlight(val channelId: String, val messageId: String) : Action() data class LinkInfo(val url: String) : Action() data class EmoteInfo(val emoteId: String) : Action() data class MessageReactionInfo(val messageId: String, val emoji: String) : Action() diff --git a/app/src/main/java/chat/revolt/internals/Changelogs.kt b/app/src/main/java/chat/revolt/internals/Changelogs.kt index 1841e0ba..45ed1afe 100644 --- a/app/src/main/java/chat/revolt/internals/Changelogs.kt +++ b/app/src/main/java/chat/revolt/internals/Changelogs.kt @@ -1,13 +1,7 @@ package chat.revolt.internals import android.content.Context -import chat.revolt.api.REVOLT_KJBOOK -import chat.revolt.api.RevoltHttp -import chat.revolt.api.RevoltJson -import chat.revolt.internals.IndexHolder.cachedIndex import chat.revolt.persistence.KVStorage -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText import kotlinx.serialization.Serializable @Serializable @@ -44,54 +38,85 @@ data class Changelog( val rendered: String ) -object IndexHolder { - var cachedIndex: ChangelogIndex? = null -} - class Changelogs(val context: Context, val kvStorage: KVStorage? = null) { - suspend fun fetchChangelogIndex(): ChangelogIndex { - if (cachedIndex != null) { - return cachedIndex as ChangelogIndex - } - try { - val response = RevoltHttp.get("$REVOLT_KJBOOK/changelogs.json") - cachedIndex = - RevoltJson.decodeFromString(ChangelogIndex.serializer(), response.bodyAsText()) - return cachedIndex as ChangelogIndex - } catch (e: Error) { - return ChangelogIndex() + companion object { + private val changelog1003006 = ChangelogData( + version = ChangelogVersion( + code = 1003006, + name = "1.3.6bf-forked", + title = "Notification Support!!!!πŸŽ‰πŸŽ‰" + ), + date = ChangelogDate( + publish = "2025-09-26T12:00:00.000Z" + ), + summary = "Comprehensive notification system" + ) + + private val allChangelogs = listOf( + changelog1003006 + ) + + private fun getChangelogContent(versionCode: Long): String { + return when (versionCode) { + 1003006L -> """ +

Notification Support!!!!πŸŽ‰πŸŽ‰

+
    +
  • Revolt Forked now supports notifications! +
+ +

How to Use:

+
    +
  1. Enable Notification Permissions: There should have been a pop-up asking you for notification permissions. If not, you'll need to enable it for the app.
  2. +
  3. Enable Background Notifications: Go to Settings β†’ Notifications β†’ Enable "Background Notifications" (an option to enable permissions for the app will be available here)
  4. +
  5. (Optional, but recommended for reliable notifications) Optimize Battery Settings: Toggle "Battery Optimization" in notification settings and follow the system prompts to ensure reliable delivery
  6. +
  7. Click Notifications: Simply tap any notification to jump directly to the message that triggered it
  8. +
  9. Known issues: If the app is completely closed (not just minimized), navigation away from the channel/DM where the notification is will be disabled. Also, sometimes the name of the user will not show up.
  10. +
+ """.trimIndent() + else -> "

Changelog content not available

" + } } } + suspend fun fetchChangelogIndex(): ChangelogIndex { + return ChangelogIndex(changelogs = allChangelogs) + } + suspend fun fetchChangelogByVersionCode(versionCode: Long): Changelog { - try { - val response = RevoltHttp.get("$REVOLT_KJBOOK/changelogs/$versionCode.json") - return RevoltJson.decodeFromString(Changelog.serializer(), response.bodyAsText()) - } catch (e: Error) { - return Changelog( - id = "", - slug = "", - body = e.localizedMessage ?: "", - collection = "", - data = ChangelogData( - version = ChangelogVersion( - code = 0L, - name = "", - title = e.localizedMessage ?: "", - ), - date = ChangelogDate( - publish = "1970-01-01T00:00:00.000Z" - ), - summary = e.localizedMessage ?: "" - ), - rendered = e.localizedMessage ?: "" + val changelogData = allChangelogs.find { it.version.code == versionCode } + return changelogData?.let { + Changelog( + id = versionCode.toString(), + slug = it.version.name, + body = getChangelogContent(versionCode), + collection = "changelogs", + data = it, + rendered = getChangelogContent(versionCode) ) - } + } ?: Changelog( + id = "", + slug = "", + body = "Changelog not found", + collection = "", + data = ChangelogData( + version = ChangelogVersion( + code = 0L, + name = "", + title = "Changelog not found" + ), + date = ChangelogDate( + publish = "1970-01-01T00:00:00.000Z" + ), + summary = "Changelog not found" + ), + rendered = "Changelog not found" + ) } suspend fun getLatestChangelog(): ChangelogData { - return fetchChangelogIndex().changelogs.maxByOrNull { it.version.code }!! + return fetchChangelogIndex().changelogs.maxByOrNull { it.version.code } + ?: throw IllegalStateException("No changelogs available") } suspend fun getLatestChangelogCode(): String { diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 5184c5f2..eb95a2b3 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -9,6 +9,7 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -72,6 +73,7 @@ import androidx.navigation.NavController import chat.revolt.BuildConfig import chat.revolt.R import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.CurrentChannelState import chat.revolt.api.internals.DirectMessages import chat.revolt.api.internals.UpdateChecker import chat.revolt.api.internals.UpdateInfo @@ -163,6 +165,7 @@ class ChatRouterViewModel @Inject constructor( var latestChangelogRead by mutableStateOf(true) var latestChangelog by mutableStateOf("") var latestChangelogBody by mutableStateOf("") + var pendingHighlightMessageId by mutableStateOf(null) var showNotificationRationale by mutableStateOf(false) var showEarlyAccessSpark by mutableStateOf(false) var showSwipeToReplySpark by mutableStateOf(false) @@ -179,8 +182,6 @@ class ChatRouterViewModel @Inject constructor( val current = kvStorage.get("currentDestination") setSaveDestination(ChatRouterDestination.fromString(current ?: "")) - // Disabled for forked version might use later ;) - /* latestChangelogRead = changelogs.hasSeenCurrent() latestChangelog = changelogs.getLatestChangelogCode() latestChangelogBody = @@ -188,9 +189,6 @@ class ChatRouterViewModel @Inject constructor( if (!latestChangelogRead) { changelogs.markAsSeen() } - */ - // Always mark changelog as read to prevent popup - latestChangelogRead = true // Disabled for forked version /* @@ -211,7 +209,7 @@ class ChatRouterViewModel @Inject constructor( val hasNotificationPermission = NotificationManagerCompat.from(context).areNotificationsEnabled() // right now we only show this in debug builds so Chucker can show its notification - if (!hasNotificationPermission && BuildConfig.DEBUG) { + if (!hasNotificationPermission) { showNotificationRationale = true } @@ -228,6 +226,12 @@ class ChatRouterViewModel @Inject constructor( fun setSaveDestination(destination: ChatRouterDestination) { currentDestination = destination + // Update global channel state for notification filtering + when (destination) { + is ChatRouterDestination.Channel -> CurrentChannelState.setCurrentChannel(destination.channelId) + else -> CurrentChannelState.setCurrentChannel(null) + } + viewModelScope.launch { kvStorage.set("currentDestination", destination.asSerialisedString()) @@ -240,7 +244,7 @@ class ChatRouterViewModel @Inject constructor( } } - fun setRegisterForNotifications() { + fun setRegisterForNotifications() { //we technically don't use this in revolt forked. showNotificationRationale = false FirebaseMessaging.getInstance().token.addOnCompleteListener( OnCompleteListener { task -> @@ -473,6 +477,18 @@ fun ChatRouterScreen( viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId)) } + is Action.SwitchChannelAndHighlight -> { + val resolvedChannel = RevoltAPI.channelCache[action.channelId] + + if (resolvedChannel == null) { + showChannelUnavailableAlert = true + return@let + } + + viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId)) + viewModel.pendingHighlightMessageId = action.messageId + } + is Action.LinkInfo -> { linkInfoSheetUrl = action.url showLinkInfoSheet = true @@ -790,11 +806,7 @@ fun ChatRouterScreen( }, onSelected = { accepted -> if (accepted) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - askNotificationsPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) - } else { - viewModel.setRegisterForNotifications() - } + topNav.navigate("settings/notifications") } else { viewModel.markNotificationsRejected() } @@ -902,6 +914,7 @@ fun ChatRouterScreen( dest = viewModel.currentDestination, topNav = topNav, useDrawer = false, + viewModel = viewModel, disableBackHandler = disableBackHandler, toggleDrawer = { toggleDrawerLambda() @@ -969,6 +982,7 @@ fun ChatRouterScreen( useSidebarGesture = it }, onEnterVoiceUI = onEnterVoiceUI, + viewModel = viewModel ) } } @@ -1047,6 +1061,7 @@ fun ChannelNavigator( disableBackHandler: Boolean = false, onEnterVoiceUI: (String) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {}, + viewModel: ChatRouterViewModel ) { val scope = rememberCoroutineScope() @@ -1088,7 +1103,14 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, + initialHighlightMessageId = viewModel.pendingHighlightMessageId ) + + LaunchedEffect(viewModel.pendingHighlightMessageId) { + if (viewModel.pendingHighlightMessageId != null) { + viewModel.pendingHighlightMessageId = null + } + } } is ChatRouterDestination.NoCurrentChannel -> { diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 81600941..88894517 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -154,6 +154,7 @@ import chat.revolt.sheets.ReactSheet import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.datetime.Instant @@ -202,6 +203,7 @@ fun ChannelScreen( drawerIsOpen: Boolean = false, backButtonAction: (() -> Unit)? = null, useChatUI: Boolean = false, + initialHighlightMessageId: String? = null, viewModel: ChannelScreenViewModel = hiltViewModel() ) { // @@ -439,6 +441,17 @@ fun ChannelScreen( val lazyListState = rememberLazyListState() var disableScroll by remember { mutableStateOf(false) } + LaunchedEffect(initialHighlightMessageId) { + initialHighlightMessageId?.let { messageId -> + viewModel.setHighlightedMessage(messageId) + val messageIndex = viewModel.findMessageIndex(messageId) + if (messageIndex >= 0) { + delay(500) // Small delay to ensure messages are loaded + lazyListState.animateScrollToItem(messageIndex) + } + } + } + val isScrolledToBottom = remember(lazyListState) { derivedStateOf { lazyListState.firstVisibleItemIndex <= 6 diff --git a/app/src/main/java/chat/revolt/screens/settings/NotificationSettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/NotificationSettingsScreen.kt new file mode 100644 index 00000000..b2104ef9 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/settings/NotificationSettingsScreen.kt @@ -0,0 +1,406 @@ +package chat.revolt.screens.settings + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import chat.revolt.R +import chat.revolt.composables.generic.ListHeader +import chat.revolt.persistence.KVStorage +import chat.revolt.services.NotificationForegroundService +import kotlinx.coroutines.launch +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +@HiltViewModel +class NotificationSettingsScreenViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val kvStorage: KVStorage +) : ViewModel() { + companion object { + private const val KEY_BACKGROUND_SERVICE_ENABLED = "notification_background_service_enabled" + } + + var isBackgroundServiceEnabled by mutableStateOf(false) + private set + + var isBatteryOptimizationDisabled by mutableStateOf(false) + private set + + init { + loadSettings() + checkBatteryOptimization() + } + + private fun loadSettings() { + viewModelScope.launch { + isBackgroundServiceEnabled = kvStorage.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED) ?: false + + if (isBackgroundServiceEnabled && hasNotificationPermission()) { + NotificationForegroundService.start(context) + } + } + } + + fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true // Permission wasn't required in older versions. (wonder if this is worth having with the api requirement?) + } + } + + fun toggleBackgroundService(enabled: Boolean, onPermissionRequired: () -> Unit) { + if (enabled && !hasNotificationPermission()) { + onPermissionRequired() + return + } + isBackgroundServiceEnabled = enabled + + viewModelScope.launch { + kvStorage.set(KEY_BACKGROUND_SERVICE_ENABLED, enabled) + } + + if (enabled) { + NotificationForegroundService.start(context) + } else { + NotificationForegroundService.stop(context) + } + } + + private fun checkBatteryOptimization() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + isBatteryOptimizationDisabled = powerManager.isIgnoringBatteryOptimizations(context.packageName) + } else { + isBatteryOptimizationDisabled = true // Not applicable for older versions + } + } + + fun openNotificationSettings() { + val intent = Intent().apply { + action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + } + + fun openBatteryOptimizationSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + Toast.makeText(context, context.getString(R.string.toast_opening_battery_settings), Toast.LENGTH_SHORT).show() + } else { + throw SecurityException("Intent not resolvable") + } + } catch (e: Exception) { + try { + val fallbackIntent = Intent().apply { + action = Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + if (fallbackIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(fallbackIntent) + Toast.makeText(context, context.getString(R.string.toast_find_battery_optimization), Toast.LENGTH_LONG).show() + } else { + throw SecurityException("Fallback intent not resolvable") + } + } catch (e2: Exception) { + try { + val appSettingsIntent = Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.parse("package:${context.packageName}") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + if (appSettingsIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(appSettingsIntent) + Toast.makeText(context, context.getString(R.string.toast_find_battery_in_app_info), Toast.LENGTH_LONG).show() + } else { + throw SecurityException("App settings intent not resolvable") + } + } catch (e3: Exception) { + Toast.makeText(context, context.getString(R.string.toast_battery_settings_manual), Toast.LENGTH_LONG).show() + } + } + } + } else { + Toast.makeText(context, context.getString(R.string.toast_battery_optimization_not_available), Toast.LENGTH_SHORT).show() + } + } + + fun refreshBatteryOptimizationStatus() { + checkBatteryOptimization() + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun NotificationSettingsScreen( + navController: NavController, + viewModel: NotificationSettingsScreenViewModel = hiltViewModel() +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val context = LocalContext.current + + val notificationPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + } else { + null + } + + val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionState?.status?.isGranted == true + } else { + true // Permission wasn't required in older versions + } + + LaunchedEffect(hasPermission) { + if (hasPermission && !viewModel.isBackgroundServiceEnabled) { + viewModel.toggleBackgroundService(true) {} + } + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + scrollBehavior = scrollBehavior, + title = { + Text( + text = stringResource(R.string.settings_notifications), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = stringResource(id = R.string.back) + ) + } + }, + ) + }, + ) { pv -> + val scrollState = rememberScrollState() + Column( + Modifier + .padding(pv) + .imePadding() + .fillMaxSize() + .verticalScroll(scrollState) + ) { + if (!hasPermission) { + Card( + modifier = Modifier.padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.settings_notifications_permission_required), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = stringResource(R.string.settings_notifications_permission_denied), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { + notificationPermissionState?.launchPermissionRequest() + ?: viewModel.openNotificationSettings() + } + ) { + Text(stringResource(R.string.settings_notifications_grant_permission)) + } + OutlinedButton( + onClick = viewModel::openNotificationSettings + ) { + Text(stringResource(R.string.settings_notifications_open_system_settings)) + } + } + } + } + } + + ListHeader { + Text( + text = stringResource(R.string.settings_notifications_header), + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = stringResource(R.string.settings_notifications_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val onPermissionRequired = { + notificationPermissionState?.launchPermissionRequest() + ?: viewModel.openNotificationSettings() + } + + ListItem( + headlineContent = { + Text(stringResource(R.string.settings_notifications_background_service)) + }, + supportingContent = { + Text(stringResource(R.string.settings_notifications_background_service_description)) + }, + trailingContent = { + Switch( + checked = viewModel.isBackgroundServiceEnabled, + onCheckedChange = null, + enabled = hasPermission + ) + }, + modifier = Modifier.clickable(enabled = hasPermission || !viewModel.isBackgroundServiceEnabled) { + viewModel.toggleBackgroundService( + !viewModel.isBackgroundServiceEnabled, + onPermissionRequired + ) + } + ) + + // Battery Optimization Settings + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ListItem( + headlineContent = { + Text(stringResource(R.string.settings_notifications_battery_optimization)) + }, + supportingContent = { + Column { + Text(stringResource(R.string.settings_notifications_battery_optimization_description)) + Text( + text = if (viewModel.isBatteryOptimizationDisabled) { + stringResource(R.string.settings_notifications_battery_not_optimized) + } else { + stringResource(R.string.settings_notifications_battery_optimized) + }, + style = MaterialTheme.typography.bodySmall, + color = if (viewModel.isBatteryOptimizationDisabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + }, + trailingContent = { + OutlinedButton( + onClick = { + viewModel.openBatteryOptimizationSettings() + } + ) { + Text(stringResource(R.string.settings_notifications_open_battery_settings)) + } + } + ) + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshBatteryOptimizationStatus() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(Unit) { + viewModel.refreshBatteryOptimizationStatus() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt index 5b9218fd..6aba1f54 100644 --- a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt @@ -300,6 +300,27 @@ fun SettingsScreen( } ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.settings_notifications) + ) + }, + leadingContent = { + SettingsIcon { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null, + ) + } + }, + modifier = Modifier + .testTag("settings_view_notifications") + .clickable { + navController.navigate("settings/notifications") + } + ) + ListItem( headlineContent = { Text(text = "App Updates") diff --git a/app/src/main/java/chat/revolt/services/NotificationForegroundService.kt b/app/src/main/java/chat/revolt/services/NotificationForegroundService.kt new file mode 100644 index 00000000..86c82f90 --- /dev/null +++ b/app/src/main/java/chat/revolt/services/NotificationForegroundService.kt @@ -0,0 +1,249 @@ +package chat.revolt.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import chat.revolt.R +import chat.revolt.activities.MainActivity +import chat.revolt.api.REVOLT_WEBSOCKET +import chat.revolt.api.RevoltAPI +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.realtime.frames.receivable.AnyFrame +import chat.revolt.api.realtime.frames.receivable.MessageFrame +import chat.revolt.api.realtime.frames.sendable.AuthorizationFrame +import chat.revolt.api.schemas.NotificationState +import chat.revolt.api.settings.NotificationSettingsProvider +import chat.revolt.api.internals.CurrentChannelState +import io.ktor.client.plugins.websocket.ws +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext +import logcat.LogPriority +import logcat.logcat + +class NotificationForegroundService : Service() { + companion object { + const val FOREGROUND_NOTIFICATION_ID = 1001 + const val CHANNEL_ID = "revolt_notification_service" + const val ACTION_START_SERVICE = "START_SERVICE" + const val ACTION_STOP_SERVICE = "STOP_SERVICE" + + fun start(context: Context) { + val intent = Intent(context, NotificationForegroundService::class.java).apply { + action = ACTION_START_SERVICE + } + context.startForegroundService(intent) + } + + fun stop(context: Context) { + val intent = Intent(context, NotificationForegroundService::class.java).apply { + action = ACTION_STOP_SERVICE + } + context.stopService(intent) + } + } + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var websocketJob: Job? = null + private lateinit var notificationHelper: NotificationHelper + + private fun shouldNotifyMessage(channelId: String, serverId: String?, isMention: Boolean): Boolean { + return try { + val channel = RevoltAPI.channelCache[channelId] + + when { + channel == null -> false + channel.type == "SavedMessages" -> false + CurrentChannelState.shouldFilterNotification(channelId) -> { + logcat(LogPriority.DEBUG) { "Notification filtered: message is for currently active channel $channelId (app in foreground)" } + false + } + else -> NotificationSettingsProvider.shouldNotify(channelId, serverId, isMention) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Notification logic failed: ${e.message}" } + isMention + } + } + + override fun onCreate() { + super.onCreate() + notificationHelper = NotificationHelper(this) + createNotificationChannel() + logcat(LogPriority.INFO) { "NotificationForegroundService created" } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START_SERVICE -> { + startForegroundService() + return START_STICKY + } + ACTION_STOP_SERVICE -> { + stopSelf() + return START_NOT_STICKY + } + } + return START_STICKY + } + + private fun startForegroundService() { + val notification = createForegroundNotification() + startForeground(FOREGROUND_NOTIFICATION_ID, notification) + + logcat(LogPriority.INFO) { "Starting WebSocket connection for notifications" } + startWebSocketConnection() + } + + private fun startWebSocketConnection() { + websocketJob?.cancel() + + websocketJob = serviceScope.launch { + var retryCount = 0 + + while (true) { + try { + logcat(LogPriority.INFO) { "Connecting to WebSocket..." } + + RevoltHttp.ws(REVOLT_WEBSOCKET) { + logcat(LogPriority.INFO) { "WebSocket connected successfully" } + retryCount = 0 + + val authFrame = AuthorizationFrame("Authenticate", RevoltAPI.sessionToken) + send(RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame)) + logcat(LogPriority.DEBUG) { "Sent authentication frame" } + + incoming.consumeEach { frame -> + if (frame is Frame.Text) { + handleWebSocketFrame(frame.readText()) + } + } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "WebSocket error: ${e.message}" } + retryCount++ + + val retryDelay = minOf(30000, 1000 * (1 shl retryCount)) + logcat(LogPriority.INFO) { "Retrying WebSocket connection in ${retryDelay}ms (attempt $retryCount)" } + delay(retryDelay.toLong()) + } + } + } + } + + private suspend fun handleWebSocketFrame(frameString: String) { + try { + val frameType = RevoltJson.decodeFromString(AnyFrame.serializer(), frameString).type + + when (frameType) { + "Message" -> { + val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), frameString) + handleNewMessage(messageFrame) + } + "Ready" -> { + logcat(LogPriority.INFO) { "WebSocket authenticated and ready" } + } + "Pong" -> { + logcat(LogPriority.DEBUG) { "Received pong frame" } + } + } + } catch (e: Exception) { + logcat(LogPriority.WARN) { "Failed to handle WebSocket frame: ${e.message}" } + } + } + + private suspend fun handleNewMessage(messageFrame: MessageFrame) { + withContext(Dispatchers.Main) { + try { + val channelId = messageFrame.channel ?: return@withContext + val channel = RevoltAPI.channelCache[channelId] + val serverId = channel?.server + + if (messageFrame.author == RevoltAPI.selfId) { + return@withContext + } + + val selfId = RevoltAPI.selfId ?: return@withContext + val isMention = messageFrame.mentions?.contains(selfId) == true || + messageFrame.content?.contains("@everyone") == true || + messageFrame.content?.contains("@here") == true + + if (shouldNotifyMessage(channelId, serverId, isMention)) { + val author = RevoltAPI.userCache[messageFrame.author] + val server = serverId?.let { RevoltAPI.serverCache[it] } + + notificationHelper.showMessageNotification( + messageFrame = messageFrame, + author = author, + channel = channel, + server = server + ) + + logcat(LogPriority.DEBUG) { "Showed notification for message ${messageFrame.id}" } + } else { + logcat(LogPriority.DEBUG) { "Notification filtered out for channel $channelId" } + } + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Error handling message notification: ${e.message}" } + } + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "Revolt Notification Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keeps Revolt connected for instant notifications" + setShowBadge(false) + } + + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } + + private fun createForegroundNotification(): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Revolt Notifications") + .setContentText("Connected and listening for messages") + .setSmallIcon(R.drawable.ic_notification_monochrome) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + override fun onDestroy() { + super.onDestroy() + websocketJob?.cancel() + logcat(LogPriority.INFO) { "NotificationForegroundService destroyed" } + } + + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/services/NotificationHelper.kt b/app/src/main/java/chat/revolt/services/NotificationHelper.kt new file mode 100644 index 00000000..71ffe8d1 --- /dev/null +++ b/app/src/main/java/chat/revolt/services/NotificationHelper.kt @@ -0,0 +1,113 @@ +package chat.revolt.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import chat.revolt.R +import chat.revolt.activities.MainActivity +import chat.revolt.api.realtime.frames.receivable.MessageFrame +import chat.revolt.api.schemas.Channel +import chat.revolt.api.schemas.Server +import chat.revolt.api.schemas.User +import logcat.LogPriority +import logcat.logcat + +class NotificationHelper(private val context: Context) { + companion object { + const val MESSAGES_CHANNEL_ID = "revolt_messages" + private const val MESSAGE_NOTIFICATION_ID_BASE = 2000 + } + + init { + createNotificationChannels() + } + + fun showMessageNotification( + messageFrame: MessageFrame, + author: User?, + channel: Channel?, + server: Server? + ) { + val channelName = when { + server != null && channel != null -> "#${channel.name} in ${server.name}" + channel?.name != null -> channel.name + else -> "Direct Message" + } + + val authorName = author?.displayName ?: author?.username ?: "Unknown User" + val messageContent = messageFrame.content?.take(100) ?: "" + + val title = "$authorName in $channelName" + val content = if (messageContent.isBlank()) "Sent an attachment" else messageContent + + val notificationIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + + logcat(LogPriority.DEBUG) { "Creating notification intent for channel: ${messageFrame.channel}, messageId: ${messageFrame.id}" } + putExtra("channelId", messageFrame.channel) + putExtra("messageId", messageFrame.id) + + if (server != null && channel != null) { + putExtra("serverId", server.id) + putExtra("serverName", server.name) + putExtra("channelName", channel.name) + logcat(LogPriority.DEBUG) { "Added server context - serverId: ${server.id}, channelName: ${channel.name}" } + } + } + + val requestCode = (messageFrame.channel?.hashCode() ?: 0) + System.currentTimeMillis().toInt() + val pendingIntent = PendingIntent.getActivity( + context, + requestCode, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, MESSAGES_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_monochrome) + .setContentTitle(title) + .setContentText(content) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(content) + .setSummaryText(channelName) + ) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .build() + + val notificationId = MESSAGE_NOTIFICATION_ID_BASE + messageFrame.channel.hashCode() + + try { + with(NotificationManagerCompat.from(context)) { + notify(notificationId, notification) + } + } catch (e: SecurityException) { + logcat(LogPriority.ERROR) { "Failed to show notification: Missing POST_NOTIFICATIONS permission" } + } + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val messagesChannel = NotificationChannel( + MESSAGES_CHANNEL_ID, + "Messages", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "New messages from channels and direct messages" + enableVibration(true) + setShowBadge(true) + } + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(messagesChannel) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/services/NotificationServiceManager.kt b/app/src/main/java/chat/revolt/services/NotificationServiceManager.kt new file mode 100644 index 00000000..acb24d89 --- /dev/null +++ b/app/src/main/java/chat/revolt/services/NotificationServiceManager.kt @@ -0,0 +1,44 @@ +package chat.revolt.services + +import android.content.Context +import android.content.pm.PackageManager +import android.Manifest +import android.os.Build +import androidx.core.content.ContextCompat +import chat.revolt.persistence.KVStorage +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationServiceManager @Inject constructor( + @ApplicationContext private val context: Context, + private val kvStorage: KVStorage +) { + companion object { + private const val KEY_BACKGROUND_SERVICE_ENABLED = "notification_background_service_enabled" + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + fun startServicesIfEnabled(applicationScope: CoroutineScope) { + applicationScope.launch { + val isBackgroundServiceEnabled = kvStorage.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED) ?: false + + if (isBackgroundServiceEnabled && hasNotificationPermission()) { + NotificationForegroundService.start(context) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/ChannelContextSheet.kt b/app/src/main/java/chat/revolt/sheets/ChannelContextSheet.kt index 57ef991d..0905a565 100644 --- a/app/src/main/java/chat/revolt/sheets/ChannelContextSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/ChannelContextSheet.kt @@ -2,13 +2,18 @@ package chat.revolt.sheets import android.widget.Toast import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text 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.LocalClipboardManager @@ -22,6 +27,7 @@ import chat.revolt.api.RevoltAPI import chat.revolt.composables.generic.SheetButton import chat.revolt.internals.Platform +import chat.revolt.sheets.ChannelNotificationContextSheet import kotlinx.coroutines.launch @Composable @@ -42,8 +48,32 @@ fun ChannelContextSheet(channelId: String, onHideSheet: suspend () -> Unit) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() + var showNotificationSubmenu by remember { mutableStateOf(false) } - SheetButton( + if (showNotificationSubmenu) { + Column { + SheetButton( + headlineContent = { Text("← Notifications") }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = null + ) + }, + onClick = { showNotificationSubmenu = false } + ) + + ChannelNotificationContextSheet( + channelId = channelId, + serverId = channel.server, + dismissSheet = onHideSheet + ) + } + return + } + + Column { + SheetButton( headlineContent = { Text( text = stringResource(id = R.string.channel_context_sheet_actions_copy_id), @@ -96,5 +126,15 @@ fun ChannelContextSheet(channelId: String, onHideSheet: suspend () -> Unit) { } ) - + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_menu_title)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + onClick = { showNotificationSubmenu = true } + ) + } } diff --git a/app/src/main/java/chat/revolt/sheets/NotificationContextSheet.kt b/app/src/main/java/chat/revolt/sheets/NotificationContextSheet.kt new file mode 100644 index 00000000..6a272129 --- /dev/null +++ b/app/src/main/java/chat/revolt/sheets/NotificationContextSheet.kt @@ -0,0 +1,473 @@ +package chat.revolt.sheets + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import chat.revolt.R +import chat.revolt.api.RevoltAPI +import chat.revolt.api.schemas.NotificationState +import chat.revolt.api.settings.NotificationSettingsProvider +import chat.revolt.composables.generic.SheetButton +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@Composable +fun ColumnScope.ChannelNotificationContextSheet( + channelId: String, + serverId: String? = null, + dismissSheet: suspend () -> Unit +) { + val scope = rememberCoroutineScope() + val channel = RevoltAPI.channelCache[channelId] + + if (channel == null) return + + var showMuteOptions by remember { mutableStateOf(false) } + + val isChannelMuted = NotificationSettingsProvider.isChannelMuted(channelId) + val currentChannelState = NotificationSettingsProvider.getChannelNotificationState(channelId, serverId) + val channelMute = NotificationSettingsProvider.getChannelMute(channelId) + val serverState = if (serverId != null) { + NotificationSettingsProvider.getServerNotificationState(serverId) + } else null + + if (showMuteOptions) { + val muteOptions = listOf( + 15 * 60 * 1000L to stringResource(R.string.mute_for_15_minutes), + 60 * 60 * 1000L to stringResource(R.string.mute_for_1_hour), + 3 * 60 * 60 * 1000L to stringResource(R.string.mute_for_3_hours), + 8 * 60 * 60 * 1000L to stringResource(R.string.mute_for_8_hours), + 24 * 60 * 60 * 1000L to stringResource(R.string.mute_for_24_hours), + null to stringResource(R.string.mute_until_turned_off) + ) + + muteOptions.forEach { (durationMs, label) -> + SheetButton( + headlineContent = { Text(label) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + onClick = { + scope.launch { + val until = if (durationMs != null) { + System.currentTimeMillis() + durationMs + } else null + NotificationSettingsProvider.muteChannel(channelId, until) + dismissSheet() + } + } + ) + } + + SheetButton( + headlineContent = { Text("Back") }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = null + ) + }, + onClick = { showMuteOptions = false } + ) + return + } + + if (isChannelMuted) { + SheetButton( + headlineContent = { + Text( + text = stringResource(R.string.unmute_channel), + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = channelMute?.until?.let { until -> + { + val formatter = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) + Text( + text = stringResource(R.string.muted_until, formatter.format(Date(until))), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + onClick = { + scope.launch { + NotificationSettingsProvider.unmuteChannel(channelId) + dismissSheet() + } + } + ) + } else { + SheetButton( + headlineContent = { Text(stringResource(R.string.mute_channel)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + onClick = { showMuteOptions = true } + ) + } + + Text( + text = stringResource(R.string.notification_menu_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + val serverDefaultLabel = if (serverId != null) { + stringResource(R.string.notification_state_server_default) + } else { + stringResource(R.string.notification_state_default) + } + + val defaultStateDescription = when { + serverId != null && serverState != null -> { + when (serverState) { + NotificationState.ALL -> stringResource(R.string.notification_state_all) + NotificationState.MENTION -> stringResource(R.string.notification_state_mention) + NotificationState.NONE -> stringResource(R.string.notification_state_none) + } + } + serverId == null -> { + when (channel?.type) { + "DirectMessage", "Group", "SavedMessages" -> stringResource(R.string.notification_state_all) + else -> stringResource(R.string.notification_state_mention) + } + } + else -> stringResource(R.string.notification_state_mention) + } + + val isUsingDefault = NotificationSettingsProvider.getChannelNotificationState(channelId, serverId).let { state -> + val explicitSetting = chat.revolt.api.settings.SyncedSettings.notifications.channel[channelId] + explicitSetting == null + } + + SheetButton( + headlineContent = { Text(serverDefaultLabel) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + supportingContent = { + Text( + text = defaultStateDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = if (isUsingDefault) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setChannelNotificationState(channelId, null) + dismissSheet() + } + } + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_all)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentChannelState == NotificationState.ALL && !isUsingDefault) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setChannelNotificationState(channelId, NotificationState.ALL) + dismissSheet() + } + } + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_mention)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_campaign_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentChannelState == NotificationState.MENTION && !isUsingDefault) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setChannelNotificationState(channelId, NotificationState.MENTION) + dismissSheet() + } + } + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_none)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentChannelState == NotificationState.NONE && !isUsingDefault) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setChannelNotificationState(channelId, NotificationState.NONE) + dismissSheet() + } + } + ) +} + +@Composable +fun ColumnScope.ServerNotificationContextSheet( + serverId: String, + dismissSheet: suspend () -> Unit +) { + val scope = rememberCoroutineScope() + val server = RevoltAPI.serverCache[serverId] + + if (server == null) return + + var showMuteOptions by remember { mutableStateOf(false) } + + val isServerMuted = NotificationSettingsProvider.isServerMuted(serverId) + val currentServerState = NotificationSettingsProvider.getServerNotificationState(serverId) + val serverMute = NotificationSettingsProvider.getServerMute(serverId) + + if (showMuteOptions) { + val muteOptions = listOf( + 15 * 60 * 1000L to stringResource(R.string.mute_for_15_minutes), + 60 * 60 * 1000L to stringResource(R.string.mute_for_1_hour), + 3 * 60 * 60 * 1000L to stringResource(R.string.mute_for_3_hours), + 8 * 60 * 60 * 1000L to stringResource(R.string.mute_for_8_hours), + 24 * 60 * 60 * 1000L to stringResource(R.string.mute_for_24_hours), + null to stringResource(R.string.mute_until_turned_off) + ) + + muteOptions.forEach { (durationMs, label) -> + SheetButton( + headlineContent = { Text(label) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + onClick = { + scope.launch { + val until = if (durationMs != null) { + System.currentTimeMillis() + durationMs + } else null + NotificationSettingsProvider.muteServer(serverId, until) + dismissSheet() + } + } + ) + } + + SheetButton( + headlineContent = { Text("Back") }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = null + ) + }, + onClick = { showMuteOptions = false } + ) + return + } + + if (isServerMuted) { + SheetButton( + headlineContent = { + Text( + text = stringResource(R.string.unmute_server), + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = serverMute?.until?.let { until -> + { + val formatter = SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) + Text( + text = stringResource(R.string.muted_until, formatter.format(Date(until))), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + onClick = { + scope.launch { + NotificationSettingsProvider.unmuteServer(serverId) + dismissSheet() + } + } + ) + } else { + SheetButton( + headlineContent = { Text(stringResource(R.string.mute_server)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + onClick = { showMuteOptions = true } + ) + } + + Text( + text = stringResource(R.string.notification_menu_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_all)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentServerState == NotificationState.ALL) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setServerNotificationState(serverId, NotificationState.ALL) + dismissSheet() + } + } + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_mention)) }, + supportingContent = { + Text( + text = "Default for servers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_campaign_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentServerState == NotificationState.MENTION) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setServerNotificationState(serverId, NotificationState.MENTION) + dismissSheet() + } + } + ) + + SheetButton( + headlineContent = { Text(stringResource(R.string.notification_state_none)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_close_24dp), + contentDescription = null + ) + }, + trailingContent = if (currentServerState == NotificationState.NONE) { + { + Icon( + painter = painterResource(R.drawable.icn_check_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + scope.launch { + NotificationSettingsProvider.setServerNotificationState(serverId, NotificationState.NONE) + dismissSheet() + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/ServerContextSheet.kt b/app/src/main/java/chat/revolt/sheets/ServerContextSheet.kt index 62625b70..3b9e237f 100644 --- a/app/src/main/java/chat/revolt/sheets/ServerContextSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/ServerContextSheet.kt @@ -49,6 +49,7 @@ import chat.revolt.composables.markdown.RichMarkdown import chat.revolt.composables.screens.settings.ServerOverview import chat.revolt.composables.sheets.SheetSelection import chat.revolt.internals.Platform +import chat.revolt.sheets.ServerNotificationContextSheet import kotlinx.coroutines.launch @Composable @@ -76,6 +77,7 @@ fun ServerContextSheet( var showLeaveConfirmation by remember { mutableStateOf(false) } var leaveSilently by remember { mutableStateOf(false) } + var showNotificationSubmenu by remember { mutableStateOf(false) } if (showLeaveConfirmation) { AlertDialog( @@ -150,6 +152,27 @@ fun ServerContextSheet( ) } + if (showNotificationSubmenu) { + Column(Modifier.verticalScroll(rememberScrollState())) { + SheetButton( + headlineContent = { Text("← Notifications") }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = null + ) + }, + onClick = { showNotificationSubmenu = false } + ) + + ServerNotificationContextSheet( + serverId = serverId, + dismissSheet = onHideSheet + ) + } + return + } + Column(Modifier.verticalScroll(rememberScrollState())) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -258,6 +281,23 @@ fun ServerContextSheet( } ) + SheetButton( + leadingContent = { + Icon( + painter = painterResource(id = R.drawable.icn_notification_settings_24dp), + contentDescription = null + ) + }, + headlineContent = { + Text( + text = stringResource(id = R.string.notification_menu_title) + ) + }, + onClick = { + showNotificationSubmenu = true + } + ) + if (server.owner != RevoltAPI.selfId) { SheetButton( leadingContent = { diff --git a/app/src/main/jniLibs/arm64-v8a/libfinalmarkdown.so b/app/src/main/jniLibs/arm64-v8a/libfinalmarkdown.so new file mode 100644 index 00000000..d2b16da7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libfinalmarkdown.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libfinalmarkdown.so b/app/src/main/jniLibs/armeabi-v7a/libfinalmarkdown.so new file mode 100644 index 00000000..559cc582 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libfinalmarkdown.so differ diff --git a/app/src/main/jniLibs/x86/libfinalmarkdown.so b/app/src/main/jniLibs/x86/libfinalmarkdown.so new file mode 100644 index 00000000..eec6de3f Binary files /dev/null and b/app/src/main/jniLibs/x86/libfinalmarkdown.so differ diff --git a/app/src/main/jniLibs/x86_64/libfinalmarkdown.so b/app/src/main/jniLibs/x86_64/libfinalmarkdown.so new file mode 100644 index 00000000..08516a1b Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libfinalmarkdown.so differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bef58868..10c18106 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -641,7 +641,7 @@ An error occurred. Please try again later. Stay in the loop - Enable notifications to be kept up to date with messages and mentions. + Enable notifications to be kept up to date with messages and mentions. Note you will have to enable background services in notification settings for this to work fully. Enable notifications Not now @@ -757,6 +757,44 @@ %1$s (unknown) Reset %1$s settings + Notifications + App Notifications + Get notified instantly about new messages from your friends and servers. + Real-time Notifications + Receive notifications instantly when the app is closed. Uses more battery but ensures you never miss a message. + Notification permission required + Notifications are disabled. Please enable them in your device settings to receive message notifications. + Grant Permission + Open Settings + Battery Optimization + Disable battery optimization to ensure reliable background notifications. May impact battery life. + Battery optimization enabled - notifications may be delayed + Battery optimization disabled - notifications will arrive reliably + Battery Settings + Opening battery optimization settings... + Find and disable battery optimization for Revolt + Please find battery settings in app info + Unable to open settings. Please manually disable battery optimization for Revolt. + Battery optimization not available on this Android version + + + Notifications + All Messages + Mentions Only + None + Server Default + Default + Mute Channel + Mute Server + Unmute Channel + Unmute Server + For 15 minutes + For 1 hour + For 3 hours + For 8 hours + For 24 hours + Until I turn it back on + Muted until %1$s Feedback Join the Revolt server to give feedback or suggestions and report bugs. diff --git a/docs/src/content/changelogs/1001001.md b/docs/src/content/changelogs/1001001.md deleted file mode 100644 index 79909e92..00000000 --- a/docs/src/content/changelogs/1001001.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -version: - code: 1001001 - name: 1.1.1-beta+gp20 - title: Post-Beta Patch -date: - publish: 2024-06-22T12:57:44.148Z -summary: Customise your corners and look at the new tabs. ---- - -## Exciting new Features ✨ - -- **πŸ“· Avatar Corner Radius** You can now customise the corner radius of user avatars. Whether in chat, profiles or anywhere else, give your avatars the perfect look. -- **🎨 Tabs Makeover** Tabs now look more modern, even when Material You is disabled. Major glow-up! - -## Tweaks and Fixes πŸ”§ - -- **🧹 Dependency Updates** Freshened up dependencies to keep things neat. -- **πŸ›‘ On Timeout** Temporarily removed multimedia-related frameworks to save on app size. Don't worry, they're just on a short vacation. diff --git a/docs/src/content/changelogs/1002000.md b/docs/src/content/changelogs/1002000.md deleted file mode 100644 index 3ef89e73..00000000 --- a/docs/src/content/changelogs/1002000.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -version: - code: 1002000 - name: 1.2.0-beta+gp20 - title: August 1st Patch -date: - publish: 2024-08-01T02:22:00.000Z -summary: New image picker, push groundwork, redesigned sidebar. ---- - -Hey chat! - -First off, you might've noticed this ✨fancy✨ new changelog sheet that just popped up. You may even -have noticed that it contains this text. Indeed, none of this is a coincidence. We've been working -hard to bring you the best changelog experience possible, and I think we might just have done it. - -This comes out of the fact we noticed that we simply *cook too much* for Google Play, and reached -the character limit right off the bat on our very first changelog. Not this time, Google Play, we -will not be defeated by your character limits! - -Of course, we've also been working on some other stuff. Here's a quick rundown of what's new in this -version. - -## Image Picker - -You might have noticed that the good ol' grid-o-images that we used to have for picking images has -suddenly disappeared by a new and confusing picker picker, something like this: - -![Image Picker](https://autumn.revolt.chat/attachments/DH2pQiE_XbyUE5gpQYTIgPcUmVZ49xSaR3jrloELY9) - -Sure enough, the rightmost two buttons seem self-explanatory. **Files** lets you pick any file on -your device, and **Camera** lets you take a picture. But what about the leftmost button? The big -**Select Photos** button? Well, that's where the magic happens. - -When you tap that button, you'll be presented with Android's new -built-in [photo picker](https://developer.android.com/training/data-storage/shared/photopicker). -This picker lets you select photos from your device, Google Photos, and other sources, at least when -those other sources come around to supporting it. Indeed Google has mandated that all apps must use -this picker. So we're getting ahead of the curve and making the switch now. - -## Stability Improvements - -We've also been working on some stability improvements. We've been working on some stability -improvements. We've been working on some stability improvements. We've been working on some -stability improvements. We've been working on some stability improvements. We've been working on -some stability improvements. We've been working on some stability improvements. - -Every time you see this text, it means we've been working on some stability improvements. And you -will see this text a lot in this changelog. Because we've been working on some stability -improvements. Can you guess what we've been working on? That's right, stability improvements. - -## Paving the Way for Push Notifications - -Push Notifications are really cool. They let you receive notifications even when you're not using -the app. Imagine this: you're out and about, and you get a notification that someone has replied to -your message. Call it fate, call it destiny, but I'll call it pretty cool. - -This update ships some of the groundwork for push notifications. We're not quite there yet, but -we're getting there. - -## Sidebar - -This is the most noticeable change in this update. The sidebar has been completely redesigned. It's -now stringently following the Material Design 3 guidelines on side drawers, and it has pretty -animations and stuff. Tablet users will also notice that unlike most apps, we actually remember -that tablets exist, and the sidebar is optimized for them too. - -Here's a screenshot of the new sidebar: - -![Sidebar](https://autumn.revolt.chat/attachments/rqo986CIx22IE_Z6fRx-UP1rLpQQBCJX9uxqnnlhUx) - -## Bottom Sheet Aesthetic Fix - -We've fixed a bug where the bottom sheet would leave an area below it unfilled. This was a visual -bug that was really annoying, and we're glad to have fixed it. Now the bottom sheet will fill the -entire screen, as it should, and when you close it, it will disappear into the void from whence it -came rather than chilling at the bottom of the screen for a second. - -## Wrappping Up - -That's all for this update. We hope you enjoy the new features and improvements. As always, if you -have any feedback, feel free to let us know in the channels on Jenvolt (the invite you shall find -on the app's "Home" screen or [by clicking here for free](https://rvlt.gg/jen)). We're always -listening and we're always working to make Revolt on Android better. - -We'll see you in the next update! \ No newline at end of file diff --git a/docs/src/content/changelogs/1002001.md b/docs/src/content/changelogs/1002001.md deleted file mode 100644 index 1cd6d841..00000000 --- a/docs/src/content/changelogs/1002001.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -version: - code: 1002001 - name: 1.2.1-beta+gp20 - title: The Colour Picker Update -date: - publish: 2024-08-01T02:22:00.000Z -summary: Brand new colour picker. ---- - -Hey chat! - -Didn't expect us to be so back so quickly, did you? Well, we're back, and we're back with a bang. -This update brings you a brand new colour picker, and it's a doozy. - -## Colour Picker - -At Revolt, we've always been a bit more about customisation than the other guys. And what's more -customisable than colours? This is why we've been shipping Material You support since our very -first internal builds. - -Of course, Material You may not be for everyone. So we've also had a colour override system in place -that lets you customise the colours of the app to your heart's content, as well as import and export -themes into cryptic `RATO` files. - -But there was always one thing missing: a good colour picker. The old one was a bit... well, let's -just say it was a bit lacking. So we've gone ahead and built a brand new colour picker from the -ground up, built to compete with what the iOS team has recently built into their system. - -Of course, team iOS is at an advantage here, because they have a system-wide colour picker. Android -historically hasn't had one, so we had to build our own. Here's our new colour picker: - -![Sliders tab](https://autumn.revolt.chat/attachments/fFmNZinvfdWroWWfLEEC5yF7G3MrIS0x09K2AN6DeT) - -The new colour picker has three tabs: **Sliders**, **Palette**, and **Hex**. The **Sliders** tab -lets you pick colours using HSV sliders, which is especially helpful for picking colours that are -harmonious with each other, which you probably want to do if you're customising the app's colours. - -![Palette tab](https://autumn.revolt.chat/attachments/vQXKvZikyoEBN6fBGLjHj_DKQJ9XN12s_H9-7hMrB4) - -The **Palette** tab lets you pick colours from a predefined palette. This is especially helpful if -you're not sure what colours you want to use, or if you're just looking for a quick colour to use. -Our palette is from the fine folks at [Tailwind CSS](https://tailwindcss.com/) and has been -carefully curated to give you a wide range of colours to choose from. Round of applause for Team -Tailwind! πŸ‘ - -![Hex tab](https://autumn.revolt.chat/attachments/-fA87N8uDyytFTDQ2eEG_22M3n9SyaBsWA_5Z63WwV) - -The **Hex** tab lets you input colours using hex codes. This is especially helpful if you have a -specific colour in mind, or if you're importing a theme from elsewhere. - -We hope you enjoy the new colour picker. We've put a lot of work into it, and we think it's a big -improvement over the old one. - -We'll see you in the next update! \ No newline at end of file diff --git a/docs/src/content/changelogs/1003000.md b/docs/src/content/changelogs/1003000.md deleted file mode 100644 index d659a33e..00000000 --- a/docs/src/content/changelogs/1003000.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -version: - code: 1003000 - name: 1.3.0 - title: Happy New Year - Happy New Features -date: - publish: 2025-02-06T15:00:00.000Z -summary: This is the first early access release. ---- - -Highlights include: - -- **"You" Screen**: The old home screen has been replaced with something far more useful. -- **Support for status text**: Not only can you now set your status text, you can also see the - status text of your friends, so you're always on the same page. -- **Keyboard shortcuts**: We've added support for keyboard shortcuts to make your life easier. For - the two of you that use a physical keyboard on Android, this one's for you. -- **Fixed so so many bugs**: They're all listed in the changelog, check out if your favourite bug - has been fixed. - -For a full list of changes, see -the [complete changelog](https://revolt.chat/updates/android-january2025) on our revamped website! \ No newline at end of file diff --git a/scripts/download_deps.ts b/scripts/download_deps.ts index de14b2e3..3cc98e95 100644 --- a/scripts/download_deps.ts +++ b/scripts/download_deps.ts @@ -333,79 +333,79 @@ for (const dep of deps) { console.log(`Downloaded ${dep.file} to ${file}`) } -const libsQuery = "https://git.revolt.chat/api/v1/repos/android/final-markdown/releases/latest" +// const libsQuery = "https://git.revolt.chat/api/v1/repos/android/final-markdown/releases/latest" -console.log("The script will now query the Revolt version control server for the latest version" - + " of the final-markdown native library and download it. If you do not wish to use pre-built" - + " libraries, you can build final-markdown yourself from the source code at" - + " https://git.revolt.chat/android/final-markdown, and copy the files to the" - + " app/src/main/jniLibs folder. Note the files that are downloaded are exactly the same as" - + " the ones we use for the app on Google Play, so you can be sure they are safe to use.") +// console.log("The script will now query the Revolt version control server for the latest version" +// + " of the final-markdown native library and download it. If you do not wish to use pre-built" +// + " libraries, you can build final-markdown yourself from the source code at" +// + " https://git.revolt.chat/android/final-markdown, and copy the files to the" +// + " app/src/main/jniLibs folder. Note the files that are downloaded are exactly the same as" +// + " the ones we use for the app on Google Play, so you can be sure they are safe to use.") -if (!autoAccept) { - console.log(`Will now query ${libsQuery} for the latest version of the library and download it.`) - if (!confirm("Continue?")) { - console.log("Aborted.") - Deno.exit(0) - } -} else { - console.log(`Will now query ${libsQuery} for the latest version of the library and download it. (auto-accepted)`) -} +// if (!autoAccept) { +// console.log(`Will now query ${libsQuery} for the latest version of the library and download it.`) +// if (!confirm("Continue?")) { +// console.log("Aborted.") +// Deno.exit(0) +// } +// } else { +// console.log(`Will now query ${libsQuery} for the latest version of the library and download it. (auto-accepted)`) +// } -const queryLibsRes = await fetch(libsQuery) -if (!queryLibsRes.ok) { - console.error(`Failed to fetch the latest library version: ${queryLibsRes.status} ${queryLibsRes.statusText}`) - Deno.exit(1) -} +// const queryLibsRes = await fetch(libsQuery) +// if (!queryLibsRes.ok) { +// console.error(`Failed to fetch the latest library version: ${queryLibsRes.status} ${queryLibsRes.statusText}`) +// Deno.exit(1) +// } -const libsJson = await queryLibsRes.json() -const zipUrl = libsJson - .assets - .find((asset: { name: string }) => asset.name === "jniLibs.zip")?.browser_download_url +// const libsJson = await queryLibsRes.json() +// const zipUrl = libsJson +// .assets +// .find((asset: { name: string }) => asset.name === "jniLibs.zip")?.browser_download_url -if (!zipUrl) { - console.error("No jniLibs.zip found in the latest release.") - Deno.exit(1) -} +// if (!zipUrl) { +// console.error("No jniLibs.zip found in the latest release.") +// Deno.exit(1) +// } -const libsRes = await fetch(zipUrl) -if (!libsRes.ok) { - console.error(`Failed to fetch the jniLibs.zip: ${libsRes.status} ${libsRes.statusText}`) - Deno.exit(1) -} -const libsData = await libsRes.arrayBuffer() +// const libsRes = await fetch(zipUrl) +// if (!libsRes.ok) { +// console.error(`Failed to fetch the jniLibs.zip: ${libsRes.status} ${libsRes.statusText}`) +// Deno.exit(1) +// } +// const libsData = await libsRes.arrayBuffer() -const libArchitectures = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] +// const libArchitectures = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] -const zipFileBytes = new Uint8Array(libsData) +// const zipFileBytes = new Uint8Array(libsData) -const zipReader = new ZipReader(new Uint8ArrayReader(zipFileBytes)) +// const zipReader = new ZipReader(new Uint8ArrayReader(zipFileBytes)) -const entries = await zipReader.getEntries() +// const entries = await zipReader.getEntries() -const createDirPromises = libArchitectures.map(arch => { - const dirPath = resolve(jniLibsFolder, arch) - return Deno.mkdir(dirPath, { recursive: true }) -}) -await Promise.all(createDirPromises) +// const createDirPromises = libArchitectures.map(arch => { +// const dirPath = resolve(jniLibsFolder, arch) +// return Deno.mkdir(dirPath, { recursive: true }) +// }) +// await Promise.all(createDirPromises) -const writeFilePromises = libArchitectures.map(async arch => { - const filePathInZip = `${arch}/libfinalmarkdown.so` +// const writeFilePromises = libArchitectures.map(async arch => { +// const filePathInZip = `${arch}/libfinalmarkdown.so` - const entry = entries.find(e => e.filename === filePathInZip) +// const entry = entries.find(e => e.filename === filePathInZip) - if (!entry) { - throw new Error(`Expected file not found in zip: ${filePathInZip}`) - } +// if (!entry) { +// throw new Error(`Expected file not found in zip: ${filePathInZip}`) +// } - const writer = new Uint8ArrayWriter() - const fileData = await entry.getData!(writer) +// const writer = new Uint8ArrayWriter() +// const fileData = await entry.getData!(writer) - const destinationPath = resolve(jniLibsFolder, filePathInZip) - return Deno.writeFile(destinationPath, fileData) -}) +// const destinationPath = resolve(jniLibsFolder, filePathInZip) +// return Deno.writeFile(destinationPath, fileData) +// }) -await Promise.all(writeFilePromises) +// await Promise.all(writeFilePromises) -// Close the zip reader -await zipReader.close() +// // Close the zip reader +// await zipReader.close()