Skip to content

Commit

Permalink
Migrate from legacy FCM API (#12)
Browse files Browse the repository at this point in the history
* Migrate from legacy FCM API

* remove unused dependency
  • Loading branch information
oyakovlev authored Feb 8, 2024
1 parent 9af7ab2 commit c068d35
Show file tree
Hide file tree
Showing 13 changed files with 154 additions and 298 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ repositories {
}

// Append dependency
implementation("com.icerockdev.service:fcm-push-service:1.0.0")
implementation("com.icerockdev.service:fcm-push-service:2.0.0")
````

## Koin configure
Expand All @@ -22,7 +22,7 @@ single {
PushService(
coroutineScope = fcmScope,
config = FCMConfig(
serverKey = appConf.getString("fcm.serverKey")
googleServiceAccountJson = appConf.getString("fcm.googleServiceAccountJson")
),
pushRepository = object : IPushRepository {
override fun deleteByTokenList(tokenList: List<String>): Int {
Expand Down
13 changes: 3 additions & 10 deletions fcm-push-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import kotlin.text.String
*/

group = "com.icerockdev.service"
version = "1.0.0"
version = "2.0.0"

plugins {
id("org.jetbrains.kotlin.jvm")
Expand All @@ -25,16 +25,9 @@ val sourcesJar by tasks.registering(Jar::class) {
dependencies {
// logging
implementation("ch.qos.logback:logback-classic:${properties["logback_version"]}")
implementation("io.ktor:ktor-client-apache:${properties["ktor_version"]}")
api("io.ktor:ktor-client-logging:${properties["ktor_version"]}")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${properties["coroutines_version"]}")

implementation("io.ktor:ktor-client-content-negotiation:${properties["ktor_version"]}")
implementation("io.ktor:ktor-serialization-jackson:${properties["ktor_version"]}")

// tests
testImplementation("io.ktor:ktor-server-tests:${properties["ktor_version"]}")
testImplementation("io.ktor:ktor-client-mock:${properties["ktor_version"]}")
testImplementation(kotlin("test"))
implementation("com.google.firebase:firebase-admin:${properties["firebase_admin_sdk_version"]}")
}

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@
package com.icerockdev.service.fcmpush

data class FCMConfig(
val serverKey: String = "",
val apiUrl: String = "https://fcm.googleapis.com/fcm/send"
)
val googleServiceAccountJson: String
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ package com.icerockdev.service.fcmpush

data class FCMPayLoad(
val tokenList: List<String>,
/**
* @see <a href="https://firebase.google.com/docs/cloud-messaging/send-message#send-messages-to-topics">link</a>
*/
val condition: String? = null,
val notificationObject: NotificationData? = null,
val dataObject: Map<String, String>? = null,
/**
* @see <a href="https://firebase.google.com/docs/cloud-messaging/send-message#send-messages-to-topics">link</a>
*/
val topic: String? = null,
val priority: PushPriority = PushPriority.NORMAL
)
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@

package com.icerockdev.service.fcmpush

import com.fasterxml.jackson.annotation.JsonProperty

/**
* @see <a href="https://firebase.google.com/docs/cloud-messaging/http-server-ref#notification-payload-support">link</a>
* @see <a href="https://firebase.google.com/docs/cloud-messaging/send-message">link</a>
*/
data class NotificationData(
/**
Expand All @@ -25,8 +23,8 @@ data class NotificationData(
/**
* android/ios: what should happen upon notification click. when empty on android the default activity
* will be launched passing any payload to an intent.
* on android: intent name, on ios: category in apns payload
*/
@JsonProperty("click_action")
val clickAction: String? = null,
/**
* iOS only: will add small red bubbles indicating the number of notifications to your apps icon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,20 @@

package com.icerockdev.service.fcmpush

import com.fasterxml.jackson.annotation.JsonInclude
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.apache.Apache
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.jackson.jackson
import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.messaging.AndroidConfig
import com.google.firebase.messaging.AndroidConfig.Priority
import com.google.firebase.messaging.AndroidNotification
import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.BatchResponse
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.Message
import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification
import com.google.firebase.messaging.SendResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
Expand All @@ -27,25 +26,15 @@ import org.slf4j.LoggerFactory
class PushService(
private val coroutineScope: CoroutineScope,
private val pushRepository: IPushRepository,
private val config: FCMConfig,
private val logLevel: LogLevel = LogLevel.INFO,
private var client: HttpClient = HttpClient(Apache)
) : AutoCloseable {
config: FCMConfig
) {
private val firebaseMessaging: FirebaseMessaging

init {
client = client.config {
install(ContentNegotiation) {
jackson {
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
install(DefaultRequest) {
headers.append("Authorization", "key=${config.serverKey}")
}
install(Logging) {
logger = Logger.DEFAULT
this.level = logLevel
}
}
val options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(config.googleServiceAccountJson.byteInputStream()))
.build()
firebaseMessaging = FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options))
}

fun sendAsync(payLoad: FCMPayLoad): Deferred<PushResult> {
Expand All @@ -55,40 +44,87 @@ class PushService(

val chunkedTokenList = payLoad.tokenList.chunked(FCM_TOKEN_CHUNK)


return coroutineScope.async {
var success = 0
var failure = 0

chunkedTokenList.forEach { tokenList ->
val requestData = prepareRequestBody(payLoad, tokenList)
val response = sendChunk(requestData)
val response = if (requestData.condition != null || requestData.topic != null) {
send(requestData)
} else {
sendChunk(requestData)
}

if (response == null) {
failure += tokenList.size
return@forEach
}
success += response.success
failure += response.failure
success += response.successCount
failure += response.failureCount
}
return@async PushResult(
success = success,
failure = failure
)
}
}

private fun send(payloadObject: RequestData): BatchResponse? {
return try {
val token = payloadObject.registrationTokenList?.firstOrNull()

val msg = Message.builder()
.setToken(token)
.setCondition(payloadObject.condition)
.setTopic(payloadObject.topic)
.setNotification(getNotification(payloadObject))
.setAndroidConfig(getAndroidConfig(payloadObject))
.setApnsConfig(getApnsConfig(payloadObject))
.putAllData(payloadObject.data ?: emptyMap())
.build()

val messageId = firebaseMessaging.send(msg)
if (messageId == null && token != null) { // wrong token
pushRepository.deleteByTokenList(listOf(token))
}

return object : BatchResponse {
override fun getResponses(): MutableList<SendResponse> {
return mutableListOf()
}

override fun getSuccessCount(): Int {
return messageId?.let { 1 } ?: 0
}

override fun getFailureCount(): Int {
return messageId?.let { 0 } ?: 1
}
}
} catch (t: Throwable) {
logger.error(t.localizedMessage, t)
null
}
}

private suspend fun sendChunk(payloadObject: RequestData): FCMResponse? {
private fun sendChunk(payloadObject: RequestData): BatchResponse? {
return try {
val response: FCMResponse = client.post(config.apiUrl) {
contentType(ContentType.Application.Json)
setBody(payloadObject)
}.body()
if (response.failure > 0) { // has wrong tokens
val msg = MulticastMessage.builder()
.addAllTokens(payloadObject.registrationTokenList)
.setNotification(getNotification(payloadObject))
.setAndroidConfig(getAndroidConfig(payloadObject))
.setApnsConfig(getApnsConfig(payloadObject))
.putAllData(payloadObject.data ?: emptyMap())
.build()

val response = firebaseMessaging.sendEachForMulticast(msg)

if (response.failureCount > 0) { // has wrong tokens
val invalidTokenList = ArrayList<String>()
val tokenList = payloadObject.registrationTokenList!!
response.results.forEachIndexed { index, message ->
if (message.error === null) {
response.responses.forEachIndexed { index, message ->
if (message.isSuccessful) {
return@forEachIndexed
}
if (tokenList.size < index) {
Expand All @@ -105,22 +141,56 @@ class PushService(
}
}

private fun getNotification(payloadObject: RequestData): Notification {
return Notification.builder()
.setTitle(payloadObject.notification?.title)
.setBody(payloadObject.notification?.body)
.setImage(payloadObject.notification?.icon)
.build()
}

private fun getAndroidConfig(payloadObject: RequestData): AndroidConfig {
return AndroidConfig.builder()
.setNotification(
AndroidNotification.builder()
.setTitle(payloadObject.notification?.title)
.setBody(payloadObject.notification?.body)
.setClickAction(payloadObject.notification?.clickAction)
.setColor(payloadObject.notification?.color)
.setImage(payloadObject.notification?.icon)
.setSound(payloadObject.notification?.sound)
.setTag(payloadObject.notification?.tag)
.build()
)
.setPriority(Priority.valueOf(payloadObject.priority.value))
.build()
}

private fun getApnsConfig(payloadObject: RequestData): ApnsConfig {
return ApnsConfig.builder()
.setAps(
Aps.builder()
.setSound(payloadObject.notification?.sound)
.setBadge(payloadObject.notification?.badge?.toInt() ?: 0)
.setCategory(payloadObject.notification?.clickAction)
.build()
)
.build()
}

private fun prepareRequestBody(payLoad: FCMPayLoad, tokenList: List<String>): RequestData {
return RequestData(
data = payLoad.dataObject,
registrationTokenList = tokenList,
condition = payLoad.condition,
topic = payLoad.topic,
notification = payLoad.notificationObject,
priority = payLoad.priority.value
priority = payLoad.priority
)
}

private companion object {
val logger: org.slf4j.Logger = LoggerFactory.getLogger(PushService::class.java)
private const val FCM_TOKEN_CHUNK = 1000
}

override fun close() {
client.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@

package com.icerockdev.service.fcmpush

import com.fasterxml.jackson.annotation.JsonProperty

internal data class RequestData(
val data: Map<String, String>?,
@JsonProperty("registration_ids")
val registrationTokenList: List<String>?,
val condition: String?,
val topic: String?,
val notification: NotificationData?,
val priority: String
)
val priority: PushPriority
)
Loading

0 comments on commit c068d35

Please sign in to comment.