From b8ff7292d92de446f2a52d933a3b8691881b3098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojtek=20Kalici=C5=84ski?= <146713236+wkal-pubnub@users.noreply.github.com> Date: Thu, 22 Aug 2024 10:28:41 +0000 Subject: [PATCH] Update build, match defaults with TS, fix custom events (#58) * Build updates * Change default store activity interval to match TS * Test custom payloads * Add custom event handling * Fix flaky tests --- build.gradle.kts | 22 ++- gradle/libs.versions.toml | 43 ++++++ pubnub-chat-api/api/pubnub-chat-api.api | 23 +-- pubnub-chat-api/build.gradle.kts | 10 +- .../commonMain/kotlin/com/pubnub/chat/Chat.kt | 4 +- .../pubnub/chat/config/ChatConfiguration.kt | 14 +- .../com/pubnub/chat/config/CustomPayloads.kt | 2 +- .../chat/config/PushNotificationsConfig.kt | 4 +- .../kotlin/com/pubnub/chat/types/Types.kt | 5 +- pubnub-chat-impl/build.gradle.kts | 24 +-- .../com/pubnub/chat/internal/ChatImpl.kt | 15 +- .../com/pubnub/chat/internal/EventImpl.kt | 14 +- .../chat/internal/message/MessageImpl.kt | 14 +- .../internal/message/ThreadMessageImpl.kt | 21 +-- .../com/pubnub/chat/internal/utils/json.kt | 18 ++- .../integration/ChannelIntegrationTest.kt | 78 +++++----- .../ChatConfigurationIntegrationTest.kt | 72 +++++++++ .../pubnub/integration/ChatIntegrationTest.kt | 137 +++++++++++------- .../integration/MessageIntegrationTest.kt | 53 +++---- .../kotlin/com/pubnub/kmp/utils/FakeChat.kt | 2 +- pubnub-kotlin | 2 +- 21 files changed, 386 insertions(+), 191 deletions(-) create mode 100644 gradle/libs.versions.toml create mode 100644 pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatConfigurationIntegrationTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7191f392..234b5651 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,18 +3,24 @@ import com.pubnub.gradle.tasks.GenerateVersionTask import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension plugins { - kotlin("multiplatform") version "2.0.0" apply false - kotlin("plugin.serialization") version "2.0.0" apply false - kotlin("native.cocoapods") version "2.0.0" apply false - id("org.jlleitschuh.gradle.ktlint") version "12.1.0" apply false - id("org.jetbrains.kotlin.plugin.atomicfu") version "2.0.0" - id("com.vanniktech.maven.publish") version "0.29.0" apply false - id("org.jetbrains.dokka") version "1.9.20" apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.benmanes.versions) apply false + alias(libs.plugins.vanniktech.maven.publish) apply false + alias(libs.plugins.dokka) + alias(libs.plugins.gradle.nexus.publish) + alias(libs.plugins.kotlinx.atomicfu) id("pubnub.shared") id("pubnub.dokka") id("pubnub.multiplatform") - id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.16.2" + alias(libs.plugins.kotlinx.compatibility.validator) +} + +nexusPublishing { + repositories { + sonatype() + } } kotlin { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..eb1cd2cf --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,43 @@ +[versions] +nexus = "2.0.0" +kotlin = "2.0.10" +vanniktech = "0.29.0" +ktlint = "12.1.0" +dokka = "1.9.20" +kotlinx_datetime = "0.6.0" +kotlinx_coroutines = "1.8.1" +kotlinx_serialization = "1.7.1" +pubnub = "9.2.0-DEV" + +[libraries] +pubnub-core-api = { module = "com.pubnub:pubnub-core-api", version.ref = "pubnub" } +pubnub-kotlin-api = { module = "com.pubnub:pubnub-kotlin-api", version.ref = "pubnub" } +pubnub-kotlin-impl = { module = "com.pubnub:pubnub-kotlin-impl", version.ref = "pubnub" } +pubnub-kotlin = { module = "com.pubnub:pubnub-kotlin", version.ref = "pubnub" } +pubnub-kotlin-test = { module = "com.pubnub:pubnub-kotlin-test", version.ref = "pubnub" } +#slf4j = { module = "org.slf4j:slf4j-api", version = "1.7.36" } +#cbor = { module = "co.nstant.in:cbor", version = "0.9" } +#jetbrains-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" } +kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version = "0.25.0" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx_serialization" } +benasher44-uuid = { module = "com.benasher44:uuid", version = "0.8.4" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx_datetime"} +lighthousegames-logging = { module = "org.lighthousegames:logging", version = "1.5.0"} + +## tests +#logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +#logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" } +#junit4 = { module = "junit:junit", version = "4.13.2" } +#coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx_coroutines"} +# +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlinx-atomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +benmanes-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech" } +gradle-nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus" } +kotlinx-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.2" } +mokkery = { id = "dev.mokkery", version = "2.0.0" } diff --git a/pubnub-chat-api/api/pubnub-chat-api.api b/pubnub-chat-api/api/pubnub-chat-api.api index 197926e5..7c0eaec9 100644 --- a/pubnub-chat-api/api/pubnub-chat-api.api +++ b/pubnub-chat-api/api/pubnub-chat-api.api @@ -298,6 +298,7 @@ public final class com/pubnub/chat/config/LogLevel : java/lang/Enum { public final class com/pubnub/chat/config/PushNotificationsConfig { public fun (ZLjava/lang/String;Lcom/pubnub/api/enums/PNPushType;Ljava/lang/String;Lcom/pubnub/api/enums/PNPushEnvironment;)V + public synthetic fun (ZLjava/lang/String;Lcom/pubnub/api/enums/PNPushType;Ljava/lang/String;Lcom/pubnub/api/enums/PNPushEnvironment;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getApnsEnvironment ()Lcom/pubnub/api/enums/PNPushEnvironment; public final fun getApnsTopic ()Ljava/lang/String; public final fun getDeviceGateway ()Lcom/pubnub/api/enums/PNPushType; @@ -439,28 +440,12 @@ public final class com/pubnub/chat/types/EventContent$Companion { } public final class com/pubnub/chat/types/EventContent$Custom : com/pubnub/chat/types/EventContent { - public static final field Companion Lcom/pubnub/chat/types/EventContent$Custom$Companion; - public fun (Ljava/lang/Object;Lcom/pubnub/chat/types/EmitEventMethod;)V - public synthetic fun (Ljava/lang/Object;Lcom/pubnub/chat/types/EmitEventMethod;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getData ()Ljava/lang/Object; + public fun (Ljava/util/Map;Lcom/pubnub/chat/types/EmitEventMethod;)V + public synthetic fun (Ljava/util/Map;Lcom/pubnub/chat/types/EmitEventMethod;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getData ()Ljava/util/Map; public final fun getMethod ()Lcom/pubnub/chat/types/EmitEventMethod; } -public synthetic class com/pubnub/chat/types/EventContent$Custom$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Lcom/pubnub/chat/types/EventContent$Custom$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/pubnub/chat/types/EventContent$Custom; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/pubnub/chat/types/EventContent$Custom;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class com/pubnub/chat/types/EventContent$Custom$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class com/pubnub/chat/types/EventContent$Invite : com/pubnub/chat/types/EventContent { public static final field Companion Lcom/pubnub/chat/types/EventContent$Invite$Companion; public fun (Lcom/pubnub/chat/types/ChannelType;Ljava/lang/String;)V diff --git a/pubnub-chat-api/build.gradle.kts b/pubnub-chat-api/build.gradle.kts index 62336c03..4fff14c8 100644 --- a/pubnub-chat-api/build.gradle.kts +++ b/pubnub-chat-api/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - kotlin("plugin.serialization") version "2.0.0" - id("org.jetbrains.kotlin.plugin.atomicfu") version "2.0.0" + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinx.atomicfu) id("pubnub.shared") id("pubnub.dokka") id("pubnub.multiplatform") @@ -10,9 +10,9 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - api("com.pubnub:pubnub-core-api:9.2-DEV") - api("com.pubnub:pubnub-kotlin-api:9.2-DEV") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.0-RC") + api(libs.pubnub.core.api) + api(libs.pubnub.kotlin.api) + implementation(libs.kotlinx.serialization.core) } } } diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt index 483fa69d..931289b3 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/Chat.kt @@ -139,7 +139,7 @@ interface Chat { fun listenForEvents( type: KClass, channelId: String, - customMethod: EmitEventMethod? = null, + customMethod: EmitEventMethod = EmitEventMethod.PUBLISH, callback: (event: Event) -> Unit ): AutoCloseable @@ -194,7 +194,7 @@ interface Chat { inline fun Chat.listenForEvents( channelId: String, - customMethod: EmitEventMethod? = null, + customMethod: EmitEventMethod = EmitEventMethod.PUBLISH, noinline callback: (event: Event) -> Unit ): AutoCloseable { return listenForEvents(T::class, channelId, customMethod, callback) diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt index ac9ef859..68591741 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/ChatConfiguration.kt @@ -21,14 +21,14 @@ interface ChatConfiguration { fun ChatConfiguration( logLevel: LogLevel = LogLevel.OFF, // todo document all levels including "Off" typingTimeout: Duration = 5.seconds, - storeUserActivityInterval: Duration = 60.seconds, + storeUserActivityInterval: Duration = 600.seconds, storeUserActivityTimestamps: Boolean = false, pushNotifications: PushNotificationsConfig = PushNotificationsConfig( - false, - null, - PNPushType.FCM, - null, - PNPushEnvironment.DEVELOPMENT + sendPushes = false, + deviceToken = null, + deviceGateway = PNPushType.FCM, + apnsTopic = null, + apnsEnvironment = PNPushEnvironment.DEVELOPMENT ), rateLimitFactor: Int = 2, rateLimitPerChannel: Map = RateLimitPerChannel(), @@ -36,7 +36,7 @@ fun ChatConfiguration( ): ChatConfiguration = object : ChatConfiguration { override val logLevel: LogLevel = logLevel override val typingTimeout: Duration = typingTimeout - override val storeUserActivityInterval: Duration = storeUserActivityInterval + override val storeUserActivityInterval: Duration = maxOf(storeUserActivityInterval, 60.seconds) override val storeUserActivityTimestamps: Boolean = storeUserActivityTimestamps override val pushNotifications: PushNotificationsConfig = pushNotifications override val rateLimitFactor: Int = rateLimitFactor diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/CustomPayloads.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/CustomPayloads.kt index fb16273c..87bb0e51 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/CustomPayloads.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/CustomPayloads.kt @@ -4,7 +4,7 @@ import com.pubnub.api.JsonElement import com.pubnub.chat.types.EventContent class CustomPayloads( - val getMessagePublishBody: ((m: EventContent.TextMessageContent, channelId: String) -> Map)? = null, + val getMessagePublishBody: ((m: EventContent.TextMessageContent, channelId: String) -> Map)? = null, val getMessageResponseBody: ( (m: JsonElement) -> EventContent.TextMessageContent )? = null, // todo do we have tests that checks this functionality diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/PushNotificationsConfig.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/PushNotificationsConfig.kt index 4ca64cf5..a8b3c2d6 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/PushNotificationsConfig.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/config/PushNotificationsConfig.kt @@ -7,6 +7,6 @@ class PushNotificationsConfig( val sendPushes: Boolean, val deviceToken: String?, val deviceGateway: PNPushType, - val apnsTopic: String?, - val apnsEnvironment: PNPushEnvironment + val apnsTopic: String? = null, + val apnsEnvironment: PNPushEnvironment = PNPushEnvironment.DEVELOPMENT ) diff --git a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/Types.kt b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/Types.kt index f82859e1..69572730 100644 --- a/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/Types.kt +++ b/pubnub-chat-api/src/commonMain/kotlin/com/pubnub/chat/types/Types.kt @@ -2,7 +2,6 @@ package com.pubnub.chat.types import com.pubnub.api.JsonElement import com.pubnub.chat.restrictions.RestrictionType -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -44,10 +43,8 @@ sealed class EventContent { @SerialName("invite") class Invite(val channelType: ChannelType, val channelId: String) : EventContent() - @Serializable - @SerialName("custom") class Custom( - @Contextual val data: Any, + val data: Map, @Transient val method: EmitEventMethod = EmitEventMethod.PUBLISH ) : EventContent() diff --git a/pubnub-chat-impl/build.gradle.kts b/pubnub-chat-impl/build.gradle.kts index 964eddb8..8e68bebb 100644 --- a/pubnub-chat-impl/build.gradle.kts +++ b/pubnub-chat-impl/build.gradle.kts @@ -3,40 +3,40 @@ import com.pubnub.gradle.enableJsTarget import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension plugins { - kotlin("plugin.serialization") version "2.0.0" - id("org.jetbrains.kotlin.plugin.atomicfu") version "2.0.0" + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinx.atomicfu) id("pubnub.ios-simulator-test") id("pubnub.shared") id("pubnub.dokka") id("pubnub.multiplatform") - id("dev.mokkery") version "2.0.0" + alias(libs.plugins.mokkery) } kotlin { sourceSets { val commonMain by getting { dependencies { - implementation("com.pubnub:pubnub-core-api:9.2-DEV") - implementation("com.pubnub:pubnub-kotlin-api:9.2-DEV") - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.0-RC") - implementation("com.benasher44:uuid:0.8.4") - implementation("org.jetbrains.kotlinx:atomicfu:0.24.0") implementation(project(":pubnub-chat-api")) - implementation("org.lighthousegames:logging:1.5.0") + implementation(libs.pubnub.core.api) + implementation(libs.pubnub.kotlin.api) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.atomicfu) + implementation(libs.benasher44.uuid) + implementation(libs.lighthousegames.logging) } } val commonTest by getting { dependencies { implementation(kotlin("test")) - implementation("com.pubnub:pubnub-kotlin-test") + implementation(libs.pubnub.kotlin.test) } } val jvmMain by getting { dependencies { - implementation("com.pubnub:pubnub-kotlin:9.2-DEV") + implementation(libs.pubnub.kotlin) implementation(kotlin("test-junit")) } } diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt index 18f44774..14d0a50e 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/ChatImpl.kt @@ -3,6 +3,9 @@ package com.pubnub.chat.internal import com.benasher44.uuid.uuid4 import com.pubnub.api.PubNub import com.pubnub.api.PubNubException +import com.pubnub.api.asMap +import com.pubnub.api.asString +import com.pubnub.api.decode import com.pubnub.api.enums.PNPushType import com.pubnub.api.models.consumer.PNBoundedPage import com.pubnub.api.models.consumer.PNPublishResult @@ -580,7 +583,7 @@ class ChatImpl( override fun listenForEvents( type: KClass, channelId: String, - customMethod: EmitEventMethod?, + customMethod: EmitEventMethod, callback: (event: Event) -> Unit ): AutoCloseable { val handler = fun(_: PubNub, pnEvent: PNEvent) { @@ -588,7 +591,15 @@ class ChatImpl( return } val message = (pnEvent as? MessageResult)?.message ?: return - val eventContent: EventContent = PNDataEncoder.decode(message) + val eventContent: EventContent = try { + PNDataEncoder.decode(message) + } catch (e: Exception) { + if (message.asMap()?.get("type")?.asString() == "custom") { + EventContent.Custom((message.decode() as Map) - "type") + } else { + throw e + } + } @Suppress("UNCHECKED_CAST") val payload = eventContent as? T ?: return diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt index 3631c0fa..0e6efe52 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/EventImpl.kt @@ -1,5 +1,8 @@ package com.pubnub.chat.internal +import com.pubnub.api.asMap +import com.pubnub.api.asString +import com.pubnub.api.decode import com.pubnub.api.models.consumer.history.PNFetchMessageItem import com.pubnub.chat.Chat import com.pubnub.chat.Event @@ -19,10 +22,19 @@ class EventImpl( channelId: String, pnFetchMessageItem: PNFetchMessageItem ): Event { + val eventContent: EventContent = try { + PNDataEncoder.decode(pnFetchMessageItem.message) + } catch (e: Exception) { + if (pnFetchMessageItem.message.asMap()?.get("type")?.asString() == "custom") { + EventContent.Custom((pnFetchMessageItem.message.decode() as Map) - "type") + } else { + throw e + } + } return EventImpl( chat = chat, timetoken = pnFetchMessageItem.timetoken ?: 0, - payload = PNDataEncoder.decode(pnFetchMessageItem.message), + payload = eventContent, channelId = channelId, userId = pnFetchMessageItem.uuid ?: "unknown-user" ) diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/MessageImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/MessageImpl.kt index ad059309..5f2a2756 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/MessageImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/MessageImpl.kt @@ -39,11 +39,14 @@ data class MessageImpl( companion object { internal fun fromDTO(chat: ChatInternal, pnMessageResult: PNMessageResult): Message { - val content = chat.config.customPayloads?.getMessageResponseBody?.invoke(pnMessageResult.message) ?: defaultGetMessageResponseBody(pnMessageResult.message) + val content = + chat.config.customPayloads?.getMessageResponseBody?.invoke(pnMessageResult.message) + ?: defaultGetMessageResponseBody(pnMessageResult.message) + ?: EventContent.UnknownMessageFormat(pnMessageResult.message) return MessageImpl( chat, pnMessageResult.timetoken!!, - content!!, // todo handle malformed content (then this is null) + content, pnMessageResult.channel, pnMessageResult.publisher!!, meta = pnMessageResult.userMetadata?.decode() as? Map, @@ -54,12 +57,15 @@ data class MessageImpl( } internal fun fromDTO(chat: ChatInternal, messageItem: PNFetchMessageItem, channelId: String): Message { - val content = chat.config.customPayloads?.getMessageResponseBody?.invoke(messageItem.message) ?: defaultGetMessageResponseBody(messageItem.message) + val content = + chat.config.customPayloads?.getMessageResponseBody?.invoke(messageItem.message) + ?: defaultGetMessageResponseBody(messageItem.message) + ?: EventContent.UnknownMessageFormat(messageItem.message) return MessageImpl( chat, messageItem.timetoken!!, - content!!, + content, channelId, messageItem.uuid!!, messageItem.actions, diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/ThreadMessageImpl.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/ThreadMessageImpl.kt index 2506f26e..299eb5bc 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/ThreadMessageImpl.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/message/ThreadMessageImpl.kt @@ -1,6 +1,5 @@ package com.pubnub.chat.internal.message -import com.pubnub.api.asString import com.pubnub.api.decode import com.pubnub.api.models.consumer.history.PNFetchMessageItem import com.pubnub.api.models.consumer.history.PNFetchMessageItem.Action @@ -10,6 +9,7 @@ import com.pubnub.chat.ThreadMessage import com.pubnub.chat.internal.ChatImpl import com.pubnub.chat.internal.ChatInternal import com.pubnub.chat.internal.channel.ChannelImpl +import com.pubnub.chat.internal.defaultGetMessageResponseBody import com.pubnub.chat.internal.error.PubNubErrorMessage.PARENT_CHANNEL_DOES_NOT_EXISTS import com.pubnub.chat.internal.serialization.PNDataEncoder import com.pubnub.chat.internal.util.pnError @@ -53,11 +53,15 @@ data class ThreadMessageImpl( private val log = logging() internal fun fromDTO(chat: ChatImpl, pnMessageResult: PNMessageResult, parentChannelId: String): ThreadMessage { + val content = + chat.config.customPayloads?.getMessageResponseBody?.invoke(pnMessageResult.message) + ?: defaultGetMessageResponseBody(pnMessageResult.message) + ?: EventContent.UnknownMessageFormat(pnMessageResult.message) return ThreadMessageImpl( chat, parentChannelId, pnMessageResult.timetoken!!, - PNDataEncoder.decode(pnMessageResult.message) as EventContent.TextMessageContent, + content, pnMessageResult.channel, pnMessageResult.publisher!!, meta = pnMessageResult.userMetadata?.decode() as? Map, @@ -68,19 +72,16 @@ data class ThreadMessageImpl( } internal fun fromDTO(chat: ChatInternal, messageItem: PNFetchMessageItem, channelId: String, parentChannelId: String): ThreadMessage { - val eventContent = try { - messageItem.message.asString()?.let { text -> - EventContent.TextMessageContent(text, null) - } ?: PNDataEncoder.decode(messageItem.message) - } catch (e: Exception) { - EventContent.UnknownMessageFormat(messageItem.message) - } + val content = + chat.config.customPayloads?.getMessageResponseBody?.invoke(messageItem.message) + ?: defaultGetMessageResponseBody(messageItem.message) + ?: EventContent.UnknownMessageFormat(messageItem.message) return ThreadMessageImpl( chat = chat, parentChannelId = parentChannelId, timetoken = messageItem.timetoken!!, - content = eventContent, + content = content, channelId = channelId, userId = messageItem.uuid!!, actions = messageItem.actions, diff --git a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/utils/json.kt b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/utils/json.kt index 4f4a2c80..0d7ab220 100644 --- a/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/utils/json.kt +++ b/pubnub-chat-impl/src/commonMain/kotlin/com/pubnub/chat/internal/utils/json.kt @@ -1,6 +1,7 @@ import com.pubnub.chat.internal.defaultGetMessagePublishBody import com.pubnub.chat.internal.serialization.PNDataEncoder import com.pubnub.chat.types.EventContent +import kotlinx.serialization.InternalSerializationApi internal fun Any?.tryLong(): Long? { return when (this) { @@ -28,9 +29,9 @@ internal fun Any?.tryDouble(): Double? { internal fun EventContent.TextMessageContent.encodeForSending( channelId: String, - getMessagePublishBody: ((m: EventContent.TextMessageContent, channelId: String) -> Map)? = null, + getMessagePublishBody: ((m: EventContent.TextMessageContent, channelId: String) -> Map)? = null, mergeMessageWith: Map? = null, -): Map { +): Map { var finalMessage = getMessagePublishBody?.invoke(this, channelId) ?: defaultGetMessagePublishBody(this, channelId) if (mergeMessageWith != null) { finalMessage = buildMap { @@ -41,10 +42,19 @@ internal fun EventContent.TextMessageContent.encodeForSending( return finalMessage } +@OptIn(InternalSerializationApi::class) internal fun EventContent.encodeForSending( mergeMessageWith: Map? = null, -): Map { - var finalMessage = PNDataEncoder.encode(this) as Map +): Map { + var finalMessage = if (this is EventContent.Custom) { + buildMap { + putAll(data) + put("type", "custom") + } + } else { + PNDataEncoder.encode(this) as Map + } + if (mergeMessageWith != null) { finalMessage = buildMap { putAll(finalMessage) diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt index 3e17f96b..32427be2 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChannelIntegrationTest.kt @@ -244,7 +244,7 @@ class ChannelIntegrationTest : BaseChatIntegrationTest() { assertEquals(userName, userSuggestionsMembershipsFromCache.first().user.name) } - // todo fix + // todo flaky @Test fun streamReadReceipts() = runTest(timeout = 10.seconds) { val completableBeforeMark = CompletableDeferred() @@ -261,22 +261,27 @@ class ChannelIntegrationTest : BaseChatIntegrationTest() { chat.markAllMessagesAsRead().await() val tt = channel.sendText("text2").await().timetoken - val dispose = channel.streamReadReceipts { receipts -> - val lastRead = receipts.entries.find { it.value.contains(chat.currentUser.id) }?.key - if (lastRead != null) { - if (tt > lastRead) { - completableBeforeMark.complete(Unit) // before calling markAllMessagesRead - } else { - completableAfterMark.complete(Unit) // after calling markAllMessagesRead + var dispose: AutoCloseable? = null + pubnub.test(backgroundScope, checkAllEvents = false) { + pubnub.awaitSubscribe(listOf(channel.id)) { + dispose = channel.streamReadReceipts { receipts -> + val lastRead = receipts.entries.find { it.value.contains(chat.currentUser.id) }?.key + if (lastRead != null) { + if (tt > lastRead) { + completableBeforeMark.complete(Unit) // before calling markAllMessagesRead + } else { + completableAfterMark.complete(Unit) // after calling markAllMessagesRead + } + } } } - } - completableBeforeMark.await() - chat.markAllMessagesAsRead().await() - completableAfterMark.await() + completableBeforeMark.await() + chat.markAllMessagesAsRead().await() + completableAfterMark.await() - dispose.close() + dispose?.close() + } } @Test @@ -440,32 +445,37 @@ class ChannelIntegrationTest : BaseChatIntegrationTest() { val message = channel01.getMessage(timetoken).await()!! val assertionErrorInCallback = CompletableDeferred() - val streamMessageReports = channel01.streamMessageReports { reportEvent: Event -> - try { - // we need to have try/catch here because assertion error will not cause test to fail - numberOfReports.incrementAndGet() - val reportReason = reportEvent.payload.reason - assertTrue(reportReason == reason01 || reportReason == reason02) - assertEquals(messageText, reportEvent.payload.text) - assertTrue(reportEvent.payload.reportedMessageChannelId?.contains(INTERNAL_MODERATION_PREFIX)!!) - assertTrue(reportEvent.channelId.contains(INTERNAL_MODERATION_PREFIX)) - if (numberOfReports.value == 2) { - assertionErrorInCallback.complete(null) + pubnub.test(backgroundScope, checkAllEvents = false) { + var streamMessageReportsCloseable: AutoCloseable? = null + + pubnub.awaitSubscribe(listOf("PUBNUB_INTERNAL_MODERATION_${channel01.id}")) { + streamMessageReportsCloseable = channel01.streamMessageReports { reportEvent: Event -> + try { + // we need to have try/catch here because assertion error will not cause test to fail + numberOfReports.incrementAndGet() + val reportReason = reportEvent.payload.reason + assertTrue(reportReason == reason01 || reportReason == reason02) + assertEquals(messageText, reportEvent.payload.text) + assertTrue(reportEvent.payload.reportedMessageChannelId?.contains(INTERNAL_MODERATION_PREFIX)!!) + assertTrue(reportEvent.channelId.contains(INTERNAL_MODERATION_PREFIX)) + if (numberOfReports.value == 2) { + assertionErrorInCallback.complete(null) + } + } catch (e: AssertionError) { + assertionErrorInCallback.complete(e) + } } - } catch (e: AssertionError) { - assertionErrorInCallback.complete(e) } - } - delayInMillis(550) - // report messages - message.report(reason01).await() - message.report(reason02).await() + // report messages + message.report(reason01).await() + message.report(reason02).await() - assertionErrorInCallback.await()?.let { assertionError -> throw (assertionError) } - assertEquals(2, numberOfReports.value) + assertionErrorInCallback.await()?.let { assertionError -> throw (assertionError) } + assertEquals(2, numberOfReports.value) - streamMessageReports.close() + streamMessageReportsCloseable?.close() + } } } diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatConfigurationIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatConfigurationIntegrationTest.kt new file mode 100644 index 00000000..bedb2620 --- /dev/null +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatConfigurationIntegrationTest.kt @@ -0,0 +1,72 @@ +import com.pubnub.api.JsonElement +import com.pubnub.api.asList +import com.pubnub.api.asMap +import com.pubnub.api.asString +import com.pubnub.chat.Message +import com.pubnub.chat.config.ChatConfiguration +import com.pubnub.chat.config.CustomPayloads +import com.pubnub.chat.internal.ChatImpl +import com.pubnub.chat.types.EventContent +import com.pubnub.chat.types.File +import com.pubnub.integration.BaseChatIntegrationTest +import com.pubnub.test.await +import com.pubnub.test.randomString +import com.pubnub.test.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ChatConfigurationIntegrationTest : BaseChatIntegrationTest() { + @Test + fun custom_payloads_send_receive_msgs() = runTest { + val chat = ChatImpl( + ChatConfiguration( + customPayloads = CustomPayloads( + getMessagePublishBody = { content, channelId -> + mapOf( + "custom" to mapOf( + "payload" to mapOf( + "text" to content.text + ) + ), + "files" to content.files, +// "type" to "text" + ) + }, + getMessageResponseBody = { json: JsonElement -> + EventContent.TextMessageContent( + json.asMap()?.get("custom")?.asMap()?.get("payload")?.asMap()?.get("text")?.asString()!!, + json.asList()?.map { + File( + it.asMap()?.get("name")?.asString()!!, + it.asMap()?.get("id")?.asString()!!, + it.asMap()?.get("url")?.asString()!!, + it.asMap()?.get("type")?.asString(), + ) + } + ) + } + ) + ), + pubnub + ).initialize().await() + val channel = chat.createChannel(randomString()).await() + val messageText = randomString() + val message = CompletableDeferred() + + pubnub.test(backgroundScope, checkAllEvents = false) { + var unsubscribe: AutoCloseable? = null + pubnub.awaitSubscribe { + unsubscribe = channel.connect { + message.complete(it) + } + } + channel.sendText(messageText).await() + assertEquals(messageText, message.await().text) + assertFalse(channel.getMembers().await().members.any { it.user.id == chat.currentUser.id }) + unsubscribe?.close() + } + } +} diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt index 8f299f73..ed181ac8 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/ChatIntegrationTest.kt @@ -20,6 +20,7 @@ import com.pubnub.chat.membership.MembershipsResponse import com.pubnub.chat.message.GetUnreadMessagesCounts import com.pubnub.chat.message.MarkAllMessageAsReadResponse import com.pubnub.chat.types.ChannelMentionData +import com.pubnub.chat.types.EmitEventMethod import com.pubnub.chat.types.EventContent import com.pubnub.chat.types.GetCurrentUserMentionsResult import com.pubnub.chat.types.GetEventsHistoryResult @@ -31,6 +32,7 @@ import com.pubnub.kmp.CustomObject import com.pubnub.kmp.createCustomObject import com.pubnub.test.await import com.pubnub.test.randomString +import com.pubnub.test.test import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import tryLong @@ -156,64 +158,78 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { // register lister of "Receipt" event val assertionErrorInListener01 = CompletableDeferred() - val removeListenerAndUnsubscribe01: AutoCloseable = chat.listenForEvents( - channelId = channelId01 - ) { event: Event -> - try { - // we need to have try/catch here because assertion error will not cause test to fail - assertEquals(channelId01, event.channelId) - assertEquals(chat.currentUser.id, event.userId) - assertNotEquals(lastReadMessageTimetokenValue, event.payload.messageTimetoken) - assertEquals(lastPublishToChannel01.timetoken, event.payload.messageTimetoken) - assertionErrorInListener01.complete(null) - } catch (e: AssertionError) { - assertionErrorInListener01.complete(e) - } - } val assertionErrorInListener02 = CompletableDeferred() - val removeListenerAndUnsubscribe02 = chat.listenForEvents( - type = EventContent.Receipt::class, - channelId = channelId02 - ) { event: Event -> - try { - // we need to have try/catch here because assertion error will not cause test to fail - assertEquals(channelId02, event.channelId) - assertEquals(chat.currentUser.id, event.userId) - assertNotEquals(lastReadMessageTimetokenValue, event.payload.messageTimetoken) - assertEquals(lastPublishToChannel02.timetoken, event.payload.messageTimetoken) - assertionErrorInListener02.complete(null) - } catch (e: AssertionError) { - assertionErrorInListener02.complete(e) + + var removeListenerAndUnsubscribe01: AutoCloseable? = null + var removeListenerAndUnsubscribe02: AutoCloseable? = null + pubnub.test(backgroundScope, checkAllEvents = false) { + pubnub.awaitSubscribe(listOf(channel01.id, channel02.id)) { + removeListenerAndUnsubscribe01 = chat.listenForEvents( + channelId = channelId01 + ) { event: Event -> + try { + // we need to have try/catch here because assertion error will not cause test to fail + assertEquals(channelId01, event.channelId) + assertEquals(chat.currentUser.id, event.userId) + assertNotEquals(lastReadMessageTimetokenValue, event.payload.messageTimetoken) + assertEquals(lastPublishToChannel01.timetoken, event.payload.messageTimetoken) + assertionErrorInListener01.complete(null) + } catch (e: AssertionError) { + assertionErrorInListener01.complete(e) + } + } + + removeListenerAndUnsubscribe02 = chat.listenForEvents( + type = EventContent.Receipt::class, + channelId = channelId02 + ) { event: Event -> + try { + // we need to have try/catch here because assertion error will not cause test to fail + assertEquals(channelId02, event.channelId) + assertEquals(chat.currentUser.id, event.userId) + assertNotEquals(lastReadMessageTimetokenValue, event.payload.messageTimetoken) + assertEquals(lastPublishToChannel02.timetoken, event.payload.messageTimetoken) + assertionErrorInListener02.complete(null) + } catch (e: AssertionError) { + assertionErrorInListener02.complete(e) + } + } } - } - // then - val markAllMessageAsReadResponse: MarkAllMessageAsReadResponse = chat.markAllMessagesAsRead().await() + // then + val markAllMessageAsReadResponse: MarkAllMessageAsReadResponse = chat.markAllMessagesAsRead().await() - // verify response contains updated "lastReadMessageTimetoken" - markAllMessageAsReadResponse.memberships.forEach { membership: Membership -> - // why membership.custom!!["lastReadMessageTimetoken"] returns double? <--this is default behaviour of GSON - assertNotEquals(lastReadMessageTimetokenValue, membership.custom!!["lastReadMessageTimetoken"].tryLong()) - } + // verify response contains updated "lastReadMessageTimetoken" + markAllMessageAsReadResponse.memberships.forEach { membership: Membership -> + // why membership.custom!!["lastReadMessageTimetoken"] returns double? <--this is default behaviour of GSON + assertNotEquals( + lastReadMessageTimetokenValue, + membership.custom!!["lastReadMessageTimetoken"].tryLong() + ) + } - // verify each Membership has updated custom value for "lastReadMessageTimetoken" - val userMembership: MembershipsResponse = chat.currentUser.getMemberships().await() - userMembership.memberships.forEach { membership: Membership -> - assertNotEquals(lastReadMessageTimetokenValue, membership.custom!!["lastReadMessageTimetoken"].tryLong()) - } + // verify each Membership has updated custom value for "lastReadMessageTimetoken" + val userMembership: MembershipsResponse = chat.currentUser.getMemberships().await() + userMembership.memberships.forEach { membership: Membership -> + assertNotEquals( + lastReadMessageTimetokenValue, + membership.custom!!["lastReadMessageTimetoken"].tryLong() + ) + } - // verify assertion inside listeners - assertionErrorInListener01.await()?.let { throw it } - assertionErrorInListener02.await()?.let { throw it } + // verify assertion inside listeners + assertionErrorInListener01.await()?.let { throw it } + assertionErrorInListener02.await()?.let { throw it } - // remove messages - chat.pubNub.deleteMessages(listOf(channelId01, channelId02)) + // remove messages + chat.pubNub.deleteMessages(listOf(channelId01, channelId02)) - // remove listeners and unsubscribe - removeListenerAndUnsubscribe01.close() - removeListenerAndUnsubscribe02.close() + // remove listeners and unsubscribe + removeListenerAndUnsubscribe01?.close() + removeListenerAndUnsubscribe02?.close() - // remove memberships (user). This will be done in tearDown method + // remove memberships (user). This will be done in tearDown method + } } @Ignore // fails from time to time @@ -470,6 +486,29 @@ class ChatIntegrationTest : BaseChatIntegrationTest() { chat.pubNub.deleteMessages(listOf(channelId01, userId)) } + @Test + fun emitEvent_with_custom() = runTest { + val channel = chat.createChannel(randomString()).await() + val event = CompletableDeferred>() + var tt: Long = 0 + pubnub.test(backgroundScope, checkAllEvents = false) { + var unsubscribe: AutoCloseable? = null + pubnub.awaitSubscribe { + unsubscribe = chat.listenForEvents(channel.id) { + event.complete(it) + } + } + tt = chat.emitEvent(channel.id, EventContent.Custom(mapOf("abc" to "def"), EmitEventMethod.PUBLISH)).await().timetoken + assertEquals(mapOf("abc" to "def"), event.await().payload.data) + assertFalse(channel.getMembers().await().members.any { it.user.id == chat.currentUser.id }) + unsubscribe?.close() + } + delayInMillis(1000) + val eventFromHistory = chat.getEventsHistory(channel.id, tt + 1, tt).await().events.first() + require(eventFromHistory.payload is EventContent.Custom) + assertEquals(mapOf("abc" to "def"), (eventFromHistory.payload as EventContent.Custom).data) + } + private suspend fun assertPushChannels(expectedNumberOfChannels: Int) { val pushChannels = chat.getPushChannels().await() assertEquals(expectedNumberOfChannels, pushChannels.size) diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MessageIntegrationTest.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MessageIntegrationTest.kt index e0f572f9..fa060b4c 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MessageIntegrationTest.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/integration/MessageIntegrationTest.kt @@ -99,34 +99,37 @@ class MessageIntegrationTest : BaseChatIntegrationTest() { val reason = "rude" val assertionErrorInListener01 = CompletableDeferred() val channelId = "$INTERNAL_MODERATION_PREFIX${channel01.id}" - val removeListenerAndUnsubscribe: AutoCloseable = chat.listenForEvents( - channelId = channelId, - callback = { event: Event -> - println("-= in listenForEvents") - try { - // we need to have try/catch here because assertion error will not cause test to fail - assertEquals(reason, event.payload.reason) - assertEquals(channelId, event.payload.reportedMessageChannelId) - assertEquals(channelId, event.channelId) - assertEquals(someUser.id, event.payload.reportedUserId) - assertEquals(timetoken, event.payload.reportedMessageTimetoken) - assertionErrorInListener01.complete(null) - } catch (e: AssertionError) { - assertionErrorInListener01.complete(e) - } + pubnub.test(backgroundScope, checkAllEvents = false) { + var removeListenerAndUnsubscribe: AutoCloseable? = null + pubnub.awaitSubscribe(listOf(channelId)) { + removeListenerAndUnsubscribe = chat.listenForEvents( + channelId = channelId, + callback = { event: Event -> + println("-= in listenForEvents") + try { + // we need to have try/catch here because assertion error will not cause test to fail + assertEquals(reason, event.payload.reason) + assertEquals(channelId, event.payload.reportedMessageChannelId) + assertEquals(channelId, event.channelId) + assertEquals(someUser.id, event.payload.reportedUserId) + assertEquals(timetoken, event.payload.reportedMessageTimetoken) + assertionErrorInListener01.complete(null) + } catch (e: AssertionError) { + assertionErrorInListener01.complete(e) + } + } + ) } - ) - delayInMillis(150) - - // when - val message: Message = channel01.getMessage(timetoken).await()!! - message.report(reason).await() + // when + val message: Message = channel01.getMessage(timetoken).await()!! + message.report(reason).await() - // then - assertionErrorInListener01.await()?.let { throw it } + // then + assertionErrorInListener01.await()?.let { throw it } - // cleanup - removeListenerAndUnsubscribe.close() + // cleanup + removeListenerAndUnsubscribe?.close() + } } private fun getDeletedActionMap() = mapOf( diff --git a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt index 5a1aecf8..acf3fab4 100644 --- a/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt +++ b/pubnub-chat-impl/src/commonTest/kotlin/com/pubnub/kmp/utils/FakeChat.kt @@ -203,7 +203,7 @@ abstract class FakeChat(override val config: ChatConfiguration, override val pub override fun listenForEvents( type: KClass, channelId: String, - customMethod: EmitEventMethod?, + customMethod: EmitEventMethod, callback: (event: Event) -> Unit, ): AutoCloseable { TODO("Not yet implemented") diff --git a/pubnub-kotlin b/pubnub-kotlin index e47ee8d7..22a7566d 160000 --- a/pubnub-kotlin +++ b/pubnub-kotlin @@ -1 +1 @@ -Subproject commit e47ee8d7a3121238755b6e4f2baaa64463c6b606 +Subproject commit 22a7566d8d2faa335c377fbc8ac5422242a1f681