Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.waitDisplayed
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] 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).waitDisplayed())
device.pressBack()
return this
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* 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")
}
}

/**
* 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) {
Comment thread
VelikovPetar marked this conversation as resolved.
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"
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@
android:exported="true"
>
</activity>

<receiver
android:name=".push.PushTestReceiver"
android:exported="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?platform=android&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) {
Expand Down
Loading