urbanairship / android-library

Urban Airship Android SDK
Other
109 stars 123 forks source link

v18.0.0 `InAppMessageListener` missing from API #239

Open wezley98 opened 2 months ago

wezley98 commented 2 months ago

Hey, Just looking at the v18.0.0 migration guide but couldn't see anything relating to InAppMessageListener no longer been available.

Screenshot 2024-06-17 at 16 23 08

Could you please provide instructions on how to migrate these imports in v18.0.0?

jyaganeh commented 2 months ago

Hi @wezley98, would you be able to share a snippet (or description) of how you're using those APIs in SDK 17? If you'd rather not post it publicly, my email is: josh.yaganeh @ airship.com

wezley98 commented 2 months ago

@jyaganeh Here you go.

package com.xxxx.xxxxx.xxxxx

import com.xxxx.xxxxx.interfaces.notifications.InAppNotificationMessage
import com.xxxx.xxxx.logging.data.Log
import com.urbanairship.channel.AirshipChannelListener
import com.urbanairship.iam.DisplayContent
import com.urbanairship.iam.InAppMessage
import com.urbanairship.iam.InAppMessageListener
import com.urbanairship.iam.ResolutionInfo
import com.urbanairship.iam.banner.BannerDisplayContent
import com.urbanairship.iam.fullscreen.FullScreenDisplayContent
import com.urbanairship.iam.html.HtmlDisplayContent
import com.urbanairship.iam.modal.ModalDisplayContent
import com.urbanairship.push.NotificationActionButtonInfo
import com.urbanairship.push.NotificationInfo
import com.urbanairship.push.NotificationListener
import com.urbanairship.push.PushListener
import com.urbanairship.push.PushMessage
import com.urbanairship.push.PushTokenListener

/**
 * Listener for push, notifications, and registrations events.
 */
@Suppress("TooManyFunctions", "FunctionOnlyReturningConstant", "UnusedPrivateMember")
class AirshipListener(
    private val notificationHandler: NotificationHandler,
    private val inAppNotification: InAppNotificationMessage
) : PushListener, NotificationListener, PushTokenListener, AirshipChannelListener, InAppMessageListener {

    override fun onNotificationPosted(notificationInfo: NotificationInfo) {
        Log.i("AirshipListener Notification posted: $notificationInfo")
    }

    override fun onNotificationOpened(notificationInfo: NotificationInfo): Boolean {
        notificationHandler.handleNotificationInfo(notificationInfo)
        return true
    }

    override fun onNotificationForegroundAction(notificationInfo: NotificationInfo, actionButtonInfo: NotificationActionButtonInfo): Boolean {
        Log.i("AirshipListener Notification action: $notificationInfo $actionButtonInfo")

        // Return false here to allow Airship to auto launch the launcher
        // activity for foreground notification action buttons
        notificationHandler.handleNotificationInfo(notificationInfo)
        return true
    }

    override fun onNotificationBackgroundAction(notificationInfo: NotificationInfo, actionButtonInfo: NotificationActionButtonInfo) {
        Log.i("AirshipListener Notification action: $notificationInfo $actionButtonInfo")
    }

    override fun onNotificationDismissed(notificationInfo: NotificationInfo) {
        Log.i(
            "AirshipListener Notification dismissed. Alert: ${notificationInfo.message.alert}. " +
                "Notification ID: ${notificationInfo.notificationId}"
        )
    }

    override fun onPushReceived(message: PushMessage, notificationPosted: Boolean) {
        Log.i(
            "AirshipListener Received push message. Alert: ${message.alert}. Posted notification: $notificationPosted"
        )
    }

    override fun onChannelCreated(channelId: String) {
        Log.i("AirshipListener Channel created $channelId")
    }

    override fun onPushTokenUpdated(token: String) {
        Log.i("AirshipListener Push token updated $token")
    }

    override fun onMessageDisplayed(scheduleId: String, message: InAppMessage) {
        Log.i("AirshipListener InAppAutomation message displayed:\nscheduleId:$scheduleId,\nmessage:$message")
        val contentTitle: String = getInAppMessageTitle(message)
        val contentMessage: String = getInAppMessageText(message)
        val buttons: List<String> = getInAppMessageButtons(message)
        inAppNotification.onMessageDisplayed(
            scheduleId = scheduleId,
            title = contentTitle,
            message = contentMessage,
            buttons = buttons
        )
    }

    override fun onMessageFinished(scheduleId: String, message: InAppMessage, resolutionInfo: ResolutionInfo) {
        Log.i(
            "AirshipListener InAppAutomation message display finished:" +
                "\nscheduleId:$scheduleId," +
                "\nmessage:$message," +
                "\nresolutionType:${resolutionInfo.type}" +
                "\nresolutionButton:${resolutionInfo.buttonInfo}" +
                "\nmessage extras:${message.extras}" +
                "\nmessage toJsonValue:${message.toJsonValue()}"
        )
        val contentTitle: String = getInAppMessageTitle(message)
        val contentMessage: String = getInAppMessageText(message)
        val buttons: List<String> = getInAppMessageButtons(message)

        val buttonInfo = resolutionInfo.buttonInfo
        val buttonText = buttonInfo?.label?.text.orEmpty()
        val buttonActions: MutableMap<String, String> = mutableMapOf()
        buttonInfo?.actions?.forEach {
            buttonActions[it.key] = it.value.toString()
        }
        inAppNotification.onMessageFinished(
            scheduleId = scheduleId,
            title = contentTitle,
            message = contentMessage,
            buttons = buttons,
            selected = buttonText,
            actions = buttonActions
        )
    }

    private fun getInAppMessageText(message: InAppMessage): String {
        val messageText = when (message.type) {
            InAppMessage.TYPE_MODAL -> getMessageContent<ModalDisplayContent>(message)?.body?.text.orEmpty()
            InAppMessage.TYPE_BANNER -> getMessageContent<BannerDisplayContent>(message)?.body?.text.orEmpty()
            InAppMessage.TYPE_FULLSCREEN -> getMessageContent<FullScreenDisplayContent>(message)?.body?.text.orEmpty()
            InAppMessage.TYPE_HTML -> getMessageContent<HtmlDisplayContent>(message)?.url.orEmpty()
            else -> "" // This is for CustomDisplayContent
        }

        Log.d("AirshipListener InAppAutomation message getInAppMessageText message is : $messageText")

        return messageText
    }

    private fun getInAppMessageTitle(message: InAppMessage): String {
        val title = when (message.type) {
            InAppMessage.TYPE_MODAL -> getMessageContent<ModalDisplayContent>(message)?.heading?.text.orEmpty()
            InAppMessage.TYPE_BANNER -> getMessageContent<BannerDisplayContent>(message)?.heading?.text.orEmpty()
            InAppMessage.TYPE_FULLSCREEN -> getMessageContent<FullScreenDisplayContent>(
                message
            )?.heading?.text.orEmpty()
            else -> "" // This is for CustomDisplayContent and HtmlDisplayContent
        }

        Log.d("AirshipListener InAppAutomation message getInAppMessageTitle title is : $title")

        return title
    }

    private fun getInAppMessageButtons(message: InAppMessage): List<String> {
        val buttons: MutableList<String> = mutableListOf()
        val contentButtons = when (message.type) {
            InAppMessage.TYPE_MODAL -> getMessageContent<ModalDisplayContent>(message)?.buttons
            InAppMessage.TYPE_BANNER -> getMessageContent<BannerDisplayContent>(message)?.buttons
            InAppMessage.TYPE_FULLSCREEN -> getMessageContent<FullScreenDisplayContent>(message)?.buttons
            else -> null // This is for CustomDisplayContent and HtmlDisplayContent
        }
        contentButtons?.forEach { buttonInfo ->
            buttonInfo.label.text?.let {
                buttons.add(it)
            }
        }
        return buttons
    }

    private fun <T : DisplayContent> getMessageContent(message: InAppMessage): T? {
        var content: T? = null
        try {
            content = message.getDisplayContent<T>()
        } catch (_: ClassCastException) {
            Log.d(
                "AirshipListener InAppAutomation message getMessageModalContent not applicable for type : ${message.type}"
            )
        }
        return content
    }
}
jyaganeh commented 2 months ago

Thanks @wezley98!

The DisplayContent class was restructured into a new sealed class: InAppMessageDisplayContent. Here's how to migrate the helpers at the bottom of your listener (the getMessageContent helper should no longer be needed):

private fun getInAppMessageText(message: InAppMessage): String {
    val messageText = when (val content = message.displayContent) {
        is InAppMessageDisplayContent.ModalContent -> content.modal.body?.text.orEmpty()
        is InAppMessageDisplayContent.BannerContent -> content.banner.body?.text.orEmpty()
        is InAppMessageDisplayContent.FullscreenContent -> content.fullscreen.body?.text.orEmpty()
        is InAppMessageDisplayContent.HTMLContent -> content.html.url
        else -> "" // Scenes & Surveys, and Custom display content
    }

    Log.d("AirshipListener InAppAutomation message getInAppMessageText message is : $messageText")

    return messageText
}

private fun getInAppMessageTitle(message: InAppMessage): String {
    val title = when (val content = message.displayContent) {
        is InAppMessageDisplayContent.ModalContent -> content.modal.heading?.text.orEmpty()
        is InAppMessageDisplayContent.BannerContent -> content.banner.heading?.text.orEmpty()
        is InAppMessageDisplayContent.FullscreenContent -> content.fullscreen.heading?.text.orEmpty()
        else -> "" // Scenes & Surveys, HTML, and Custom display content
    }

    Log.d("AirshipListener InAppAutomation message getInAppMessageTitle title is : $title")

    return title
}

private fun getInAppMessageButtons(message: InAppMessage): List<String> {
    val buttons: MutableList<String> = mutableListOf()
    val contentButtons = when (val content = message.displayContent) {
        is InAppMessageDisplayContent.ModalContent -> content.modal.buttons
        is InAppMessageDisplayContent.BannerContent -> content.banner.buttons
        is InAppMessageDisplayContent.FullscreenContent -> content.fullscreen.buttons
        else -> null // Scenes & Surveys, HTML, and Custom display content
    }
    contentButtons?.forEach { buttonInfo ->
        buttons.add(buttonInfo.label.text)
    }
    return buttons
}

The replacement for InAppMessageListener is InAppMessageDisplayDelegate, which has the following methods:

You can register the display delegate like this:

InAppAutomation.shared().inAppMessaging.displayDelegate = AirshipListener(...)

In the new display delegate, the messageFinishedDisplaying() callback doesn't receive the resolution info, but it's still possible to listen to the events Flow on the analytics class to get info about InApp resolutions. Here's an example:

scope.launch {
    airship.analytics.events
        .filter { it.type == EventType.IN_APP_RESOLUTION }
        .collect { event ->
            val body = event.body.optMap()
            val messageId = body.opt("id").optMap().opt("message_id").string
            val resolution = body.opt("resolution").optMap()

            Log.i("InApp Message ($messageId) resolution: $resolution")
        }
}

The full event body looks like:

{"context":{"button":{"identifier":"dismiss_button"},"reporting_context":{"iax_linking_id":"8eefb821-4bac-4db0-b170-1275d9881c85","experiment_id":"","content_types":["scene"]},"pager":{"count":1,"identifier":"59748c2d-236e-4ca6-9a7b-8bf79260071a","completed":true,"page_identifier":"1a365d16-cb46-42ab-9bee-64b0bf5aabaf","page_index":0},"display":{"is_first_display":false,"is_first_display_trigger_session":true,"trigger_session_id":"88b0bd79-2c00-43c4-83d6-24c740d86d9b"}},"id":{"message_id":"8eefb821-4bac-4db0-b170-1275d9881c85"},"source":"urban-airship","resolution":{"button_id":"dismiss_button","type":"button_click","display_time":2,"button_description":"dismiss_button"}}

And the resolution object looks like:

{"button_id":"dismiss_button","type":"button_click","display_time":2,"button_description":"dismiss_button"}

I'm not sure what the onMessageFinished(...) call on your InAppNotificationMessage class is doing with the resolution info that used to be provided on the InAppMessageListener. onMessageFinished callback, so let me know if using the analytics event feed doesn't fit your use case.

DejanMedicSKY commented 2 months ago

According to the posted code resolution is used to get the actions on the buttons and one which was selected. Which can be useful to know I guess.

        val buttonInfo = resolutionInfo.buttonInfo
        val buttonText = buttonInfo?.label?.text.orEmpty()
        val buttonActions: MutableMap<String, String> = mutableMapOf()
        buttonInfo?.actions?.forEach {
            buttonActions[it.key] = it.value.toString()
        }

Proposed airship.analytics.events Flow observing is a bit different and can happen at different pace and not be available at time when delegate call happens fun messageFinishedDisplaying(message: InAppMessage, scheduleId: String).

DejanMedicSKY commented 2 months ago

Also app crashes when takeoff is called and we did not disable analytics with builder AirshipConfigOptions. App crashes at AppForegroundEvent.getEventData. With it disabled it setup fine AirshipConfigOptions.Builder().setAnalyticsEnabled(false).

 java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String com.urbanairship.analytics.Analytics.getConversionSendId()' on a null object reference
    at com.urbanairship.analytics.AppForegroundEvent.getEventData(AppForegroundEvent.java:49)
    at com.urbanairship.analytics.Analytics.addEvent(Analytics.kt:318)
    at com.urbanairship.analytics.Analytics.onForeground(Analytics.kt:354)
    at com.urbanairship.analytics.Analytics.<init>(Analytics.kt:203)
    at com.urbanairship.analytics.Analytics.<init>(Analytics.kt:52)
    at com.urbanairship.analytics.Analytics.<init>(Analytics.kt:176)
    at com.urbanairship.UAirship.init(UAirship.java:732)
    at com.urbanairship.UAirship.executeTakeOff(UAirship.java:428)
    at com.urbanairship.UAirship.access$000(UAirship.java:72)
    at com.urbanairship.UAirship$2.run(UAirship.java:387)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
    at com.urbanairship.util.AirshipThreadFactory$1.run(AirshipThreadFactory.java:50)
    at java.lang.Thread.run(Thread.java:1012)
rlepinski commented 2 months ago

thanks for the report, looking into it

rlepinski commented 2 months ago

@DejanMedicSKY @wezley98 Are you working on the same implementation? Any chance we can sync on a call to figure out how we can provide the right info for you?

rlepinski commented 2 months ago

I am mostly curious how you are using this data? Yes the flow changes when you get it but if you are just generating reporting from the data then its probably fine and you can replace the listener with the flow. If you can provide more details on these methods then we can hopefully unblock you:

 inAppNotification.onMessageDisplayed(
            scheduleId = scheduleId,
            title = contentTitle,
            message = contentMessage,
            buttons = buttons
        )

inAppNotification.onMessageFinished(
            scheduleId = scheduleId,
            title = contentTitle,
            message = contentMessage,
            buttons = buttons,
            selected = buttonText,
            actions = buttonActions
        )
wezley98 commented 2 months ago

@rlepinski Basically we have a business request to intercept the callbacks and send this information to another analytics system. eg which button was pressed by the user. I'm checking with my colleague @DejanMedicSKY to see if we have everything we need now. Looks like v18.1.0 also fixes the crash we were seeing.

jyaganeh commented 2 months ago

@wezley98 @DejanMedicSKY, thanks for the email! would one or both of you be able to join a short call to discuss this further? We'd like to make sure that we fully understand your needs, so that we can either make the necessary changes in an upcoming SDK release, or help with an alternate solution that satisfies your use case.

wezley98 commented 2 months ago

@jyaganeh We've dropped you an email on 21st June, yes happy to jump on a call to discuss further.