diff --git a/app/.gitignore b/app/.gitignore
index 956c004d..77f70dc7 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,2 +1,3 @@
/build
-/release
\ No newline at end of file
+/release
+google-services.json
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 095038eb..a9fa9641 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,6 +11,7 @@ plugins {
id 'kotlin-kapt'
id 'kotlin-parcelize'
+ id 'com.google.gms.google-services'
}
def property(String fileName, String propertyName, String fallbackEnv = null) {
@@ -273,6 +274,10 @@ dependencies {
// Livekit
// FIXME temporarily not included, re-add when realtime media is to be implemented
// implementation "io.livekit:livekit-android:$livekit_version"
+
+ // Firebase - Cloud Messaging
+ implementation(platform("com.google.firebase:firebase-bom:33.1.1"))
+ implementation("com.google.firebase:firebase-messaging")
}
sqldelight {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 502600a2..5e28981b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+
+
+
+
+
+
+
+
EmailPasswordAssessment(
firstUserHints = RevoltJson.decodeFromString(UserHints.serializer(), responseContent)
)
+
"MFA" -> EmailPasswordAssessment(
proceedMfa = true,
mfaSpec = RevoltJson.decodeFromString(MfaLoginSpec.serializer(), responseContent)
)
+
else -> throw Exception("Unknown result: ${responseJson.result}")
}
}
diff --git a/app/src/main/java/chat/revolt/api/routes/push/Push.kt b/app/src/main/java/chat/revolt/api/routes/push/Push.kt
new file mode 100644
index 00000000..77e23856
--- /dev/null
+++ b/app/src/main/java/chat/revolt/api/routes/push/Push.kt
@@ -0,0 +1,25 @@
+package chat.revolt.api.routes.push
+
+import chat.revolt.api.RevoltHttp
+import chat.revolt.api.routes.account.WebPushData
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+
+suspend fun subscribePush(
+ endpoint: String = "fcm",
+ auth: String,
+ p256diffieHellman: String? = null,
+) {
+ val data = WebPushData(
+ endpoint = endpoint,
+ p256diffieHellman = p256diffieHellman ?: "",
+ auth = auth
+ )
+
+ RevoltHttp.post("/push/subscribe") {
+ setBody(data)
+ contentType(ContentType.Application.Json)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/c2dm/ChannelRegistrator.kt b/app/src/main/java/chat/revolt/c2dm/ChannelRegistrator.kt
new file mode 100644
index 00000000..e3751546
--- /dev/null
+++ b/app/src/main/java/chat/revolt/c2dm/ChannelRegistrator.kt
@@ -0,0 +1,64 @@
+package chat.revolt.c2dm
+
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.content.Context
+import androidx.core.content.ContextCompat.getSystemService
+import chat.revolt.R
+
+// TODO
+// * Add the remaining groups.
+// * Add the remaining channels.
+// * Find out whether every conversation should have its own channel or if they should be grouped
+// together as one channel.
+
+class ChannelRegistrator(val context: Context) {
+ companion object {
+ const val CHANNEL_ID_GROUP_CONVERSATIONS = "chat.revolt.c2dm.conversations"
+
+ const val CHANNEL_ID_GROUP_SOCIAL = "chat.revolt.c2dm.social"
+ const val CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS = "chat.revolt.c2dm.social.friendrequests"
+ }
+
+ private val notificationManager =
+ getSystemService(context, NotificationManager::class.java) as NotificationManager
+
+ private fun registerGroups() {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
+
+ notificationManager.createNotificationChannelGroup(
+ NotificationChannelGroup(
+ CHANNEL_ID_GROUP_CONVERSATIONS,
+ context.getString(R.string.notification_channel_group_conversations)
+ )
+ )
+ notificationManager.createNotificationChannelGroup(
+ NotificationChannelGroup(
+ CHANNEL_ID_GROUP_SOCIAL,
+ context.getString(R.string.notification_channel_group_social)
+ )
+ )
+ }
+
+ private fun registerChannels() {
+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
+
+ notificationManager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS,
+ context.getString(R.string.notification_channel_friend_requests),
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ group = CHANNEL_ID_GROUP_SOCIAL
+ description =
+ context.getString(R.string.notification_channel_friend_requests_description)
+ }
+ )
+ }
+
+ fun register() {
+ registerGroups()
+ registerChannels()
+ }
+}
diff --git a/app/src/main/java/chat/revolt/c2dm/HandlerService.kt b/app/src/main/java/chat/revolt/c2dm/HandlerService.kt
new file mode 100644
index 00000000..8f21e026
--- /dev/null
+++ b/app/src/main/java/chat/revolt/c2dm/HandlerService.kt
@@ -0,0 +1,86 @@
+package chat.revolt.c2dm
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.Person
+import androidx.core.app.RemoteInput
+import androidx.core.graphics.drawable.IconCompat
+import chat.revolt.R
+import chat.revolt.activities.MainActivity
+import chat.revolt.api.internals.SpecialUsers
+import chat.revolt.api.internals.ULID
+import chat.revolt.api.routes.push.subscribePush
+import chat.revolt.c2dm.ChannelRegistrator.Companion.CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS
+import com.bumptech.glide.Glide
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import kotlinx.coroutines.runBlocking
+
+class HandlerService : FirebaseMessagingService() {
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+ runBlocking {
+ subscribePush(auth = token)
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ val integ = ULID.asInteger(ULID.makeNext())
+ val bitmap = Glide.with(this)
+ .asBitmap()
+ .load("https://autumn.revolt.chat/avatars/GJXjHUC1X7tGEgHFhYOvtCByuNRd72qFwlztjKZUHP/bfe0842b74ae716139138574e8a1b749751e2968~3.jpg")
+ .circleCrop()
+ .submit()
+ .get()
+ val jen =
+ Person.Builder()
+ .setBot(true)
+ .setKey(SpecialUsers.JENNIFER)
+ .setIcon(IconCompat.createWithBitmap(bitmap))
+ .setName("Jennifer")
+ .build()
+ var remoteInput = RemoteInput.Builder("Reply").run {
+ setLabel("Reply")
+ build()
+ }
+ var action: NotificationCompat.Action =
+ NotificationCompat.Action.Builder(
+ R.drawable.ic_reply_24dp,
+ "Reply", PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_MUTABLE
+ )
+ )
+ .addRemoteInput(remoteInput)
+ .build()
+ var builder = NotificationCompat.Builder(this, CHANNEL_ID_GROUP_SOCIAL_FRIENDREQUESTS)
+ .setSmallIcon(R.drawable.ic_message_text_24dp)
+ .setContentTitle("Jennifer")
+ .setContentText("Expand for details")
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setStyle(
+ NotificationCompat.MessagingStyle(jen)
+ .setConversationTitle("#Genderal")
+ .addMessage("hiii 👋", System.currentTimeMillis(), jen)
+ )
+ .addAction(action)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+
+ NotificationManagerCompat.from(this).apply {
+ if (ActivityCompat.checkSelfPermission(
+ this@HandlerService,
+ android.Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+ notify(integ, builder.build())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt
index 99d55d5b..55d1cb60 100644
--- a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt
+++ b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt
@@ -99,7 +99,7 @@ fun UserAvatar(
) {
if (avatar != null) {
RemoteImage(
- url = rawUrl ?: "$REVOLT_FILES/avatars/${avatar.id!!}/user.png?max_side=256",
+ url = rawUrl ?: "$REVOLT_FILES/avatars/${avatar.id}/user.png?max_side=256",
contentScale = ContentScale.Crop,
description = stringResource(id = R.string.avatar_alt, username),
modifier = Modifier
diff --git a/app/src/main/java/chat/revolt/dialogs/NotificationRationaleDialog.kt b/app/src/main/java/chat/revolt/dialogs/NotificationRationaleDialog.kt
new file mode 100644
index 00000000..cab125df
--- /dev/null
+++ b/app/src/main/java/chat/revolt/dialogs/NotificationRationaleDialog.kt
@@ -0,0 +1,64 @@
+package chat.revolt.dialogs
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import chat.revolt.R
+
+@Composable
+fun NotificationRationaleDialog(
+ onSelected: (Boolean) -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(stringResource(id = R.string.spark_notifications_rationale))
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ux_notification_rationale),
+ contentDescription = null,
+ modifier = Modifier
+ .widthIn(max = 200.dp)
+ .aspectRatio(1f)
+ .align(Alignment.CenterHorizontally)
+ )
+ Text(
+ text = stringResource(id = R.string.spark_notifications_rationale_description)
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ onSelected(false)
+ onDismiss()
+ }) {
+ Text(stringResource(id = R.string.spark_notifications_rationale_dismiss))
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ onSelected(true)
+ onDismiss()
+ }) {
+ Text(stringResource(id = R.string.spark_notifications_rationale_cta))
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/screens/settings/DebugSettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/DebugSettingsScreen.kt
index 3ffd6dfc..5cada2bc 100644
--- a/app/src/main/java/chat/revolt/screens/settings/DebugSettingsScreen.kt
+++ b/app/src/main/java/chat/revolt/screens/settings/DebugSettingsScreen.kt
@@ -1,5 +1,10 @@
package chat.revolt.screens.settings
+import android.os.Build
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -8,9 +13,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -24,10 +31,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -35,9 +47,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
+import chat.revolt.api.routes.push.subscribePush
+import chat.revolt.dialogs.NotificationRationaleDialog
import chat.revolt.persistence.Database
import chat.revolt.persistence.KVStorage
import chat.revolt.persistence.SqlStorage
+import chat.revolt.ui.theme.FragmentMono
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailabilityLight
+import com.google.android.gms.tasks.OnCompleteListener
+import com.google.firebase.messaging.FirebaseMessaging
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -72,8 +92,85 @@ fun DebugSettingsScreen(
navController: NavController,
viewModel: DebugSettingsScreenViewModel = hiltViewModel()
) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ var showNotificationsRationaleDialogue by remember { mutableStateOf(false) }
+ val askNotificationsPermission =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener(
+ OnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Log.e(
+ "DebugSettingsScreen",
+ "Fetching FCM registration token failed",
+ task.exception
+ )
+ return@OnCompleteListener
+ }
+
+ scope.launch {
+ subscribePush(auth = task.result)
+ }
+ })
+ }
+ }
+ var showC2dmDataDialogue by remember { mutableStateOf(false) }
+ var fcmToken by remember { mutableStateOf("") }
+ var playServicesAvailable by remember { mutableStateOf(false) }
+
+ if (showNotificationsRationaleDialogue) {
+ NotificationRationaleDialog(
+ onSelected = { accepted ->
+ if (accepted) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ askNotificationsPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ Toast.makeText(
+ context,
+ "Will not ask for permission on pre-Tiramisu devices!",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ },
+ onDismiss = {
+ showNotificationsRationaleDialogue = false
+ }
+ )
+ }
+
+ if (showC2dmDataDialogue) {
+ AlertDialog(
+ onDismissRequest = {
+ showC2dmDataDialogue = false
+ },
+ title = { Text("Notification Properties") },
+ text = {
+ Column {
+ Text("FCM token", style = MaterialTheme.typography.headlineSmall)
+ SelectionContainer {
+ Text(fcmToken, fontFamily = FragmentMono)
+ }
+ Text(
+ "Google Play Services available",
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(if (playServicesAvailable) "Yes" else "No")
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ showC2dmDataDialogue = false
+ }) {
+ Text("OK")
+ }
+ })
+ }
+
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -126,6 +223,42 @@ fun DebugSettingsScreen(
}
}
+ Text(
+ text = "Notifications",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(bottom = 10.dp)
+ )
+ Row(
+ modifier = Modifier.horizontalScroll(rememberScrollState())
+ ) {
+ ElevatedButton(onClick = { showNotificationsRationaleDialogue = true }) {
+ Text("Show notification rationale dialog")
+ }
+
+ ElevatedButton(onClick = {
+ playServicesAvailable = GoogleApiAvailabilityLight.getInstance()
+ .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
+ FirebaseMessaging.getInstance().token.addOnCompleteListener(
+ OnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Log.e(
+ "DebugSettingsScreen",
+ "Fetching FCM registration token failed",
+ task.exception
+ )
+ fcmToken = "Fetching FCM registration token failed!"
+ showC2dmDataDialogue = true
+ return@OnCompleteListener
+ }
+
+ fcmToken = task.result
+ showC2dmDataDialogue = true
+ })
+ }) {
+ Text("Show Notification Properties")
+ }
+ }
+
Text(
text = "Changelogs",
style = MaterialTheme.typography.headlineSmall,
@@ -154,7 +287,7 @@ fun DebugSettingsScreen(
Text(
text = viewModel.serverQueries[index].toString(),
style = LocalTextStyle.current.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = FragmentMono
)
)
HorizontalDivider(Modifier.padding(vertical = 10.dp))
@@ -169,7 +302,7 @@ fun DebugSettingsScreen(
Text(
text = viewModel.channelQueries[index].toString(),
style = LocalTextStyle.current.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = FragmentMono
)
)
HorizontalDivider(Modifier.padding(vertical = 10.dp))
diff --git a/app/src/main/res/drawable/ic_notification_monochrome.xml b/app/src/main/res/drawable/ic_notification_monochrome.xml
new file mode 100644
index 00000000..4eb5c682
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_monochrome.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ux_notification_rationale.xml b/app/src/main/res/drawable/ux_notification_rationale.xml
new file mode 100644
index 00000000..e85a32e1
--- /dev/null
+++ b/app/src/main/res/drawable/ux_notification_rationale.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c8463a..91c4e4f7 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,6 +1,6 @@
- #FFDA4E5B
+ #FFFE4654
#FF131722
#FFFFFFFF
#FF000000
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 703958c5..628e153a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -491,6 +491,11 @@
Then long tap your profile picture to open the settings.
Got it
+ Stay in the loop
+ Enable notifications to be kept up to date with messages and mentions.
+ Enable notifications
+ Not now
+
Important notice regarding your account
You have received an important notice regarding your account from our moderation team. Please read it carefully.
View
@@ -611,4 +616,9 @@
This attachment is too large for Revolt (max. $1$s).
Search channels
m select a channel to share to.
+
+ Conversations
+ Friends and Social
+ Friend Requests
+ Incoming friend requests, as well as accepted requests you\'ve sent.
diff --git a/build.gradle b/build.gradle
index ac84d0c2..8ffb68cb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -26,6 +26,7 @@ plugins {
id 'com.mikepenz.aboutlibraries.plugin' version "10.9.1" apply false
id 'com.google.devtools.ksp' version '1.9.22-1.0.17' apply false
id 'org.jmailen.kotlinter' version "4.0.0" apply false
+ id("com.google.gms.google-services") version "4.4.2" apply false
}
tasks.register('clean', Delete) {