From 4b4a7222ea286520fa445cc415eb246429aab8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 15 Jul 2026 16:07:43 +0100 Subject: [PATCH 1/4] e2e: Port the iOS push notification tests --- .../chat/android/compose/robots/UserRobot.kt | 10 ++ .../compose/robots/UserRobotPushAsserts.kt | 45 +++++ .../compose/tests/PushNotificationTests.kt | 166 ++++++++++++++++++ .../src/e2e/AndroidManifest.xml | 4 + .../compose/sample/push/PushTestReceiver.kt | 44 +++++ .../compose/sample/ui/StartupActivity.kt | 38 +++- .../e2e/test/robots/ParticipantRobot.kt | 15 ++ 7 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt create mode 100644 stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/push/PushTestReceiver.kt diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 2081b237117..19497445107 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -108,6 +108,16 @@ class UserRobot { return this } + fun openNotificationShade(): UserRobot { + device.openNotification() + return this + } + + fun tapOnPushNotification(text: String): UserRobot { + By.text(text).waitToAppear().click() + return this + } + fun tapOnBackButton(): UserRobot { MessageListPage.Header.backButton.waitToAppear().click() return this diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt new file mode 100644 index 00000000000..04fbb824db6 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.robots + +import androidx.test.uiautomator.By +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed +import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue + +/** + * Opens the notification shade and asserts a notification with [text] is shown. + * The shade stays open so the notification can be tapped next. + */ +fun UserRobot.assertPushNotification(text: String): UserRobot { + openNotificationShade() + assertTrue(By.text(text).waitDisplayed()) + return this +} + +/** + * Opens the notification shade, asserts no notification with [text] is shown, and closes it. + */ +fun UserRobot.assertPushNotificationDoesNotAppear(text: String): UserRobot { + openNotificationShade() + assertFalse(By.text(text).waitToDisappear().isDisplayed()) + device.pressBack() + return this +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt new file mode 100644 index 00000000000..97caa94c9de --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.tests + +import io.getstream.chat.android.compose.robots.assertMessage +import io.getstream.chat.android.compose.robots.assertPushNotification +import io.getstream.chat.android.compose.robots.assertPushNotificationDoesNotAppear +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.goToBackground +import io.getstream.chat.android.e2e.test.uiautomator.goToForeground +import io.getstream.chat.android.e2e.test.uiautomator.packageName +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import org.junit.Test + +class PushNotificationTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + + private val pushMessage = "Push test message" + private val pushReceiverComponent get() = "$packageName/$PUSH_RECEIVER_CLASS" + + @AllureId("5715") + @Test + fun test_pushNotificationFromMessageList() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND user goes to background") { + device.goToBackground() + } + step("WHEN participant sends a message and its push notification") { + participantRobot + .sendMessage(pushMessage) + .sendPushNotification(pushReceiverComponent) + } + step("THEN user receives the push notification") { + userRobot.assertPushNotification(pushMessage) + } + step("WHEN user taps on the push notification") { + userRobot.tapOnPushNotification(pushMessage) + } + step("THEN the message list shows the message") { + userRobot.assertMessage(pushMessage) + } + } + + @AllureId("5832") + @Test + fun test_pushNotificationFromChannelList() { + step("GIVEN user opens the channel and goes back to the channel list") { + userRobot.login().openChannel().tapOnBackButton() + } + step("AND user goes to background") { + device.goToBackground() + } + step("WHEN participant sends a message and its push notification") { + participantRobot + .sendMessage(pushMessage) + .sendPushNotification(pushReceiverComponent) + } + step("THEN user receives the push notification") { + userRobot.assertPushNotification(pushMessage) + } + step("WHEN user taps on the push notification") { + userRobot.tapOnPushNotification(pushMessage) + } + step("THEN the message list shows the message") { + userRobot.assertMessage(pushMessage) + } + } + + @AllureId("5662") + @Test + fun test_pushNotification_optionalValuesEqualToNil() { + assertPushNotificationWithDegradedPayload(rest = "null") + } + + @AllureId("5834") + @Test + fun test_pushNotification_optionalValuesAreEmpty() { + assertPushNotificationWithDegradedPayload(rest = "empty") + } + + @AllureId("5835") + @Test + fun test_pushNotification_optionalValuesContainIncorrectType() { + assertPushNotificationWithDegradedPayload(rest = "incorrect_type") + } + + @AllureId("5836") + @Test + fun test_pushNotification_optionalValuesContainIncorrectData() { + assertPushNotificationWithDegradedPayload(rest = "incorrect_data") + } + + @AllureId("5837") + @Test + fun test_pushNotification_requiredValuesAreInvalid() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND user goes to background") { + device.goToBackground() + } + listOf("invalid_version", "invalid_sender", "invalid_type").forEach { rest -> + step("WHEN participant sends a push notification with $rest in the payload") { + participantRobot + .sendMessage(rest) + .sendPushNotification(pushReceiverComponent, rest = rest) + } + step("THEN user does not receive a push notification") { + userRobot.assertPushNotificationDoesNotAppear(rest) + } + } + step("WHEN user comes back to foreground") { + device.goToForeground() + } + step("THEN the message list shows all messages") { + userRobot + .assertMessage("invalid_version") + .assertMessage("invalid_sender") + .assertMessage("invalid_type") + } + } + + private fun assertPushNotificationWithDegradedPayload(rest: String) { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND user goes to background") { + device.goToBackground() + } + step("WHEN participant sends a message and a push notification with $rest optional values") { + participantRobot + .sendMessage(pushMessage) + .sendPushNotification(pushReceiverComponent, rest = rest) + } + step("THEN user receives the push notification") { + userRobot.assertPushNotification(pushMessage) + } + step("WHEN user taps on the push notification") { + userRobot.tapOnPushNotification(pushMessage) + } + step("THEN the message list shows the message") { + userRobot.assertMessage(pushMessage) + } + } +} + +private const val PUSH_RECEIVER_CLASS = "io.getstream.chat.android.compose.sample.push.PushTestReceiver" diff --git a/stream-chat-android-compose-sample/src/e2e/AndroidManifest.xml b/stream-chat-android-compose-sample/src/e2e/AndroidManifest.xml index d00533ea394..b2e3809faf7 100644 --- a/stream-chat-android-compose-sample/src/e2e/AndroidManifest.xml +++ b/stream-chat-android-compose-sample/src/e2e/AndroidManifest.xml @@ -21,5 +21,9 @@ android:exported="true" > + + \ No newline at end of file diff --git a/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/push/PushTestReceiver.kt b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/push/PushTestReceiver.kt new file mode 100644 index 00000000000..7af9c5bb541 --- /dev/null +++ b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/push/PushTestReceiver.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.push + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.google.firebase.messaging.RemoteMessage +import io.getstream.android.push.firebase.FirebaseMessagingDelegate + +/** + * Feeds a push payload into the production push pipeline during E2E tests. The mock server + * delivers the payload as an adb broadcast; every string extra becomes an entry of the + * [RemoteMessage] data map, which then goes through the same validation and rendering path + * as a real FCM message. + */ +internal class PushTestReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras ?: return + val data = extras.keySet() + .mapNotNull { key -> extras.getString(key)?.let { value -> key to value } } + .toMap() + FirebaseMessagingDelegate.handleRemoteMessage(RemoteMessage.Builder(SENDER).setData(data).build()) + } + + private companion object { + private const val SENDER = "test@fcm.googleapis.com" + } +} diff --git a/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt index 1ecac3ec15c..ed20a440585 100644 --- a/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt +++ b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt @@ -20,23 +20,50 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder import io.getstream.chat.android.compose.sample.ChatHelper import io.getstream.chat.android.compose.sample.data.PredefinedUserCredentials import io.getstream.chat.android.compose.sample.data.customSettings +import io.getstream.chat.android.compose.sample.feature.channel.list.ChannelsActivity +import io.getstream.chat.android.compose.sample.ui.channel.ChannelActivity class StartupActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - ChatHelper.initializeSdk(applicationContext, PredefinedUserCredentials.API_KEY, intent.getStringExtra("BASE_URL")) - customSettings().isComposerLinkPreviewEnabled = true + // The harness always launches with BASE_URL; a launch without it comes from a + // notification tap, where the already initialized client must stay untouched. + val baseUrl = intent.getStringExtra("BASE_URL") + if (baseUrl != null) { + ChatHelper.initializeSdk(applicationContext, PredefinedUserCredentials.API_KEY, baseUrl) + customSettings().isComposerLinkPreviewEnabled = true + } - val initTestActivity = intent.getSerializableExtra("InitTestActivity") as InitTestActivity - startActivity(initTestActivity.createIntent(this@StartupActivity)) + val initTestActivity = intent.getSerializableExtra("InitTestActivity") as? InitTestActivity + if (initTestActivity != null) { + startActivity(initTestActivity.createIntent(this@StartupActivity)) + } else { + // Navigating from a push notification, route to the messages screen + val channelId = requireNotNull(intent.getStringExtra(KEY_CHANNEL_ID)) + TaskStackBuilder.create(applicationContext) + .addNextIntent(ChannelsActivity.createIntent(applicationContext)) + .addNextIntent( + ChannelActivity.createIntent( + context = applicationContext, + channelId = channelId, + messageId = intent.getStringExtra(KEY_MESSAGE_ID), + parentMessageId = intent.getStringExtra(KEY_PARENT_MESSAGE_ID), + ), + ) + .startActivities() + } finish() } companion object { + private const val KEY_CHANNEL_ID = "channelId" + private const val KEY_MESSAGE_ID = "messageId" + private const val KEY_PARENT_MESSAGE_ID = "parentMessageId" fun createIntent( context: Context, @@ -45,6 +72,9 @@ class StartupActivity : AppCompatActivity() { parentMessageId: String?, ): Intent { return Intent(context, StartupActivity::class.java) + .putExtra(KEY_CHANNEL_ID, channelId) + .putExtra(KEY_MESSAGE_ID, messageId) + .putExtra(KEY_PARENT_MESSAGE_ID, parentMessageId) } } } diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt index 45c6b98fb0d..86b3da30414 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt @@ -56,6 +56,21 @@ public class ParticipantRobot( return this } + /** + * Delivers a push notification for the last message to the Android app under test. + * + * @param component The broadcast receiver component of the app under test. + * @param rest Optional payload degradation, matching the mock server's `rest` values. + */ + public fun sendPushNotification(component: String, rest: String? = null): ParticipantRobot { + var endpoint = "participant/push?component=$component" + if (rest != null) { + endpoint += "&rest=$rest" + } + mockServer.postRequest(endpoint) + return this + } + public fun sendMessage(text: String, delay: Int = 0): ParticipantRobot { var endpoint = "participant/message" if (delay > 0) { From d21ff144e8284ace44fc787f1a9de40fb1e2e014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 15 Jul 2026 20:29:42 +0100 Subject: [PATCH 2/4] e2e: Wait the full window for absent push and recover the shade between tests --- .../chat/android/compose/robots/UserRobotPushAsserts.kt | 8 ++++---- .../chat/android/compose/tests/StreamTestCase.kt | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt index 04fbb824db6..860a041ee7d 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPushAsserts.kt @@ -18,9 +18,7 @@ package io.getstream.chat.android.compose.robots import androidx.test.uiautomator.By import io.getstream.chat.android.e2e.test.uiautomator.device -import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed -import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -35,11 +33,13 @@ fun UserRobot.assertPushNotification(text: String): UserRobot { } /** - * Opens the notification shade, asserts no notification with [text] is shown, and closes it. + * Opens the notification shade, asserts no notification with [text] appears within the + * delivery window, and closes it. Polls for the full timeout so the assertion cannot pass + * before the push has had time to be delivered and rendered. */ fun UserRobot.assertPushNotificationDoesNotAppear(text: String): UserRobot { openNotificationShade() - assertFalse(By.text(text).waitToDisappear().isDisplayed()) + assertFalse(By.text(text).waitDisplayed()) device.pressBack() return this } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt index dcc0ec69efd..3c75881af3d 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt @@ -59,6 +59,9 @@ abstract class StreamTestCase { backendRobot = BackendRobot(mockServer) participantRobot = ParticipantRobot(mockServer) } + // Start from a clean device state: a prior test may have left the notification + // shade open, which would cover the app and break this test's first interaction. + device.pressHome() startApp() grantAppPermissions() } From 7fef8320f379d6b234dcc5efdb2e28f436cf3539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 16 Jul 2026 15:11:27 +0100 Subject: [PATCH 3/4] e2e: Send the push platform param to the mock server --- .../getstream/chat/android/e2e/test/robots/ParticipantRobot.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt index 86b3da30414..7d7bb5a289d 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt @@ -63,7 +63,7 @@ public class ParticipantRobot( * @param rest Optional payload degradation, matching the mock server's `rest` values. */ public fun sendPushNotification(component: String, rest: String? = null): ParticipantRobot { - var endpoint = "participant/push?component=$component" + var endpoint = "participant/push?platform=android&component=$component" if (rest != null) { endpoint += "&rest=$rest" } From 716f71c817e6533d4e28e49b0aa6b71bed98f8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 17 Jul 2026 10:10:06 +0100 Subject: [PATCH 4/4] Document the degraded-payload push test helper --- .../compose/tests/PushNotificationTests.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt index 97caa94c9de..32be23ba427 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PushNotificationTests.kt @@ -139,6 +139,22 @@ class PushNotificationTests : StreamTestCase() { } } + /** + * Verifies a push notification is still delivered and opens the message when the payload's + * optional values are degraded but its required keys stay valid. + * + * [rest] selects how the mock server degrades the payload: + * - `null`: omits the optional title and body + * - `empty`: sends an empty title and body + * - `incorrect_type`: sends a wrong-type title and junk badge fields + * - `incorrect_data`: sends out-of-range badge fields + * + * In all of these the required keys (`version`, `sender`, `type`, `message_id`, `cid`) stay + * valid, so the client accepts the push, shows it, and opens the message on tap. Payloads that + * break a required key are covered by [test_pushNotification_requiredValuesAreInvalid]. + * + * @param rest The mock server's payload-degradation mode. + */ private fun assertPushNotificationWithDegradedPayload(rest: String) { step("GIVEN user opens the channel") { userRobot.login().openChannel()