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