Compare commits

...

2 Commits

Author SHA1 Message Date
Korna e9f8bf30d4 Add HPK Android-related DTO's 2022-11-02 16:41:39 +03:00
Artyom 5f6adc97fe
Add basic changes for HPK push provider (#88)
* Rename and add dto's

* Add new platform and provider

* Add module

* Add builder

* Add api enum

* Add conditional webclient

* Add configuration and properties
2022-11-02 16:40:01 +03:00
42 changed files with 1299 additions and 54 deletions

View File

@ -6,7 +6,7 @@ import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.dto.result.SendPushResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageTraceableResult
import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.exceptions.InvalidPushTokenException
import ru.touchin.push.message.provider.exceptions.PushMessageProviderException
@ -32,7 +32,7 @@ class FcmClient(
fun check(request: PushTokenCheck): PushTokenStatus {
val validationRequest = PushTokenMessage(
token = request.pushToken,
notification = null,
pushMessageNotification = null,
data = emptyMap()
)
@ -54,7 +54,7 @@ class FcmClient(
return try {
val messageId = firebaseMessaging.send(message, dryRun)
SendPushTokenMessageResult(messageId)
SendPushTokenMessageTraceableResult(messageId)
} catch (e: FirebaseMessagingException) {
throw firebaseMessagingExceptionConverter(e)
}

View File

@ -1,18 +0,0 @@
package ru.touchin.push.message.provider.fcm.converters
import com.google.firebase.messaging.Notification as FcmNotification
import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.Notification
@Component
class NotificationConverter {
operator fun invoke(notification: Notification): FcmNotification {
return FcmNotification.builder()
.setTitle(notification.title)
.setBody(notification.description)
.setImage(notification.imageUrl)
.build()
}
}

View File

@ -0,0 +1,18 @@
package ru.touchin.push.message.provider.fcm.converters
import com.google.firebase.messaging.Notification as FcmNotification
import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.PushMessageNotification
@Component
class PushMessageNotificationConverter {
operator fun invoke(pushMessageNotification: PushMessageNotification): FcmNotification {
return FcmNotification.builder()
.setTitle(pushMessageNotification.title)
.setBody(pushMessageNotification.description)
.setImage(pushMessageNotification.imageUrl)
.build()
}
}

View File

@ -6,12 +6,12 @@ import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.Message
import org.springframework.stereotype.Component
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
@Component
class PushTokenMessageConverter(
private val notificationConverter: NotificationConverter
private val pushMessageNotificationConverter: PushMessageNotificationConverter
) {
private companion object {
@ -26,14 +26,14 @@ class PushTokenMessageConverter(
.setToken(request.token)
.setupApns()
.setupAndroid()
.setIfExists(request.notification)
.setIfExists(request.pushMessageNotification)
.putAllData(request.data)
.build()
}
private fun Message.Builder.setIfExists(notification: Notification?): Message.Builder {
return if (notification != null) {
setNotification(notificationConverter(notification))
private fun Message.Builder.setIfExists(pushMessageNotification: PushMessageNotification?): Message.Builder {
return if (pushMessageNotification != null) {
setNotification(pushMessageNotificationConverter(pushMessageNotification))
} else {
this
}

View File

@ -5,15 +5,15 @@ import org.junit.Assert
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
import com.google.firebase.messaging.Notification as FcmNotification
import org.junit.jupiter.api.DisplayName
@SpringBootTest
class NotificationConverterTest {
class PushMessageNotificationConverterTest {
@Autowired
lateinit var notificationConverter: NotificationConverter
lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired
lateinit var objectMapper: ObjectMapper
@ -21,19 +21,19 @@ class NotificationConverterTest {
@Test
@DisplayName("Конвертация уведомления происходит корректно")
fun invoke_basic() {
val notification = Notification(
val pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "imageUrl"
)
val realResult = notificationConverter(notification)
val realResult = pushMessageNotificationConverter(pushMessageNotification)
val realResultJson = objectMapper.writeValueAsString(realResult)
val expectedResult = FcmNotification.builder()
.setTitle(notification.title)
.setBody(notification.description)
.setImage(notification.imageUrl)
.setTitle(pushMessageNotification.title)
.setBody(pushMessageNotification.description)
.setImage(pushMessageNotification.imageUrl)
.build()
val expectedResultJson = objectMapper.writeValueAsString(expectedResult)

View File

@ -11,7 +11,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
@SpringBootTest
@ -21,7 +21,7 @@ class PushTokenMessageConverterTest {
lateinit var pushTokenMessageConverter: PushTokenMessageConverter
@Autowired
lateinit var notificationConverter: NotificationConverter
lateinit var pushMessageNotificationConverter: PushMessageNotificationConverter
@Autowired
lateinit var objectMapper: ObjectMapper
@ -29,14 +29,14 @@ class PushTokenMessageConverterTest {
@Test
@DisplayName("Конвертация сообщения с уведомлением происходит корректно")
fun invoke_withNotification() {
val notification = Notification(
val pushMessageNotification = PushMessageNotification(
title = "title",
description = "description",
imageUrl = "imageUrl"
)
val pushTokenMessage = PushTokenMessage(
token = "token",
notification = notification,
pushMessageNotification = pushMessageNotification,
data = mapOf("testKey" to "testvalue")
)
@ -45,7 +45,7 @@ class PushTokenMessageConverterTest {
val expectedResult = Message.builder()
.setToken(pushTokenMessage.token)
.setNotification(notificationConverter(notification))
.setNotification(pushMessageNotificationConverter(pushMessageNotification))
.putAllData(pushTokenMessage.data)
.setupApns()
.setupAndroid()
@ -65,7 +65,7 @@ class PushTokenMessageConverterTest {
fun invoke_withoutNotification() {
val pushTokenMessage = PushTokenMessage(
token = "token",
notification = null,
pushMessageNotification = null,
data = mapOf("testKey" to "testvalue")
)

View File

@ -10,7 +10,7 @@ import org.springframework.boot.test.mock.mockito.MockBean
import ru.touchin.push.message.provider.dto.request.PushTokenCheck
import ru.touchin.push.message.provider.dto.request.PushTokenMessage
import ru.touchin.push.message.provider.dto.result.CheckPushTokenResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageResult
import ru.touchin.push.message.provider.dto.result.SendPushTokenMessageTraceableResult
import ru.touchin.push.message.provider.enums.PushTokenStatus
import ru.touchin.push.message.provider.fcm.clients.FcmClient
import ru.touchin.push.message.provider.services.PushMessageProviderService
@ -29,11 +29,11 @@ class PushMessageProviderFcmServiceTest {
fun send_basic() {
val request = PushTokenMessage(
token = "testToken",
notification = null,
pushMessageNotification = null,
data = emptyMap()
)
val expectedResult = SendPushTokenMessageResult("testMessageId")
val expectedResult = SendPushTokenMessageTraceableResult("testMessageId")
Mockito.`when`(
fcmClient.sendPushTokenMessage(request)

View File

@ -0,0 +1,21 @@
plugins {
id("kotlin")
id("kotlin-spring")
id("maven-publish")
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation(project(":logger-spring"))
implementation(project(":common-spring-web"))
implementation(project(":push-message-provider"))
testImplementation(project(":logger-spring-web"))
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:junit-jupiter")
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk
import org.springframework.context.annotation.Import
import ru.touchin.push.message.provider.hpk.configurations.PushMessageProviderHpkConfiguration
@Import(value = [PushMessageProviderHpkConfiguration::class])
annotation class EnablePushMessageProviderHpk

View File

@ -0,0 +1,3 @@
package ru.touchin.push.message.provider.hpk.base.builders
internal interface Buildable

View File

@ -0,0 +1,42 @@
package ru.touchin.push.message.provider.hpk.base.clients
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.web.reactive.function.client.ClientResponse
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
internal open class ConditionalWebClientParser(
private val objectMapper: ObjectMapper,
) {
open fun isOkResponse(clientResponse: ClientResponse): Boolean {
return clientResponse.statusCode().is2xxSuccessful
}
@Throws(Exception::class)
inline fun <reified S, reified F> parse(
clientResponse: ClientResponse,
body: String,
): ConditionalResponse<S, F> {
return if (isOkResponse(clientResponse)) {
ConditionalResponse<S, F>(
success = parseValue(body, S::class.java),
failure = null
)
} else {
ConditionalResponse(
success = null,
failure = parseValue(body, F::class.java)
)
}
}
private fun <T> parseValue(source: String?, clazz: Class<T>): T {
return if (clazz.canonicalName != String::class.java.canonicalName) {
objectMapper.readValue(source, clazz)
} else {
@Suppress("UNCHECKED_CAST")
source as T // T is String
}
}
}

View File

@ -0,0 +1,85 @@
package ru.touchin.push.message.provider.hpk.base.clients
import io.netty.channel.ChannelOption
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.timeout.ReadTimeoutHandler
import io.netty.handler.timeout.WriteTimeoutHandler
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import reactor.netty.http.client.HttpClient
import ru.touchin.common.spring.web.webclient.BaseLogWebClient
import ru.touchin.common.spring.web.webclient.dto.RequestLogData
import ru.touchin.common.spring.web.webclient.logger.WebClientLogger
import ru.touchin.push.message.provider.hpk.base.clients.dto.ConditionalResponse
import ru.touchin.push.message.provider.hpk.properties.HpkProperties
import java.util.concurrent.TimeUnit
abstract class ConfigurableWebClient(
webClientLogger: WebClientLogger,
webClientBuilder: WebClient.Builder,
protected val webService: HpkProperties.WebService,
) : BaseLogWebClient(webClientLogger, webClientBuilder) {
private val conditionalWebClientParser: Lazy<ConditionalWebClientParser> = lazy {
ConditionalWebClientParser(
objectMapper = getObjectMapper(),
)
}
protected fun WebClient.Builder.setTimeouts(): WebClient.Builder {
val httpClient: HttpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, webService.http.connectionTimeout.toMillis().toInt())
.doOnConnected { setup ->
setup.addHandlerLast(ReadTimeoutHandler(webService.http.readTimeout.toMillis(), TimeUnit.MILLISECONDS))
setup.addHandlerLast(WriteTimeoutHandler(webService.http.writeTimeout.toMillis(), TimeUnit.MILLISECONDS))
}
.let { httpClient ->
webService.ssl?.let { ssl ->
httpClient.secure { builder ->
builder
.sslContext(SslContextBuilder.forClient().build())
.handshakeTimeout(ssl.handshakeTimeout)
.closeNotifyFlushTimeout(ssl.notifyFlushTimeout)
.closeNotifyReadTimeout(ssl.notifyReadTimeout)
}
} ?: httpClient
}
return clientConnector(ReactorClientHttpConnector(httpClient))
}
internal inline fun <reified S, reified F> WebClient.RequestHeadersSpec<*>.exchangeWithWrap(
requestLogData: RequestLogData,
): Mono<ConditionalResponse<S, F>> {
return exchangeToMono { clientResponse ->
parse<S, F>(clientResponse)
}.doOnNext { responseWrapper ->
getLogger().log(
requestLogData.copy(
responseBody = responseWrapper.success ?: responseWrapper.failure
)
)
}
}
internal inline fun <reified S, reified F> parse(
clientResponse: ClientResponse,
): Mono<ConditionalResponse<S, F>> {
val responseBody = clientResponse
.bodyToMono(String::class.java)
.defaultIfEmpty(String())
.publishOn(Schedulers.parallel())
return responseBody
.map { body ->
conditionalWebClientParser.value.parse(
clientResponse = clientResponse,
body = body,
)
}
}
}

View File

@ -0,0 +1,15 @@
package ru.touchin.push.message.provider.hpk.base.clients.dto
internal open class ConditionalResponse<S, F>(
val success: S?,
val failure: F?,
) {
init {
// Only one value should be present
check((success == null) != (failure == null))
}
val isSuccess: Boolean = success != null
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.base.enums
import com.fasterxml.jackson.annotation.JsonValue
internal interface ValueableSerializableEnum<T> {
val value: T
@JsonValue
fun toValue(): T = value
}

View File

@ -0,0 +1,7 @@
package ru.touchin.push.message.provider.hpk.base.extensions
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
fun <V, B : Buildable> B.ifNotNull(value: V?, setter: B.(V) -> B): B {
return value?.let { setter(it) } ?: this
}

View File

@ -0,0 +1,67 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidBadgeNotification private constructor(
/** Accumulative badge number. */
val addNum: Short?,
/** Full path of the app entry activity class. */
@JsonProperty("class")
val clazz: String,
/** Badge number. Overrides [addNum]. */
val setNum: Short?,
) {
class Validator {
fun check(androidBadgeNotification: AndroidBadgeNotification) {
with(androidBadgeNotification) {
if (addNum != null) {
require(
addNum in 1..99
) { "add_num must locate between 0 and 100" }
}
if (setNum != null) {
require(
setNum in 0..99
) { "set_num must locate between -1 and 100" }
}
}
}
}
class Builder : Buildable {
private var addNum: Short? = null
private var setNum: Short? = null
fun setAddNum(addNum: Short): Builder {
this.addNum = addNum
return this
}
fun setSetNum(setNum: Short): Builder {
this.setNum = setNum
return this
}
fun build(badgeClass: String): AndroidBadgeNotification {
return AndroidBadgeNotification(
addNum = addNum,
clazz = badgeClass,
setNum = setNum,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,73 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidActionType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidIntentType
internal data class AndroidButton private constructor(
/** Button name. */
val name: String,
/** Button action. */
@JsonProperty("action_type")
val androidActionType: AndroidActionType,
/** Method of opening a custom app page. */
@JsonProperty("intent_type")
val androidIntentType: AndroidIntentType?,
val intent: String?,
/** Map of key-values. */
val data: String?,
) {
class Validator {
fun check(androidButton: AndroidButton) {
with(androidButton) {
require(
name.length < 40
) { "Button name length cannot exceed 40" }
if (androidActionType == AndroidActionType.SHARE_NOTIFICATION_MESSAGE) {
require(!data.isNullOrEmpty()) { "Data is needed when actionType is $androidActionType" }
require(data.length < 1024) { "Data length cannot exceed 1024 chars" }
}
}
}
}
class Builder : Buildable {
private var intent: String? = null
private var data: String? = null
fun setIntent(intent: String): Builder {
this.intent
return this
}
fun setData(data: String): Builder {
this.data = data
return this
}
fun build(name: String, androidActionType: AndroidActionType, androidIntentType: AndroidIntentType): AndroidButton {
return AndroidButton(
name = name,
androidActionType = androidActionType,
androidIntentType = androidIntentType,
intent = intent,
data = data
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,93 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidClickActionType
internal data class AndroidClickAction private constructor(
/** Message tapping action type. */
@JsonProperty("type")
val androidClickActionType: AndroidClickActionType,
val intent: String?,
/** URL to be opened. */
val url: String?,
/** Action corresponding to the activity of the page to be opened when the custom app page is opened through the action. */
val action: String?,
) {
class Validator {
fun check(androidClickAction: AndroidClickAction) {
with(androidClickAction) {
when (androidClickActionType) {
AndroidClickActionType.CUSTOMIZE_ACTION -> require(
!intent.isNullOrBlank() || !action.isNullOrBlank()
) { "intent or action is required when click type is $androidClickActionType" }
AndroidClickActionType.OPEN_URL -> {
require(!url.isNullOrBlank()) { "url is required when click type is $androidClickActionType" }
require(url.matches(HTTPS_PATTERN)) { "url must start with https" }
}
AndroidClickActionType.OPEN_APP -> {
// no verification
}
}
}
}
private companion object {
val HTTPS_PATTERN: Regex = Regex("^https.*")
}
}
class Builder : Buildable {
private var intent: String? = null
private var url: String? = null
private var richResource: String? = null
private var action: String? = null
fun setIntent(intent: String): Builder {
this.intent = intent
return this
}
fun setUrl(url: String): Builder {
this.url = url
return this
}
fun setRichResource(richResource: String): Builder {
this.richResource = richResource
return this
}
fun setAction(action: String): Builder {
this.action = action
return this
}
fun build(androidClickActionType: AndroidClickActionType): AndroidClickAction {
return AndroidClickAction(
androidClickActionType = androidClickActionType,
intent = intent.takeIf { androidClickActionType == AndroidClickActionType.CUSTOMIZE_ACTION },
action = action.takeIf { androidClickActionType == AndroidClickActionType.CUSTOMIZE_ACTION },
url = url.takeIf { androidClickActionType == AndroidClickActionType.OPEN_URL },
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,81 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidColor private constructor(
/** Alpha setting of the RGB color.*/
val alpha: Float,
/** Red setting of the RGB color. */
val red: Float,
/** Green setting of the RGB color. */
val green: Float,
/** Green setting of the RGB color. */
val blue: Float,
) {
class Validator {
fun check(androidColor: AndroidColor) {
with(androidColor) {
require(alpha > ZERO && alpha < ONE) { "Alpha must be locate between [0,1]" }
require(red > ZERO && red < ONE) { "Red must be locate between [0,1]" }
require(green > ZERO && green < ONE) { "Green must be locate between [0,1]" }
require(blue > ZERO && blue < ONE) { "Blue must be locate between [0,1]" }
}
}
private companion object {
private const val ZERO: Float = -0.000001f
private const val ONE: Float = 1.000001f
}
}
class Builder : Buildable {
private var alpha: Float = 1.0f
private var red: Float = 0.0f
private var green: Float = 0.0f
private var blue: Float = 0.0f
fun setAlpha(alpha: Float): Builder {
this.alpha = alpha
return this
}
fun setRed(red: Float): Builder {
this.red = red
return this
}
fun setGreen(green: Float): Builder {
this.green = green
return this
}
fun setBlue(blue: Float): Builder {
this.blue = blue
return this
}
fun build(): AndroidColor {
return AndroidColor(
alpha = alpha,
red = red,
green = green,
blue = blue
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,63 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
internal data class AndroidLightSettings private constructor(
/** Breathing light color. */
@JsonProperty("color")
val androidColor: AndroidColor,
/** Interval when a breathing light is on */
val lightOnDuration: String,
/** Interval when a breathing light is off */
val lightOffDuration: String,
) {
class Validator {
fun check(androidLightSettings: AndroidLightSettings) {
with(androidLightSettings) {
AndroidColor.validator.check(androidColor)
require(
lightOnDuration.matches(LIGHT_DURATION_PATTERN)
) { "light_on_duration format is wrong" }
require(
lightOffDuration.matches(LIGHT_DURATION_PATTERN)
) { "light_off_duration format is wrong" }
}
}
private companion object {
val LIGHT_DURATION_PATTERN: Regex = Regex("\\d+|\\d+[sS]|\\d+.\\d{1,9}|\\d+.\\d{1,9}[sS]")
}
}
class Builder : Buildable {
fun build(
color: AndroidColor,
lightOnDuration: String,
lightOffDuration: String
): AndroidLightSettings {
return AndroidLightSettings(
androidColor = color,
lightOnDuration = lightOnDuration,
lightOffDuration = lightOffDuration,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,410 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.android
import com.fasterxml.jackson.annotation.JsonProperty
import ru.touchin.push.message.provider.hpk.base.builders.Buildable
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.dto.Notification
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidImportance
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidStyleType
import ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android.AndroidVisibility
internal data class AndroidNotificationConfig private constructor(
/**
* Title of an Android notification message.
* If the title parameter is set, the value of the [Notification.title] field is overwritten.
* */
val title: String?,
/**
* Body of an Android notification message.
* If the body parameter is set, the value of the [Notification.body] field is overwritten.
* */
val body: String?,
/**
* Custom app icon on the left of a notification message.
*/
val icon: String?,
/** Custom notification bar button color. */
val color: String?,
val sound: String?,
/** Indicates whether to use the default ringtone. */
val defaultSound: Boolean,
/**
* Message tag.
* Messages that use the same message tag in the same app will be overwritten by the latest message.
* */
val tag: String?,
@JsonProperty("click_action")
val androidClickAction: AndroidClickAction?,
val bodyLocKey: String?,
val bodyLocArgs: Collection<String>?,
val titleLocKey: String?,
val titleLocArgs: Collection<String>?,
val multiLangKey: Map<String, String>?,
/** Custom channel for displaying notification messages. */
val channelId: String?,
/** Brief description of a notification message to an Android app. */
val notifySummary: String?,
/** URL of the custom small image on the right of a notification message. */
val image: String?,
/** Notification bar style. */
@JsonProperty("style")
val androidStyleType: AndroidStyleType?,
/** Android notification message title in large text style. */
val bigTitle: String?,
val bigBody: String?,
/**
* Unique notification ID of a message.
* If a message does not contain the ID or the ID is -1, the NC will generate a unique ID for the message.
* Different notification messages can use the same notification ID, so that new messages can overwrite old messages.
* */
val notifyId: Int?,
/**
* Message group.
* For example, if 10 messages that contain the same value of group are sent to a device,
* the device displays only the latest message and the total number of messages received in the group,
* but does not display these 10 messages.
*/
val group: String?,
@JsonProperty("badge")
val androidBadgeNotification: AndroidBadgeNotification? = null,
val autoCancel: Boolean,
/**
* Time when Android notification messages are delivered, in the UTC timestamp format.
* If you send multiple messages at the same time,
* they will be sorted based on this value and displayed in the Android notification panel.
* Example: 2014-10-02T15:01:23.045123456Z
*/
@JsonProperty("when")
val sendAt: String?,
val localOnly: Boolean? = null,
/**
* Android notification message priority, which determines the message notification behavior of a user device.
*/
@JsonProperty("importance")
val androidImportance: AndroidImportance?,
/** Indicates whether to use the default vibration mode. */
val useDefaultVibrate: Boolean,
/** Indicates whether to use the default breathing light. */
val useDefaultLight: Boolean,
val vibrateConfig: Collection<String>?,
/** Android notification message visibility. */
@JsonProperty("visibility")
val androidVisibility: AndroidVisibility?,
@JsonProperty("light_settings")
val androidLightSettings: AndroidLightSettings?,
/**
* Indicates whether to display notification messages in the NC when your app is running in the foreground.
* If this parameter is not set, the default value true will be used,
* indicating that notification messages will be displayed in the NC when your app runs in the foreground.
* */
val foregroundShow: Boolean,
val inboxContent: Collection<String>?,
@JsonProperty("buttons")
val androidButtons: Collection<AndroidButton>?,
/** ID of the user-app relationship. */
val profileId: String?,
) {
class Validator {
fun check(androidNotificationConfig: AndroidNotificationConfig, notification: Notification?) {
with(androidNotificationConfig) {
androidBadgeNotification?.let { AndroidBadgeNotification.validator.check(it) }
androidLightSettings?.also { AndroidLightSettings.validator.check(it) }
androidClickAction?.also { AndroidClickAction.validator.check(it) }
require(!notification?.title.isNullOrBlank() || !title.isNullOrBlank()) { "title should be set" }
require(!notification?.body.isNullOrBlank() || !body.isNullOrBlank()) { "body should be set" }
if (!color.isNullOrBlank()) {
require(color.matches(COLOR_PATTERN)) { "Wrong color format, color must be in the form #RRGGBB" }
}
if (!image.isNullOrBlank()) {
require(image.matches(HTTPS_URL_PATTERN)) { "notifyIcon must start with https" }
}
if (androidStyleType != null) {
when (androidStyleType) {
AndroidStyleType.DEFAULT -> {
// no verification
}
AndroidStyleType.BIG_TEXT -> {
require(
!bigTitle.isNullOrBlank() && !bigBody.isNullOrBlank()
) { "title and body are required when style is $androidStyleType" }
}
AndroidStyleType.INBOX -> {
require(
!inboxContent.isNullOrEmpty() && inboxContent.orEmpty().size <= 5
) { "inboxContent is required when style is $androidStyleType and at most 5 inbox content allowed" }
}
}
}
if (profileId != null) {
require(profileId.length <= 64) { "profileId length cannot exceed 64 characters" }
}
}
}
private companion object {
val COLOR_PATTERN: Regex = Regex("^#[0-9a-fA-F]{6}$")
val HTTPS_URL_PATTERN: Regex = Regex("^https.*")
}
}
class Builder : Buildable {
private var title: String? = null
private val body: String? = null
private var icon: String? = null
private var color: String? = null
private var sound: String? = null
private var defaultSound = false
private var tag: String? = null
private var bodyLocKey: String? = null
private val bodyLocArgs: MutableList<String> = mutableListOf()
private var titleLocKey: String? = null
private val titleLocArgs: MutableList<String> = mutableListOf()
private var multiLangkey: Map<String, String>? = null
private var channelId: String? = null
private var notifySummary: String? = null
private var image: String? = null
private var androidStyleType: AndroidStyleType? = null
private var bigTitle: String? = null
private var bigBody: String? = null
private var notifyId: Int? = null
private var group: String? = null
private var androidBadgeNotification: AndroidBadgeNotification? = null
private var autoCancel = true
private var sendAt: String? = null
private var androidImportance: AndroidImportance? = null
private var useDefaultVibrate = false
private var useDefaultLight = false
private val vibrateConfig: MutableList<String> = mutableListOf()
private var androidVisibility: AndroidVisibility? = null
private var androidLightSettings: AndroidLightSettings? = null
private var foregroundShow = false
private val inboxContent: MutableList<String> = mutableListOf()
private val buttons: MutableList<AndroidButton> = mutableListOf()
private var profileId: String? = null
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setBody(body: String): Builder {
this.body
return this
}
fun setIcon(icon: String): Builder {
this.icon = icon
return this
}
fun setColor(color: String): Builder {
this.color = color
return this
}
fun setSound(sound: String): Builder {
this.sound = sound
return this
}
fun setDefaultSound(defaultSound: Boolean): Builder {
this.defaultSound = defaultSound
return this
}
fun setTag(tag: String): Builder {
this.tag = tag
return this
}
fun setBodyLocKey(bodyLocKey: String): Builder {
this.bodyLocKey = bodyLocKey
return this
}
fun addBodyLocArgs(vararg arg: String): Builder {
bodyLocArgs.addAll(arg)
return this
}
fun setTitleLocKey(titleLocKey: String): Builder {
this.titleLocKey = titleLocKey
return this
}
fun addTitleLocArgs(vararg args: String): Builder {
titleLocArgs.addAll(args)
return this
}
fun setMultiLangkey(multiLangkey: Map<String, String>): Builder {
this.multiLangkey = multiLangkey
return this
}
fun setChannelId(channelId: String): Builder {
this.channelId = channelId
return this
}
fun setNotifySummary(notifySummary: String): Builder {
this.notifySummary = notifySummary
return this
}
fun setImage(image: String): Builder {
this.image = image
return this
}
fun setStyle(androidStyleType: AndroidStyleType): Builder {
this.androidStyleType = androidStyleType
return this
}
fun setBigTitle(bigTitle: String): Builder {
this.bigTitle = bigTitle
return this
}
fun setBigBody(bigBody: String): Builder {
this.bigBody = bigBody
return this
}
fun setNotifyId(notifyId: Int): Builder {
this.notifyId = notifyId
return this
}
fun setGroup(group: String): Builder {
this.group = group
return this
}
fun setBadge(androidBadgeNotification: AndroidBadgeNotification): Builder {
this.androidBadgeNotification = androidBadgeNotification
return this
}
fun setAutoCancel(autoCancel: Boolean): Builder {
this.autoCancel = autoCancel
return this
}
fun sendAt(sendAt: String): Builder {
this.sendAt = sendAt
return this
}
fun setImportance(androidImportance: AndroidImportance): Builder {
this.androidImportance = androidImportance
return this
}
fun setUseDefaultVibrate(useDefaultVibrate: Boolean): Builder {
this.useDefaultVibrate = useDefaultVibrate
return this
}
fun setUseDefaultLight(useDefaultLight: Boolean): Builder {
this.useDefaultLight = useDefaultLight
return this
}
fun addVibrateConfig(vararg vibrateTimings: String): Builder {
vibrateConfig.addAll(vibrateTimings)
return this
}
fun setAndroidVisibility(androidVisibility: AndroidVisibility): Builder {
this.androidVisibility = androidVisibility
return this
}
fun setLightSettings(androidLightSettings: AndroidLightSettings): Builder {
this.androidLightSettings = androidLightSettings
return this
}
fun setForegroundShow(foregroundShow: Boolean): Builder {
this.foregroundShow = foregroundShow
return this
}
fun addInboxContent(vararg inboxContent: String): Builder {
this.inboxContent.addAll(inboxContent)
return this
}
fun addButton(vararg button: AndroidButton): Builder {
buttons.addAll(button)
return this
}
fun setProfileId(profileId: String): Builder {
this.profileId = profileId
return this
}
fun build(
androidClickAction: AndroidClickAction,
): AndroidNotificationConfig {
return AndroidNotificationConfig(
title = title,
body = body,
icon = icon,
color = color,
sound = sound,
defaultSound = defaultSound,
tag = tag,
androidClickAction = androidClickAction,
bodyLocKey = bodyLocKey,
bodyLocArgs = bodyLocArgs.takeIf(Collection<*>::isNotEmpty),
titleLocKey = titleLocKey,
titleLocArgs = titleLocArgs.takeIf(Collection<*>::isNotEmpty),
multiLangKey = multiLangkey,
channelId = channelId,
notifySummary = notifySummary,
image = image,
androidStyleType = androidStyleType,
bigTitle = bigTitle,
bigBody = bigBody,
notifyId = notifyId,
group = group,
androidBadgeNotification = androidBadgeNotification,
autoCancel = autoCancel,
sendAt = sendAt,
androidImportance = androidImportance,
useDefaultVibrate = useDefaultVibrate,
useDefaultLight = useDefaultLight,
vibrateConfig = vibrateConfig.takeIf(Collection<*>::isNotEmpty),
androidVisibility = androidVisibility,
androidLightSettings = androidLightSettings,
foregroundShow = foregroundShow,
inboxContent = inboxContent.takeIf(Collection<*>::isNotEmpty),
androidButtons = buttons.takeIf(Collection<*>::isNotEmpty),
profileId = profileId,
)
}
}
companion object {
val validator = Validator()
fun builder() = Builder()
}
}

View File

@ -0,0 +1,17 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidActionType(
override val value: Short
) : ValueableSerializableEnum<Short> {
OPEN_APP_HOME_PAGE(0),
OPEN_CUSTOM_APP_PAGE(1),
OPEN_WEB_PAGE(2),
DELETE_NOTIFICATION_MESSAGE(3),
/** Only for Huawei devices */
SHARE_NOTIFICATION_MESSAGE(4),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidClickActionType(
override val value: Short
) : ValueableSerializableEnum<Short> {
CUSTOMIZE_ACTION(1),
OPEN_URL(2),
OPEN_APP(3),
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidFastAppTargetType(
override val value: Short
) : ValueableSerializableEnum<Short> {
DEVELOPMENT(1),
PRODUCTION(2),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidImportance(
override val value: String
) : ValueableSerializableEnum<String> {
LOW("LOW"),
NORMAL("NORMAL"),
HIGH("HIGH"), // TODO: check if this type is still supported by HMS HPK API
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class AndroidIntentType(
override val value: Short
) : ValueableSerializableEnum<Short> {
INTENT(0),
ACTION(1),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
enum class AndroidStyleType(
override val value: Short
) : ValueableSerializableEnum<Short> {
DEFAULT(0),
BIG_TEXT(1),
INBOX(3),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidTargetUserType(
override val value: Short
) : ValueableSerializableEnum<Short> {
TEST_USER(1),
FORMAL_USER(2),
VOIP_USER(3),
}

View File

@ -0,0 +1,13 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidTopicOperation(
override val value: String
) : ValueableSerializableEnum<String> {
SUBSCRIBE("subscribe"),
UNSUBSCRIBE("unsubscribe"),
LIST("list"),
}

View File

@ -0,0 +1,12 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidUrgency(
override val value: String
) : ValueableSerializableEnum<String> {
HIGH("HIGH"),
NORMAL("NORMAL"),
}

View File

@ -0,0 +1,24 @@
package ru.touchin.push.message.provider.hpk.clients.hms_hpk.enums.android
import ru.touchin.push.message.provider.hpk.base.enums.ValueableSerializableEnum
internal enum class AndroidVisibility(
override val value: String
) : ValueableSerializableEnum<String> {
/** The visibility is not specified. This value is equivalent to PRIVATE. */
VISIBILITY_UNSPECIFIED("VISIBILITY_UNSPECIFIED"),
/**
* If you have set a lock screen password and enabled Hide notification content under Settings > Notifications,
* the content of a received notification message is hidden on the lock screen.
* */
PRIVATE("PRIVATE"),
/** The content of a received notification message is displayed on the lock screen. */
PUBLIC("PUBLIC"),
/** A received notification message is not displayed on the lock screen. */
SECRET("SECRET"),
}

View File

@ -0,0 +1,59 @@
package ru.touchin.push.message.provider.hpk.configurations
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.cache.CacheManager
import org.springframework.cache.concurrent.ConcurrentMapCache
import org.springframework.cache.support.SimpleCacheManager
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Import
import ru.touchin.push.message.provider.configurations.PushMessageProviderConfiguration
import ru.touchin.push.message.provider.hpk.services.HmsOauthAccessTokenCacheServiceImpl.Companion.HMS_CLIENT_SERVICE_CACHE_KEY
@ComponentScan("ru.touchin.push.message.provider.hpk")
@ConfigurationPropertiesScan(basePackages = ["ru.touchin.push.message.provider.hpk"])
@Import(value = [PushMessageProviderConfiguration::class])
class PushMessageProviderHpkConfiguration {
@Bean
@Qualifier("push-message-provider.hpk.webclient-objectmapper")
fun webclientObjectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
@Bean
@Qualifier("push-message-provider.hpk.client-objectmapper")
fun clientObjectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
}
@Bean
@ConditionalOnMissingBean
@Qualifier("push-message-provider.hpk.webclient-cachemanager")
fun cacheManager(): CacheManager {
return SimpleCacheManager().also {
it.setCaches(
listOf(
ConcurrentMapCache(HMS_CLIENT_SERVICE_CACHE_KEY)
)
)
}
}
}

View File

@ -0,0 +1,59 @@
package ru.touchin.push.message.provider.hpk.properties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import java.net.URL
import java.time.Duration
@ConstructorBinding
@ConfigurationProperties(prefix = "push-message-provider.hpk")
data class HpkProperties(
val webServices: WebServices,
) {
class WebServices(
val clientId: String,
val oauth: Oauth,
val hpk: Hpk,
)
class Oauth(
val clientSecret: String,
url: URL,
http: Http,
ssl: Ssl?,
) : WebService(
url = url,
http = http,
ssl = ssl,
)
class Hpk(
url: URL,
http: Http,
ssl: Ssl?,
) : WebService(
url = url,
http = http,
ssl = ssl,
)
open class WebService(
val url: URL,
val http: Http,
val ssl: Ssl?,
)
class Http(
val readTimeout: Duration,
val writeTimeout: Duration,
val connectionTimeout: Duration,
)
class Ssl(
val handshakeTimeout: Duration,
val notifyFlushTimeout: Duration,
val notifyReadTimeout: Duration,
)
}

View File

@ -1,6 +1,6 @@
package ru.touchin.push.message.provider.dto
class Notification(
class PushMessageNotification(
val title: String?,
val description: String?,
val imageUrl: String?

View File

@ -1,9 +1,9 @@
package ru.touchin.push.message.provider.dto.request
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
class PushTokenMessage(
val token: String,
override val notification: Notification?,
override val pushMessageNotification: PushMessageNotification?,
override val data: Map<String, String>
) : SendPushRequest

View File

@ -1,10 +1,10 @@
package ru.touchin.push.message.provider.dto.request
import ru.touchin.push.message.provider.dto.Notification
import ru.touchin.push.message.provider.dto.PushMessageNotification
sealed interface SendPushRequest {
val notification: Notification?
val pushMessageNotification: PushMessageNotification?
val data: Map<String, String>
}

View File

@ -3,5 +3,5 @@ package ru.touchin.push.message.provider.dto.result
import ru.touchin.push.message.provider.enums.PushTokenStatus
data class CheckPushTokenResult(
val status: PushTokenStatus
val status: PushTokenStatus,
)

View File

@ -1,5 +1,3 @@
package ru.touchin.push.message.provider.dto.result
class SendPushTokenMessageResult(
val messageId: String
) : SendPushResult
object SendPushTokenMessageResult : SendPushResult

View File

@ -0,0 +1,5 @@
package ru.touchin.push.message.provider.dto.result
data class SendPushTokenMessageTraceableResult(
val messageId: String
) : SendPushResult

View File

@ -3,6 +3,7 @@ package ru.touchin.push.message.provider.enums
enum class PlatformType {
ANDROID_GOOGLE,
ANDROID_HUAWEI,
IOS
}

View File

@ -2,6 +2,7 @@ package ru.touchin.push.message.provider.enums
enum class PushMessageProviderType {
FCM
FCM,
HPK,
}

View File

@ -45,6 +45,7 @@ include("validation-spring")
include("version-spring-web")
include("push-message-provider")
include("push-message-provider-fcm")
include("push-message-provider-hpk")
include("response-wrapper-spring-web")
include("settings-spring-jpa")
include("security-authorization-server-core")