feat: push notifications with everything around
This commit is contained in:
parent
1e2ef35df0
commit
2b84cbb342
|
|
@ -66,6 +66,10 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".c2dm.ReplyReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import chat.stoat.R
|
||||||
import chat.stoat.StoatApplication
|
import chat.stoat.StoatApplication
|
||||||
import chat.stoat.api.HitRateLimitException
|
import chat.stoat.api.HitRateLimitException
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
|
import chat.stoat.c2dm.NotificationDeepLink
|
||||||
import chat.stoat.api.StoatHttp
|
import chat.stoat.api.StoatHttp
|
||||||
import chat.stoat.api.api
|
import chat.stoat.api.api
|
||||||
import chat.stoat.api.routes.microservices.geo.queryGeo
|
import chat.stoat.api.routes.microservices.geo.queryGeo
|
||||||
|
|
@ -354,6 +355,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
StoatAPI.hydrateFromPersistentCache()
|
StoatAPI.hydrateFromPersistentCache()
|
||||||
|
|
||||||
|
intent.getStringExtra("channelId")?.let { NotificationDeepLink.pendingChannelId.value = it }
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val windowSizeClass = calculateWindowSizeClass(this)
|
val windowSizeClass = calculateWindowSizeClass(this)
|
||||||
AppEntrypoint(
|
AppEntrypoint(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
@ -17,22 +16,19 @@ import androidx.core.graphics.drawable.IconCompat
|
||||||
import chat.stoat.BuildConfig
|
import chat.stoat.BuildConfig
|
||||||
import chat.stoat.R
|
import chat.stoat.R
|
||||||
import chat.stoat.activities.MainActivity
|
import chat.stoat.activities.MainActivity
|
||||||
import chat.stoat.api.StoatJson
|
|
||||||
import chat.stoat.api.internals.ULID
|
import chat.stoat.api.internals.ULID
|
||||||
|
import chat.stoat.api.routes.channel.fetchSingleChannel
|
||||||
import chat.stoat.api.routes.push.subscribePush
|
import chat.stoat.api.routes.push.subscribePush
|
||||||
import chat.stoat.c2dm.ChannelRegistrator.Companion.CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES
|
import chat.stoat.c2dm.ChannelRegistrator.Companion.CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES
|
||||||
import chat.stoat.core.model.data.STOAT_BASE
|
import chat.stoat.core.model.schemas.ChannelType
|
||||||
import chat.stoat.core.model.schemas.Message
|
|
||||||
import chat.stoat.core.model.schemas.User
|
|
||||||
import chat.stoat.persistence.Database
|
import chat.stoat.persistence.Database
|
||||||
import chat.stoat.persistence.SqlStorage
|
import chat.stoat.persistence.SqlStorage
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import logcat.LogPriority
|
||||||
import kotlinx.serialization.json.jsonObject
|
import logcat.logcat
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
object NotificationID {
|
object NotificationID {
|
||||||
const val NEW_MESSAGE = 0
|
const val NEW_MESSAGE = 0
|
||||||
|
|
@ -47,96 +43,82 @@ class HandlerService : FirebaseMessagingService() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageReceived(fcmMessage: RemoteMessage) {
|
override fun onMessageReceived(fcmMessage: RemoteMessage) {
|
||||||
/// TEMPORARY CODE, SCHEMA TO BE REPLACED
|
val data = fcmMessage.data
|
||||||
val payloadString = fcmMessage.data["payload"]
|
|
||||||
if (payloadString == null) {
|
val type = data["type"]
|
||||||
Log.e("HandlerService", "No payload in message, abort")
|
if (type != "push.message") {
|
||||||
|
logcat(LogPriority.ERROR) { "Unknown message type: $type, abort" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("HandlerService", payloadString)
|
val authorId = data["author"] ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No author in message, abort" }
|
||||||
val payload = StoatJson.parseToJsonElement(payloadString).jsonObject
|
|
||||||
val keys = payload.keys.toList().toString()
|
|
||||||
Log.d("HandlerService", "following keys: $keys")
|
|
||||||
|
|
||||||
var authorIcon = payload["icon"]?.jsonPrimitive?.contentOrNull
|
|
||||||
val message = payload["message"]?.jsonObject?.let {
|
|
||||||
StoatJson.decodeFromJsonElement(
|
|
||||||
Message.serializer(),
|
|
||||||
it
|
|
||||||
)
|
|
||||||
} ?: run {
|
|
||||||
Log.e("HandlerService", "No message in payload, abort")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = payload["message"]?.jsonObject?.get("user")?.jsonObject?.let {
|
val body = data["body"] ?: run {
|
||||||
StoatJson.decodeFromJsonElement(
|
logcat(LogPriority.ERROR) { "No body in message, abort" }
|
||||||
User.serializer(),
|
|
||||||
it
|
|
||||||
)
|
|
||||||
} ?: run {
|
|
||||||
Log.e("HandlerService", "No message->user in payload, abort")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorIcon == null) {
|
val image = data["image"] ?: run {
|
||||||
authorIcon =
|
logcat(LogPriority.WARN) { "No image in message, abort" }
|
||||||
"$STOAT_BASE/users/${message.author?.ifBlank { "0".repeat(26) }}/default_avatar"
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val authorName = data["author_name"] ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No author name in message, abort" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val channelId = data["channel"] ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No channel in message, abort" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = data["message"] ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No message ID in message, abort" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageTimestamp = ULID.asTimestamp(messageId)
|
||||||
|
|
||||||
val db = Database(SqlStorage.driver)
|
val db = Database(SqlStorage.driver)
|
||||||
val channelName = message.channel?.let {
|
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
|
||||||
db.channelQueries.findById(it).executeAsOneOrNull()
|
|
||||||
}?.let {
|
|
||||||
when (it.channelType) {
|
when (it.channelType) {
|
||||||
"DirectMessage" -> {
|
"DirectMessage" -> authorName
|
||||||
user.displayName ?: user.username
|
"TextChannel" -> "#${it.name}"
|
||||||
}
|
else -> it.name ?: authorName
|
||||||
|
|
||||||
"TextChannel" -> {
|
|
||||||
"#${it.name}"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
it.name ?: getString(R.string.unknown)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} ?: getString(
|
} ?: runBlocking {
|
||||||
R.string.unknown
|
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
|
||||||
)
|
when (it.channelType) {
|
||||||
|
ChannelType.DirectMessage -> authorName
|
||||||
val messageTimestamp = message.id?.let { ULID.asTimestamp(it) } ?: run {
|
ChannelType.TextChannel -> "#${it.name}"
|
||||||
Log.e("HandlerService", "No message id in message, abort")
|
else -> it.name ?: authorName
|
||||||
return
|
}
|
||||||
|
} ?: authorName
|
||||||
}
|
}
|
||||||
|
|
||||||
val bitmap = Glide.with(this)
|
val bitmap = Glide.with(this)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(authorIcon)
|
.load(image)
|
||||||
.circleCrop()
|
.circleCrop()
|
||||||
.submit()
|
.submit()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
val author =
|
val author = Person.Builder()
|
||||||
Person.Builder()
|
.setBot(false)
|
||||||
.setBot(user.bot != null)
|
.setKey(authorId)
|
||||||
.setKey(message.author)
|
.setIcon(IconCompat.createWithBitmap(bitmap))
|
||||||
.setIcon(IconCompat.createWithBitmap(bitmap))
|
.setName(authorName)
|
||||||
.setName(user.displayName ?: user.username)
|
.build()
|
||||||
.build()
|
|
||||||
|
|
||||||
if (message.channel == null) {
|
val shortcutId = "${BuildConfig.APPLICATION_ID}.channel.$channelId"
|
||||||
Log.e("HandlerService", "No channel in message, abort")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val shortcutId = "${BuildConfig.APPLICATION_ID}.channel.${message.channel}"
|
|
||||||
|
|
||||||
val conversationIntent = Intent(this, MainActivity::class.java).apply {
|
val conversationIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
action = Intent.ACTION_VIEW
|
action = Intent.ACTION_VIEW
|
||||||
putExtra("channelId", message.channel)
|
putExtra("channelId", channelId)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,14 +138,18 @@ class HandlerService : FirebaseMessagingService() {
|
||||||
build()
|
build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val replyIntent = Intent(this, ReplyReceiver::class.java).apply {
|
||||||
|
putExtra("channelId", channelId)
|
||||||
|
}
|
||||||
|
|
||||||
val action: NotificationCompat.Action =
|
val action: NotificationCompat.Action =
|
||||||
NotificationCompat.Action.Builder(
|
NotificationCompat.Action.Builder(
|
||||||
R.drawable.ic_reply_24dp,
|
R.drawable.ic_reply_24dp,
|
||||||
getString(R.string.message_context_sheet_actions_reply),
|
getString(R.string.message_context_sheet_actions_reply),
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getBroadcast(
|
||||||
this,
|
this,
|
||||||
0,
|
channelId.hashCode(),
|
||||||
conversationIntent,
|
replyIntent,
|
||||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -172,25 +158,21 @@ class HandlerService : FirebaseMessagingService() {
|
||||||
|
|
||||||
val contentIntent = PendingIntent.getActivity(
|
val contentIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
message.channel.hashCode(),
|
channelId.hashCode(),
|
||||||
conversationIntent,
|
conversationIntent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES)
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID_GROUP_CONVERSATIONS_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_chat_24dp)
|
.setSmallIcon(R.drawable.ic_stoat_24dp)
|
||||||
.setContentTitle(user.displayName ?: user.username)
|
.setContentTitle(authorName)
|
||||||
.setContentText(message.content)
|
.setContentText(body)
|
||||||
.setContentIntent(contentIntent)
|
.setContentIntent(contentIntent)
|
||||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
NotificationCompat.MessagingStyle(author)
|
NotificationCompat.MessagingStyle(author)
|
||||||
.setConversationTitle(channelName)
|
.setConversationTitle(channelName)
|
||||||
.addMessage(
|
.addMessage(body, messageTimestamp, author)
|
||||||
message.content ?: getString(R.string.reply_message_empty_has_attachments),
|
|
||||||
messageTimestamp,
|
|
||||||
author
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.addAction(action)
|
.addAction(action)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
|
@ -203,7 +185,7 @@ class HandlerService : FirebaseMessagingService() {
|
||||||
|
|
||||||
val bubbleIntent = PendingIntent.getActivity(
|
val bubbleIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
message.channel.hashCode(),
|
channelId.hashCode(),
|
||||||
conversationIntent,
|
conversationIntent,
|
||||||
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
|
|
@ -228,8 +210,7 @@ class HandlerService : FirebaseMessagingService() {
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notify(message.channel, NotificationID.NEW_MESSAGE, builder.build())
|
notify(channelId, NotificationID.NEW_MESSAGE, builder.build())
|
||||||
}
|
}
|
||||||
/// END TEMPORARY CODE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package chat.stoat.c2dm
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
object NotificationDeepLink {
|
||||||
|
val pendingChannelId = MutableStateFlow<String?>(null)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package chat.stoat.c2dm
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import chat.stoat.api.StoatAPI
|
||||||
|
import chat.stoat.api.routes.channel.sendMessage
|
||||||
|
import chat.stoat.persistence.KVStorage
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import logcat.LogPriority
|
||||||
|
import logcat.asLog
|
||||||
|
import logcat.logcat
|
||||||
|
|
||||||
|
class ReplyReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val channelId = intent.getStringExtra("channelId") ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No channel ID, aborting" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = RemoteInput.getResultsFromIntent(intent)
|
||||||
|
?.getCharSequence("content")
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No content, aborting" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val token = KVStorage(context).get("sessionToken") ?: run {
|
||||||
|
logcat(LogPriority.ERROR) { "No session token, aborting" }
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
StoatAPI.setSessionHeader(token)
|
||||||
|
runCatching { sendMessage(channelId, content) }
|
||||||
|
.onFailure { logcat(LogPriority.ERROR) { "Send failed: ${it.asLog()}" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(channelId, NotificationID.NEW_MESSAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ import chat.stoat.composables.chat.DisconnectedNotice
|
||||||
import chat.stoat.composables.screens.chat.drawer.ChannelSideDrawer
|
import chat.stoat.composables.screens.chat.drawer.ChannelSideDrawer
|
||||||
import chat.stoat.core.model.schemas.ReleaseNotesSettings
|
import chat.stoat.core.model.schemas.ReleaseNotesSettings
|
||||||
import chat.stoat.dialogs.NotificationRationaleDialog
|
import chat.stoat.dialogs.NotificationRationaleDialog
|
||||||
|
import chat.stoat.c2dm.NotificationDeepLink
|
||||||
import chat.stoat.internals.extensions.zero
|
import chat.stoat.internals.extensions.zero
|
||||||
import chat.stoat.persistence.KVStorage
|
import chat.stoat.persistence.KVStorage
|
||||||
import chat.stoat.screens.chat.dialogs.safety.ReportMessageDialog
|
import chat.stoat.screens.chat.dialogs.safety.ReportMessageDialog
|
||||||
|
|
@ -161,8 +162,14 @@ class ChatRouterViewModel(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = kvStorage.get("currentDestination")
|
val pendingChannel = NotificationDeepLink.pendingChannelId.value
|
||||||
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
|
if (pendingChannel != null) {
|
||||||
|
NotificationDeepLink.pendingChannelId.value = null
|
||||||
|
setSaveDestination(ChatRouterDestination.Channel(pendingChannel))
|
||||||
|
} else {
|
||||||
|
val current = kvStorage.get("currentDestination")
|
||||||
|
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
|
||||||
|
}
|
||||||
|
|
||||||
val seenEarlyAccess = kvStorage.getBoolean("spark/earlyAccess/dismissed")
|
val seenEarlyAccess = kvStorage.getBoolean("spark/earlyAccess/dismissed")
|
||||||
val seenSwipeToReply = kvStorage.getBoolean("spark/swipeToReply/dismissed")
|
val seenSwipeToReply = kvStorage.getBoolean("spark/swipeToReply/dismissed")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M3.822,20.779C1.471,18.587 0,15.464 0,12C0,5.377 5.377,0 12,0C18.623,0 24,5.377 24,12C24,17.512 20.276,22.161 15.209,23.566C14.084,21.913 13.065,20.408 13.827,17.931C12.897,18.405 12.313,19.339 12.136,20.353C11.79,19.81 11.406,19.285 10.961,18.818C9.828,17.628 8.284,16.797 7.569,15.252C7.243,14.523 7.019,13.48 7.278,12.725C7.464,14.19 8.177,15.538 9.454,16.319C10.427,16.913 11.517,17.012 12.609,17.242C13.747,17.482 15.554,18.022 16.663,17.827C17.584,17.665 19.518,16.647 19.466,15.543C19.448,15.163 18.985,14.523 18.813,14.156C18.585,13.668 18.72,13.563 18.746,13.073C18.82,11.688 18.454,11.003 17.781,9.862C16.965,8.478 16.069,7.642 14.517,7.08C13.1,6.567 10.918,6.858 10.901,6.881C11.188,7.195 11.451,7.563 11.488,7.998C10.907,7.461 10.345,6.889 9.694,6.434C8.255,5.426 7.353,5.521 6.781,7.274C6.483,8.187 6.257,9.428 6.534,10.362C6.701,10.928 7.049,11.388 7.446,11.813C6.67,11.707 6.2,11.033 6.029,10.303C4.107,14.461 3.679,16.472 3.822,20.779ZM18.082,16.797L18.081,16.797C17.783,16.806 17.508,16.647 17.282,16.466C17.156,16.358 16.969,16.219 16.943,16.058C16.892,15.737 17.147,15.507 17.3,15.412C17.43,15.331 17.589,15.285 17.739,15.251C18.081,15.172 18.435,15.099 18.78,15.165C18.883,15.185 18.985,15.219 19.07,15.281C19.154,15.342 19.22,15.435 19.232,15.54C19.24,15.617 19.219,15.694 19.193,15.768C19.134,15.936 19.052,16.096 18.949,16.243C18.847,16.389 18.726,16.523 18.579,16.624C18.433,16.725 18.26,16.792 18.082,16.797ZM12.982,12.083C14.162,11.923 14.306,13.751 13.167,13.827C12.029,13.904 11.901,12.229 12.982,12.083ZM9.006,8.454C9.115,8.714 9.339,8.887 9.533,9.078C9.093,9.211 8.648,9.084 8.262,8.862C8.285,9.271 8.587,9.631 8.813,9.918C8.44,10.003 8.11,9.875 7.854,9.606C7.85,9.985 7.98,10.316 8.166,10.637C7.438,10.999 7.124,10.238 7.086,9.642C7.046,8.987 7.311,7.334 7.781,6.858C8.307,6.325 9.123,6.946 9.568,7.303C9.961,7.619 10.34,8.001 10.696,8.356C10.228,8.665 9.532,8.652 9.006,8.454ZM18.153,13.226C17.896,12.779 17.817,12.649 17.668,12.325C17.566,12.104 17.514,11.842 17.883,11.834C18.553,11.82 18.785,12.881 18.264,13.251C18.251,13.26 18.235,13.265 18.22,13.265C18.192,13.265 18.167,13.25 18.153,13.226ZM17.713,8.956C17.903,6.344 17.881,4.654 15.459,7.025C16.373,7.45 17.142,8.129 17.713,8.956Z"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
Loading…
Reference in New Issue