diff options
11 files changed, 309 insertions, 2 deletions
diff --git a/pdf/framework/Android.bp b/pdf/framework/Android.bp index d67506690..d2d9459e9 100644 --- a/pdf/framework/Android.bp +++ b/pdf/framework/Android.bp @@ -136,6 +136,7 @@ java_library { libs: [ // To add StatsLog as a dependency of the generated file. "framework-statsd.stubs.module_lib", + "androidx.annotation_annotation", ], apex_available: [ "com.android.mediaprovider", diff --git a/photopicker/src/com/android/photopicker/MainActivity.kt b/photopicker/src/com/android/photopicker/MainActivity.kt index 22bf6bd16..8b148df60 100644 --- a/photopicker/src/com/android/photopicker/MainActivity.kt +++ b/photopicker/src/com/android/photopicker/MainActivity.kt @@ -56,6 +56,7 @@ import com.android.photopicker.core.events.Telemetry import com.android.photopicker.core.events.dispatchReportPhotopickerApiInfoEvent import com.android.photopicker.core.events.dispatchReportPhotopickerMediaItemStatusEvent import com.android.photopicker.core.events.dispatchReportPhotopickerSessionInfoEvent +import com.android.photopicker.core.events.dispatchReportPickerAppMediaCapabilities import com.android.photopicker.core.features.FeatureManager import com.android.photopicker.core.features.LocalFeatureManager import com.android.photopicker.core.selection.GrantsAwareSelectionImpl @@ -289,6 +290,12 @@ class MainActivity : Hilt_MainActivity() { pickerIntentAction = intentAction, lazyFeatureManager = featureManager, ) + + dispatchReportPickerAppMediaCapabilities( + coroutineScope = lifecycleScope, + lazyEvents = events, + photopickerConfiguration = configurationManager.configuration.value, + ) } /** diff --git a/photopicker/src/com/android/photopicker/core/events/Dispatchers.kt b/photopicker/src/com/android/photopicker/core/events/Dispatchers.kt index 1984a4335..e27cb75fd 100644 --- a/photopicker/src/com/android/photopicker/core/events/Dispatchers.kt +++ b/photopicker/src/com/android/photopicker/core/events/Dispatchers.kt @@ -16,6 +16,8 @@ package com.android.photopicker.core.events +import android.media.ApplicationMediaCapabilities +import android.media.MediaFeature import android.provider.MediaStore import com.android.photopicker.core.configuration.PhotopickerConfiguration import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv @@ -80,6 +82,8 @@ fun dispatchReportPhotopickerApiInfoEvent( val isCloudSearchEnabled = lazyFeatureManager.get().isFeatureEnabled(SearchFeature::class.java) // TODO(b/376822503): Update when search is added val isLocalSearchEnabled = false + val isTranscodingRequested: Boolean = + photopickerConfiguration.callingPackageMediaCapabilities != null coroutineScope.launch { lazyEvents .get() @@ -98,11 +102,67 @@ fun dispatchReportPhotopickerApiInfoEvent( isDefaultTabSet = isDefaultTabSet, isCloudSearchEnabled = isCloudSearchEnabled, isLocalSearchEnabled = isLocalSearchEnabled, + isTranscodingRequested = isTranscodingRequested, ) ) } } +/** Dispatches an event to log App transcoding media capabilities if advertised by the app */ +fun dispatchReportPickerAppMediaCapabilities( + coroutineScope: CoroutineScope, + lazyEvents: Lazy<Events>, + photopickerConfiguration: PhotopickerConfiguration, +) { + val dispatcherToken = FeatureToken.CORE.token + val sessionId = photopickerConfiguration.sessionId + val appMediaCapabilities: ApplicationMediaCapabilities? = + photopickerConfiguration.callingPackageMediaCapabilities + if (appMediaCapabilities != null) { + with(appMediaCapabilities) { + val supportedHdrTypes: IntArray = getEnumsForTypes(true, getSupportedHdrTypes()) + val unsupportedHdrTypes: IntArray = getEnumsForTypes(false, getUnsupportedHdrTypes()) + coroutineScope.launch { + lazyEvents + .get() + .dispatch( + Event.ReportPickerAppMediaCapabilities( + dispatcherToken = dispatcherToken, + sessionId = sessionId, + supportedHdrTypes = supportedHdrTypes, + unsupportedHdrTypes = unsupportedHdrTypes, + ) + ) + } + } + } +} + +private fun getEnumsForTypes(supported: Boolean, hdrTypesList: List<String>): IntArray { + var array: MutableList<Int> = mutableListOf() + for (type in hdrTypesList) { + when (type) { + MediaFeature.HdrType.DOLBY_VISION -> { + if (supported) array.add(Telemetry.HdrTypes.DOLBY_SUPPORTED.type) + else array.add(Telemetry.HdrTypes.DOLBY_UNSUPPORTED.type) + } + MediaFeature.HdrType.HDR10 -> { + if (supported) array.add(Telemetry.HdrTypes.HDR10_SUPPORTED.type) + else array.add(Telemetry.HdrTypes.HDR10_UNSUPPORTED.type) + } + MediaFeature.HdrType.HDR10_PLUS -> { + if (supported) array.add(Telemetry.HdrTypes.HDR10PLUS_SUPPORTED.type) + else array.add(Telemetry.HdrTypes.HDR10PLUS_UNSUPPORTED.type) + } + MediaFeature.HdrType.HLG -> { + if (supported) array.add(Telemetry.HdrTypes.HLG_SUPPORTED.type) + else array.add(Telemetry.HdrTypes.HLG_UNSUPPORTED.type) + } + } + } + return array.toIntArray() +} + /** Dispatches an event to log all the final state details of the picker */ fun dispatchReportPhotopickerSessionInfoEvent( coroutineScope: CoroutineScope, diff --git a/photopicker/src/com/android/photopicker/core/events/Event.kt b/photopicker/src/com/android/photopicker/core/events/Event.kt index 00d7b0ca5..c6b674c90 100644 --- a/photopicker/src/com/android/photopicker/core/events/Event.kt +++ b/photopicker/src/com/android/photopicker/core/events/Event.kt @@ -96,6 +96,7 @@ interface Event { val isDefaultTabSet: Boolean, val isCloudSearchEnabled: Boolean, val isLocalSearchEnabled: Boolean, + val isTranscodingRequested: Boolean, ) : Event /** @@ -225,6 +226,24 @@ interface Event { val surfacePackageDeliveryStartTime: Int, val surfacePackageDeliveryEndTime: Int, ) : Event + + /** Logs media capabilities of the App requesting transcoding */ + data class ReportPickerAppMediaCapabilities( + override val dispatcherToken: String, + val sessionId: Int, + val supportedHdrTypes: IntArray, + val unsupportedHdrTypes: IntArray, + ) : Event + + /** Logs information about the transcoding video */ + data class ReportTranscodingVideoDetails( + override val dispatcherToken: String, + val sessionId: Int, + val duration: Int, + val colorTransfer: Int, + val colorStandard: Int, + val mimeType: Int, + ) : Event } /** @@ -383,6 +402,57 @@ interface Telemetry { } /* + Different supported and unsupported HDR types + */ + enum class HdrTypes(val type: Int) { + HDR10_SUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__SUPPORTED_HDR_TYPES__TYPE_HDR10 + ), + HDR10PLUS_SUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__SUPPORTED_HDR_TYPES__TYPE_HDR10_PLUS + ), + HLG_SUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__SUPPORTED_HDR_TYPES__TYPE_HLG + ), + DOLBY_SUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__SUPPORTED_HDR_TYPES__TYPE_DOLBY_VISION + ), + HDR10_UNSUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__UNSUPPORTED_HDR_TYPES__TYPE_HDR10 + ), + HDR10PLUS_UNSUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__UNSUPPORTED_HDR_TYPES__TYPE_HDR10_PLUS + ), + HLG_UNSUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__UNSUPPORTED_HDR_TYPES__TYPE_HLG + ), + DOLBY_UNSUPPORTED( + MediaProviderStatsLog + .PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED__UNSUPPORTED_HDR_TYPES__TYPE_DOLBY_VISION + ), + } + + /* + Different Video mime types + */ + enum class VideoMimeType(val type: Int) { + DOLBY( + MediaProviderStatsLog + .PHOTOPICKER_VIDEO_TRANSCODING_DETAILS_LOGGED__MIME_TYPE__MIME_DOLBY + ), + HEVC( + MediaProviderStatsLog.PHOTOPICKER_VIDEO_TRANSCODING_DETAILS_LOGGED__MIME_TYPE__MIME_HEVC + ), + } + + /* Different picker tabs */ enum class SelectedTab(val tab: Int) { @@ -556,6 +626,15 @@ interface Telemetry { MediaProviderStatsLog.PHOTOPICKER_UIEVENT_LOGGED__UI_EVENT__UI_LOADED_EMPTY_STATE ), UNSET_UI_EVENT(MediaProviderStatsLog.PHOTOPICKER_UIEVENT_LOGGED__UI_EVENT__UNSET_UI_EVENT), + PICKER_TRANSCODING_START( + MediaProviderStatsLog.PHOTOPICKER_UIEVENT_LOGGED__UI_EVENT__PICKER_TRANSCODING_STARTED + ), + PICKER_TRANSCODING_SUCCESS( + MediaProviderStatsLog.PHOTOPICKER_UIEVENT_LOGGED__UI_EVENT__PICKER_TRANSCODING_FINISHED + ), + PICKER_TRANSCODING_FAILED( + MediaProviderStatsLog.PHOTOPICKER_UIEVENT_LOGGED__UI_EVENT__PICKER_TRANSCODING_FAILED + ), } /* diff --git a/photopicker/src/com/android/photopicker/core/events/PhotopickerEventLogger.kt b/photopicker/src/com/android/photopicker/core/events/PhotopickerEventLogger.kt index c6bc6445f..5f3787d76 100644 --- a/photopicker/src/com/android/photopicker/core/events/PhotopickerEventLogger.kt +++ b/photopicker/src/com/android/photopicker/core/events/PhotopickerEventLogger.kt @@ -100,6 +100,7 @@ class PhotopickerEventLogger(val dataService: Lazy<DataService>) { /* is_search_enabled */ false, event.isCloudSearchEnabled, event.isLocalSearchEnabled, + event.isTranscodingRequested, ) } is Event.LogPhotopickerUIEvent -> { @@ -256,6 +257,24 @@ class PhotopickerEventLogger(val dataService: Lazy<DataService>) { event.surfacePackageDeliveryEndTime, ) } + is Event.ReportPickerAppMediaCapabilities -> { + MediaProviderStatsLog.write( + MediaProviderStatsLog.PHOTOPICKER_APP_MEDIA_CAPABILITIES_REPORTED, + event.sessionId, + event.supportedHdrTypes, + event.unsupportedHdrTypes, + ) + } + is Event.ReportTranscodingVideoDetails -> { + MediaProviderStatsLog.write( + MediaProviderStatsLog.PHOTOPICKER_VIDEO_TRANSCODING_DETAILS_LOGGED, + event.sessionId, + event.duration, + event.colorStandard, + event.colorTransfer, + event.mimeType, + ) + } } } } diff --git a/photopicker/src/com/android/photopicker/core/features/FeatureManager.kt b/photopicker/src/com/android/photopicker/core/features/FeatureManager.kt index 0fd821eb6..c1ff94c45 100644 --- a/photopicker/src/com/android/photopicker/core/features/FeatureManager.kt +++ b/photopicker/src/com/android/photopicker/core/features/FeatureManager.kt @@ -121,6 +121,8 @@ class FeatureManager( Event.ReportPhotopickerSearchInfo::class.java, Event.ReportSearchDataExtractionDetails::class.java, Event.ReportEmbeddedPhotopickerInfo::class.java, + Event.ReportPickerAppMediaCapabilities::class.java, + Event.ReportTranscodingVideoDetails::class.java, ) } diff --git a/photopicker/src/com/android/photopicker/features/preparemedia/MediaPreparerViewModel.kt b/photopicker/src/com/android/photopicker/features/preparemedia/MediaPreparerViewModel.kt index 41753b265..df626b1a9 100644 --- a/photopicker/src/com/android/photopicker/features/preparemedia/MediaPreparerViewModel.kt +++ b/photopicker/src/com/android/photopicker/features/preparemedia/MediaPreparerViewModel.kt @@ -474,6 +474,31 @@ constructor( // Trigger transcoding. val transcodeStatus = if (transcoder.isTranscodeRequired(context, mediaCapabilities, item)) { + val transcodingVideoInfo: Transcoder.VideoInfo? = + transcoder.getTranscodingVideoInfo() + scope.launch { + val configuration = configurationManager.configuration.value + if (transcodingVideoInfo != null) { + events.dispatch( + Event.ReportTranscodingVideoDetails( + dispatcherToken = FeatureToken.CORE.token, + sessionId = configuration.sessionId, + duration = transcodingVideoInfo.duration, + colorTransfer = transcodingVideoInfo.colorTransfer, + colorStandard = transcodingVideoInfo.colorStandard, + mimeType = transcodingVideoInfo.mimeType, + ) + ) + } + events.dispatch( + Event.LogPhotopickerUIEvent( + FeatureToken.CORE.token, + configuration.sessionId, + configuration.callingPackageUid ?: -1, + Telemetry.UiEvent.PICKER_TRANSCODING_START, + ) + ) + } val uri = item.mediaUri val resultBundle = contentResolver.call( @@ -485,9 +510,31 @@ constructor( if (resultBundle?.getBoolean(PICKER_TRANSCODE_RESULT, false) == true) { Log.v(PrepareMediaFeature.TAG, "Transcode successful: $item") + scope.launch { + val configuration = configurationManager.configuration.value + events.dispatch( + Event.LogPhotopickerUIEvent( + FeatureToken.CORE.token, + configuration.sessionId, + configuration.callingPackageUid ?: -1, + Telemetry.UiEvent.PICKER_TRANSCODING_SUCCESS, + ) + ) + } TranscodeStatus.SUCCEED } else { Log.w(PrepareMediaFeature.TAG, "Not able to transcode: $item") + scope.launch { + val configuration = configurationManager.configuration.value + events.dispatch( + Event.LogPhotopickerUIEvent( + FeatureToken.CORE.token, + configuration.sessionId, + configuration.callingPackageUid ?: -1, + Telemetry.UiEvent.PICKER_TRANSCODING_FAILED, + ) + ) + } TranscodeStatus.NOT_APPLIED } } else { diff --git a/photopicker/src/com/android/photopicker/features/preparemedia/Transcoder.kt b/photopicker/src/com/android/photopicker/features/preparemedia/Transcoder.kt index f2f82e89e..5b1bb38fd 100644 --- a/photopicker/src/com/android/photopicker/features/preparemedia/Transcoder.kt +++ b/photopicker/src/com/android/photopicker/features/preparemedia/Transcoder.kt @@ -26,6 +26,14 @@ import com.android.photopicker.data.model.Media /** Provides methods to help video transcode. */ interface Transcoder { + /** Data class to hold details of the transcoding video. */ + data class VideoInfo( + val duration: Int, + val colorStandard: Int, + val colorTransfer: Int, + val mimeType: Int, + ) + /** * Checks if a transcode is required for the given video. * @@ -39,6 +47,9 @@ interface Transcoder { video: Media.Video, ): Boolean + /** Returns details of the video that needs transcoding */ + fun getTranscodingVideoInfo(): VideoInfo? + companion object { /** diff --git a/photopicker/src/com/android/photopicker/features/preparemedia/TranscoderImpl.kt b/photopicker/src/com/android/photopicker/features/preparemedia/TranscoderImpl.kt index 547440037..bf48ed34f 100644 --- a/photopicker/src/com/android/photopicker/features/preparemedia/TranscoderImpl.kt +++ b/photopicker/src/com/android/photopicker/features/preparemedia/TranscoderImpl.kt @@ -31,11 +31,14 @@ import androidx.annotation.VisibleForTesting import androidx.media3.common.util.MediaFormatUtil.createFormatFromMediaFormat import androidx.media3.common.util.MediaFormatUtil.isVideoFormat import androidx.media3.exoplayer.MediaExtractorCompat +import com.android.photopicker.core.events.Telemetry import com.android.photopicker.data.model.Media /** A class that help video transcode. */ class TranscoderImpl : Transcoder { + private var needsTranscodingVideoInfo: Transcoder.VideoInfo? = null + override fun isTranscodeRequired( context: Context, mediaCapabilities: ApplicationMediaCapabilities?, @@ -53,7 +56,7 @@ class TranscoderImpl : Transcoder { // Check if any video tracks need to be transcoded. val videoTrackMediaFormats = getVideoTrackMediaFormats(context, video) for (mediaFormat in videoTrackMediaFormats) { - if (isTranscodeRequired(mediaFormat, mediaCapabilities)) { + if (isTranscodeRequired(mediaFormat, mediaCapabilities, video.duration)) { return true } } @@ -98,6 +101,7 @@ class TranscoderImpl : Transcoder { fun isTranscodeRequired( mediaFormat: MediaFormat, mediaCapabilities: ApplicationMediaCapabilities, + duration: Int = 0, ): Boolean { val format = createFormatFromMediaFormat(mediaFormat) val mimeType = format.sampleMimeType @@ -111,6 +115,13 @@ class TranscoderImpl : Transcoder { // what the caller intended. if (isHlg10(colorStandard, colorTransfer) && !isHdrTypeSupported(HdrType.HLG)) { + needsTranscodingVideoInfo = + Transcoder.VideoInfo( + duration, + colorStandard ?: 0, + colorTransfer ?: 0, + Telemetry.VideoMimeType.HEVC.type, + ) return true } @@ -119,6 +130,13 @@ class TranscoderImpl : Transcoder { (!isHdrTypeSupported(HdrType.HDR10) || !isHdrTypeSupported(HdrType.HDR10_PLUS)) ) { + needsTranscodingVideoInfo = + Transcoder.VideoInfo( + duration, + colorStandard ?: 0, + colorTransfer ?: 0, + Telemetry.VideoMimeType.HEVC.type, + ) return true } } @@ -127,13 +145,24 @@ class TranscoderImpl : Transcoder { isHdrDolbyVision(mimeType, colorStandard, colorTransfer) && !isHdrTypeSupported(HdrType.DOLBY_VISION) ) { + needsTranscodingVideoInfo = + Transcoder.VideoInfo( + duration, + colorStandard ?: 0, + colorTransfer ?: 0, + Telemetry.VideoMimeType.DOLBY.type, + ) return true } } - return false } + /** Returns details of the video that needs transcoding */ + override fun getTranscodingVideoInfo(): Transcoder.VideoInfo? { + return needsTranscodingVideoInfo + } + companion object { private const val TAG = "Transcoder" @VisibleForTesting const val DURATION_LIMIT_MS = 60_000L // 1 min diff --git a/photopicker/tests/src/com/android/photopicker/core/configuration/TestPhotopickerConfiguration.kt b/photopicker/tests/src/com/android/photopicker/core/configuration/TestPhotopickerConfiguration.kt index e444adabb..4382acead 100644 --- a/photopicker/tests/src/com/android/photopicker/core/configuration/TestPhotopickerConfiguration.kt +++ b/photopicker/tests/src/com/android/photopicker/core/configuration/TestPhotopickerConfiguration.kt @@ -17,6 +17,7 @@ package com.android.photopicker.core.configuration import android.content.Intent +import android.media.ApplicationMediaCapabilities import com.android.photopicker.core.events.generatePickerSessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted @@ -69,6 +70,7 @@ class TestPhotopickerConfiguration { private var sessionId: Int = generatePickerSessionId() private var flags: PhotopickerFlags = PhotopickerFlags() private var mimeTypes: ArrayList<String> = arrayListOf("image/*", "video/*") + private var appMediaCapabilities: ApplicationMediaCapabilities? = null fun action(value: String) = apply { this.action = value } @@ -92,6 +94,10 @@ class TestPhotopickerConfiguration { fun mimeTypes(value: ArrayList<String>) = apply { this.mimeTypes = value } + fun appMediaCapabilities(value: ApplicationMediaCapabilities) = apply { + this.appMediaCapabilities = value + } + fun build(): PhotopickerConfiguration { return PhotopickerConfiguration( action = action, @@ -105,6 +111,7 @@ class TestPhotopickerConfiguration { sessionId = sessionId, flags = flags, mimeTypes = mimeTypes, + callingPackageMediaCapabilities = appMediaCapabilities, ) } } diff --git a/photopicker/tests/src/com/android/photopicker/core/events/DispatchersTest.kt b/photopicker/tests/src/com/android/photopicker/core/events/DispatchersTest.kt index 90afd5811..396a6bee0 100644 --- a/photopicker/tests/src/com/android/photopicker/core/events/DispatchersTest.kt +++ b/photopicker/tests/src/com/android/photopicker/core/events/DispatchersTest.kt @@ -20,6 +20,8 @@ import android.content.ContentResolver import android.content.Context import android.content.pm.PackageManager import android.content.pm.UserProperties +import android.media.ApplicationMediaCapabilities +import android.media.MediaFeature.HdrType import android.net.Uri import android.os.Parcel import android.os.UserHandle @@ -39,6 +41,7 @@ import com.android.photopicker.core.events.dispatchPhotopickerExpansionStateChan import com.android.photopicker.core.events.dispatchReportPhotopickerApiInfoEvent import com.android.photopicker.core.events.dispatchReportPhotopickerMediaItemStatusEvent import com.android.photopicker.core.events.dispatchReportPhotopickerSessionInfoEvent +import com.android.photopicker.core.events.dispatchReportPickerAppMediaCapabilities import com.android.photopicker.core.events.generatePickerSessionId import com.android.photopicker.core.features.FeatureManager import com.android.photopicker.core.features.FeatureToken @@ -390,6 +393,7 @@ class DispatchersTest { isDefaultTabSet = false, isCloudSearchEnabled = cloudSearch, isLocalSearchEnabled = false, + isTranscodingRequested = false, ) // Action @@ -441,6 +445,7 @@ class DispatchersTest { isDefaultTabSet = false, isCloudSearchEnabled = cloudSearch, isLocalSearchEnabled = false, + isTranscodingRequested = false, ) // Action @@ -492,6 +497,7 @@ class DispatchersTest { isDefaultTabSet = false, isCloudSearchEnabled = cloudSearch, isLocalSearchEnabled = false, + isTranscodingRequested = false, ) // Action @@ -543,6 +549,7 @@ class DispatchersTest { isDefaultTabSet = false, isCloudSearchEnabled = cloudSearch, isLocalSearchEnabled = false, + isTranscodingRequested = false, ) // Action @@ -559,4 +566,42 @@ class DispatchersTest { assertThat(eventsDispatched).contains(expectedEvent) assertThat(expectedEvent.mediaFilter).isEqualTo(telemetryMimeTypeMapping) } + + @Test + fun testDispatchReportPickerAppMediaCapabilities() = runTest { + // Setup + setup(testScope = this) + + val capabilities = + ApplicationMediaCapabilities.Builder().addUnsupportedHdrType(HdrType.HDR10).build() + + val photopickerConfiguration = + TestPhotopickerConfiguration.build { + action(value = "") + sessionId(value = sessionId) + callingPackageUid(value = packageUid) + runtimeEnv(value = PhotopickerRuntimeEnv.EMBEDDED) + appMediaCapabilities(capabilities) + } + + val expectedEvent = + Event.ReportPickerAppMediaCapabilities( + dispatcherToken = FeatureToken.CORE.token, + sessionId = sessionId, + supportedHdrTypes = intArrayOf(), + unsupportedHdrTypes = intArrayOf(Telemetry.HdrTypes.HDR10_UNSUPPORTED.type), + ) + + // Action + dispatchReportPickerAppMediaCapabilities( + coroutineScope = backgroundScope, + lazyEvents = lazyEvents, + photopickerConfiguration = photopickerConfiguration, + ) + advanceTimeBy(delayTimeMillis = 50) + + // Assert + assertThat(eventsDispatched.size).isEqualTo(1) + assertThat(eventsDispatched.get(0).toString()).isEqualTo(expectedEvent.toString()) + } } |