feat: c2dm prototype via fcm

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-07-12 15:51:29 +02:00
parent cbd182df34
commit ff1fc39475
17 changed files with 475 additions and 10 deletions

3
app/.gitignore vendored
View File

@ -1,2 +1,3 @@
/build
/release
/release
google-services.json

View File

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

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Up to Android 10, we need the following to take photos from the camera. -->
<uses-permission
@ -49,6 +50,21 @@
android:name="io.sentry.auto-init"
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification_monochrome" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/primary" />
<service
android:name=".c2dm.HandlerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity
android:name=".activities.MainActivity"
android:exported="true"

View File

@ -145,4 +145,29 @@ object ULID {
return timestamp
}
/** Like asTimestamp, but for the entire ULID including the entropy, as an integer.
Note that the number you receive will not be reversible to the original ULID, because
it will overflow the `Int` type! Used for generating Android notification IDs.
*/
fun asInteger(ulid: String): Int {
if (ulid.length != len) {
throw IllegalArgumentException("ULID must be exactly $len characters")
}
var integer = 0
for (i in 0 until len) {
val char = ulid[i]
val value = b32chars.indexOf(char)
if (value == -1) {
throw IllegalArgumentException("Invalid character '$char' at position $i")
}
integer = integer or (value shl (len - i - 1) * 5)
}
return integer
}
}

View File

@ -2,6 +2,7 @@ package chat.revolt.api.realtime
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.revolt.RevoltApplication
import chat.revolt.api.REVOLT_WEBSOCKET
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
@ -37,6 +38,7 @@ import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.c2dm.ChannelRegistrator
import chat.revolt.persistence.Database
import chat.revolt.persistence.SqlStorage
import io.ktor.client.plugins.websocket.ws
@ -63,6 +65,8 @@ object RealtimeSocket {
val database = Database(SqlStorage.driver)
var socket: WebSocketSession? = null
private val channelRegistrator = ChannelRegistrator(RevoltApplication.instance)
private var _disconnectionState = mutableStateOf(DisconnectionState.Reconnecting)
val disconnectionState: DisconnectionState
get() = _disconnectionState.value
@ -201,6 +205,9 @@ object RealtimeSocket {
val emojiMap = readyFrame.emojis.associateBy { it.id!! }
RevoltAPI.emojiCache.putAll(emojiMap)
Log.d("RealtimeSocket", "Registering push notification channels.")
channelRegistrator.register()
RevoltAPI.closeHydration()
}

View File

@ -5,10 +5,16 @@ import android.util.Log
import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.*
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
@Serializable
data class LoginNegotiation(
@ -132,10 +138,12 @@ suspend fun negotiateAuthentication(email: String, password: String): EmailPassw
"Success" -> EmailPasswordAssessment(
firstUserHints = RevoltJson.decodeFromString(UserHints.serializer(), responseContent)
)
"MFA" -> EmailPasswordAssessment(
proceedMfa = true,
mfaSpec = RevoltJson.decodeFromString(MfaLoginSpec.serializer(), responseContent)
)
else -> throw Exception("Unknown result: ${responseJson.result}")
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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())
}
}
}

View File

@ -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

View File

@ -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))
}
}
)
}

View File

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

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="500"
android:viewportHeight="500">
<path
android:pathData="M122.59,447C122.59,326.35 122.59,247.26 122.59,144.56L46,53H293.71C323.14,53 348.89,58.32 370.95,68.97C393.02,79.61 410.19,94.94 422.45,114.95C434.71,134.96 440.84,158.94 440.84,186.9C440.84,215.12 434.51,238.91 421.87,258.27C409.35,277.64 391.73,292.26 369.02,302.14C346.43,312.01 320.04,316.95 289.84,316.95H187.63V233.84H268.16C280.81,233.84 291.59,232.3 300.49,229.22C309.52,226.02 316.43,220.95 321.2,214.02C326.11,207.1 328.56,198.06 328.56,186.9C328.56,175.61 326.11,166.44 321.2,159.39C316.43,152.21 309.52,146.95 300.49,143.61C291.59,140.15 280.81,138.42 268.16,138.42H230.22V447H122.59ZM354.89,266.16L454,447H337.08L240.29,266.16H354.89Z"
android:fillColor="@color/primary"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="512dp" android:viewportHeight="132.29" android:viewportWidth="132.29" android:width="512dp">
<path android:fillColor="?attr/colorSecondary" android:pathData="m103.09,21.16 l2.77,11.75c0.34,1.44 -0.56,2.89 -2,3.23l-18.27,4.31 -3.99,6.45 -5.54,-23.5c-0.34,-1.44 0.56,-2.89 2,-3.23l13.18,-3.11c-0.04,0.42 0.08,0.94 0.18,1.34 0.86,3.65 4.41,5.85 8.07,4.99 1.44,-0.34 2.75,-1.2 3.61,-2.23m-9.06,-3.37c0.52,2.22 2.62,3.52 4.84,2.99 2.22,-0.52 3.52,-2.62 2.99,-4.84 -0.52,-2.22 -2.62,-3.52 -4.84,-2.99 -2.22,0.52 -3.52,2.62 -2.99,4.84z" android:strokeWidth="1.34108"/>
<path android:fillColor="?attr/colorPrimary" android:pathData="m56.15,88.62v2.46H11.96v-2.46l4.91,-4.91V68.98c0,-7.61 4.98,-14.31 12.28,-16.47 0,-0.25 0,-0.47 0,-0.71a4.91,4.91 0,0 1,4.91 -4.91,4.91 4.91,0 0,1 4.91,4.91c0,0.25 0,0.47 0,0.71 7.29,2.16 12.28,8.86 12.28,16.47V83.71l4.91,4.91m-17.19,4.91a4.91,4.91 0,0 1,-4.91 4.91,4.91 4.91,0 0,1 -4.91,-4.91m23.94,-43.72 l-3.49,3.49c4.2,4.15 6.55,9.8 6.55,15.69h4.91c0,-7.19 -2.85,-14.12 -7.98,-19.17M7.05,68.98h4.91c0,-5.89 2.36,-11.54 6.55,-15.69L15.02,49.81C9.89,54.87 7.05,61.79 7.05,68.98Z" android:strokeColor="#00000000" android:strokeWidth="2.45499"/>
<path android:fillColor="?attr/colorTertiary" android:pathData="m117.49,66.53c-1.27,-0.59 -2.83,-0.03 -3.42,1.24 -0.59,1.27 -0.03,2.83 1.24,3.42 1.27,0.59 2.83,0.03 3.42,-1.24 0.59,-1.27 0.03,-2.83 -1.24,-3.42m-5.09,1.68 l-6.13,-2.88c-0.73,-0.34 -1.05,-1.22 -0.71,-1.96l4.38,-9.32c0.35,-0.74 1.22,-1.05 1.96,-0.71l0.67,0.31 0.63,-1.33 1.33,0.63 -0.63,1.33 5.33,2.5 0.63,-1.33 1.33,0.63 -0.63,1.33 0.67,0.31c0.73,0.34 1.05,1.22 0.71,1.96l-2.88,6.13c-0.29,-0.24 -0.6,-0.45 -0.93,-0.61 -0.11,-0.05 -0.22,-0.1 -0.34,-0.14l1.88,-4.02 -9.32,-4.38 -3.44,7.32 6.01,2.82c-0.07,0.1 -0.12,0.21 -0.18,0.32 -0.16,0.33 -0.26,0.69 -0.33,1.06z" android:strokeWidth="0.735562"/>
<path android:fillColor="?attr/colorError" android:pathData="m112.36,116.79 l-0.45,3.03 -18.17,-2.73 0.45,-3.03c0,0 0.91,-6.06 9.99,-4.69 9.08,1.36 8.17,7.42 8.17,7.42m-2.5,-14.31a4.59,4.59 8.54,1 0,-5.22 3.86,4.59 4.59,8.54 0,0 5.22,-3.86m3.69,8.39a8.57,8.57 0,0 1,1.83 6.37l-0.45,3.03 4.54,0.68 0.45,-3.03c0,0 0.78,-5.22 -6.37,-7.06m0.01,-12.47a4.45,4.45 0,0 0,-1.38 0.01,7.65 7.65,0 0,1 -1.3,8.66 4.45,4.45 0,0 0,1.32 0.41,4.59 4.59,8.54 0,0 1.36,-9.08m-16.27,5.3 l-4.54,-0.68 0.68,-4.54 -3.03,-0.45 -0.68,4.54 -4.54,-0.68 -0.45,3.03 4.54,0.68 -0.68,4.54 3.03,0.45 0.68,-4.54 4.54,0.68z" android:strokeWidth="1.53082"/>
</vector>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FFDA4E5B</color>
<color name="primary">#FFFE4654</color>
<color name="background">#FF131722</color>
<color name="splashscreen_background">#FFFFFFFF</color>
<color name="foreground">#FF000000</color>

View File

@ -491,6 +491,11 @@
<string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string>
<string name="spark_sidebar_settings_tutorial_acknowledge">Got it</string>
<string name="spark_notifications_rationale">Stay in the loop</string>
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</string>
<string name="spark_notifications_rationale_dismiss">Not now</string>
<string name="notice_platform_mod_dm_title">Important notice regarding your account</string>
<string name="notice_platform_mod_dm_description">You have received an important notice regarding your account from our moderation team. Please read it carefully.</string>
<string name="notice_platform_mod_dm_acknowledge">View</string>
@ -611,4 +616,9 @@
<string name="share_target_attachment_too_large">This attachment is too large for Revolt (max. $1$s).</string>
<string name="share_target_search_channels">Search channels</string>
<string name="share_target_select_channel">m select a channel to share to.</string>
<string name="notification_channel_group_conversations">Conversations</string>
<string name="notification_channel_group_social">Friends and Social</string>
<string name="notification_channel_friend_requests">Friend Requests</string>
<string name="notification_channel_friend_requests_description">Incoming friend requests, as well as accepted requests you\'ve sent.</string>
</resources>

View File

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