diff --git a/ReferenceAppKotlin/app/build.gradle b/ReferenceAppKotlin/app/build.gradle index f03fc2107..a211898af 100644 --- a/ReferenceAppKotlin/app/build.gradle +++ b/ReferenceAppKotlin/app/build.gradle @@ -122,6 +122,9 @@ dependencies { implementation("com.squareup.moshi:moshi-kotlin:1.13.0") kapt("com.squareup.moshi:moshi-kotlin-codegen:1.13.0") + // Engage SDK + implementation "com.google.android.engage:engage-core:1.4.0" + // Retrofit for HTTP requests implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/MainActivity.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/MainActivity.kt index 9636bb198..d18eecf6f 100644 --- a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/MainActivity.kt +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/MainActivity.kt @@ -30,6 +30,7 @@ import com.android.tv.reference.deeplink.DeepLinkViewModel import com.android.tv.reference.deeplink.DeepLinkViewModelFactory import com.android.tv.reference.shared.datamodel.Video import com.android.tv.reference.shared.util.Result +import com.android.tv.reference.watchnext.Publisher import timber.log.Timber /** @@ -146,4 +147,9 @@ class MainActivity : FragmentActivity() { ) ) } + + override fun onStop() { + super.onStop() + Publisher.publishPeriodically(applicationContext) + } } diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/ClusterRequestFactory.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/ClusterRequestFactory.kt new file mode 100644 index 000000000..de5e73ef4 --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/ClusterRequestFactory.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.Context +import com.android.tv.reference.shared.datamodel.VideoType +import com.google.android.engage.common.datamodel.ContinuationCluster +import com.google.android.engage.service.PublishContinuationClusterRequest + +/** + * Class in charge of constructing the publishing requests and sending them to their respective + * publishers + */ +class ClusterRequestFactory(context: Context) { + + private val engageWatchNextService = EngageWatchNextService.getInstance(context) + + /** + * [constructContinuationClusterRequest] returns a [PublishContinuationClusterRequest] to be used + * by the [EngageServiceWorker] to publish Continuations clusters + * + * @return PublishContinuationClusterRequest + */ + fun constructContinuationClusterRequest(): PublishContinuationClusterRequest { + val continuationList = engageWatchNextService.getAllWatchNextVideos() + val continuationCluster = ContinuationCluster.Builder() + for (watchNextVideo in continuationList) { + val videoType = watchNextVideo.video.videoType + if (videoType == VideoType.EPISODE || videoType == VideoType.MOVIE) { + continuationCluster.addEntity( + VideoToEngageEntityConverter.convertVideo( + watchNextVideo + ) + ) + } + } + return PublishContinuationClusterRequest.Builder() + .setContinuationCluster(continuationCluster.build()) + .build() + } +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Constants.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Constants.kt new file mode 100644 index 000000000..976b26752 --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Constants.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +/** Constants that are to be used as reference for publishing guidelines */ +object Constants { + const val MAX_PUBLISHING_ATTEMPTS: Int = 5 + + const val WORKER_NAME_CONTINUATION: String = "Upload Continuation" + + const val PERIODIC_WORKER_NAME_CONTINUATION: String = "Periodically Upload Continuation" + + const val PUBLISH_TYPE: String = "PUBLISH_TYPE" + const val PUBLISH_TYPE_CONTINUATION = "PUBLISH_CONTINUATION" +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceBroadcastReceiver.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceBroadcastReceiver.kt new file mode 100644 index 000000000..91ef1d20d --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceBroadcastReceiver.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.android.tv.reference.watchnext.Publisher.publishContinuationClusters +import com.google.android.engage.service.Intents.ACTION_PUBLISH_CONTINUATION + +/** Broadcast Receiver to trigger a one time publish of clusters. */ +class EngageServiceBroadcastReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || context == null) { + return + } + when (intent.action) { + ACTION_PUBLISH_CONTINUATION -> publishContinuationClusters(context) + else -> Log.e(TAG, "onReceive: Received unrecognized intent: $intent") + } + } + + private companion object { + const val TAG = "EngageBroadcastReceiver" + } +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceWorker.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceWorker.kt new file mode 100644 index 000000000..4dde707c7 --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageServiceWorker.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE_CONTINUATION +import com.google.android.engage.service.AppEngageException +import com.google.android.engage.service.AppEngagePublishClient +import com.google.android.engage.service.AppEngagePublishStatusCode +import com.google.android.engage.service.PublishStatusRequest +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.common.annotations.VisibleForTesting +import timber.log.Timber + +/** + * [EngageServiceWorker] is a [Worker] class that is tasked with publishing cluster + * requests to Engage Service + */ +class EngageServiceWorker( + context: Context, + workerParams: WorkerParameters, +) : Worker(context, workerParams) { + + @VisibleForTesting + constructor( + context: Context, + workerParams: WorkerParameters, + client: AppEngagePublishClient, + ) : this(context, workerParams) { + this.client = client + } + + val TAG = "ENGAGE_SERVICE_WORKER" + private var client = AppEngagePublishClient(context) + private val clusterRequestFactory = ClusterRequestFactory(context) + + /** + * [doWork] is the entry point for the [EngageServiceWorker], and differentiates between + * publishing tasks of each cluster + */ + override fun doWork(): Result { + // If too many publishing attempts have failed, do not attempt to publish again. + if (runAttemptCount > Constants.MAX_PUBLISHING_ATTEMPTS) { + return Result.failure() + } + + Timber.i("Checking for Engage Service availability") + + // Check if engage service is available before publishing. + val isAvailable = Tasks.await(client.isServiceAvailable) + + // If the service is not available, do not attempt to publish and indicate failure. + if (!isAvailable) { + Timber.e("Engage service is not available") + return Result.failure() + } + + Timber.i("Engage Service is available. Proceeding to publish clusters") + + // The cluster to publish must be passed into the worker through the input data. + // This value must be one of the predefined values indicated a valid cluster to publish. Instead + // of using one worker with flags to determine what cluster to publish, you may also choose to + // your separate workers to publish different clusters; use whichever approach better fits your + // app architecture. + return when (inputData.getString(PUBLISH_TYPE)) { + PUBLISH_TYPE_CONTINUATION -> publishContinuation() + else -> throw IllegalArgumentException("Bad publish type") + } + } + + /** + * [publishContinuation] publishes continuation clusters and returns the result of the attempt to + * publish the continuation clusters if the user is signed in. If the user is signed out it + * instead publishes a request to delete the continuation cluster that had previously been + * published. + * + * @return result Result of publishing a continuation cluster, or continuation cluster deletion + */ + private fun publishContinuation(): Result { + val publishTask: Task = client.publishContinuationCluster( + clusterRequestFactory.constructContinuationClusterRequest() + ) + val statusCode: Int = AppEngagePublishStatusCode.PUBLISHED + return publishAndProvideResult(publishTask, statusCode) + } + + /** + * [publishAndProvideResult] is a method that is in charge of publishing a given task + * + * @param publishTask A task to publish some cluster or delete some cluster + * @param publishStatusCode Publish status code to set through Engage. + * @return publishResult Result of [publishTask] + */ + private fun publishAndProvideResult( + publishTask: Task, + publishStatusCode: Int + ): Result { + setPublishStatusCode(publishStatusCode) + + // Result initialized to success, it is changed to retry or failure if an exception occurs. + var result: Result = Result.success() + try { + // An AppEngageException may occur while publishing, so we may not be able to await the + // result. + Tasks.await(publishTask) + } catch (publishException: AppEngageException) { + Publisher.logPublishing(publishException) + // Some errors are recoverable, such as a threading issue, some are unrecoverable + // such as a cluster not containing all necessary fields. If an error is recoverable, we + // should attempt to publish again. Setting the result to retry means WorkManager will + // attempt to run the worker again, thus attempting to publish again. + result = + if (Publisher.isErrorRecoverable(publishException)) + Result.retry() + else + Result.failure() + } catch (exception: Exception) { + exception.printStackTrace() + result = Result.failure() + } + // This result is returned back to doWork. + return result + } + + /** + * [setPublishStatusCode] method is in charge of updating the publish status code, which monitors + * the health of the integration with EngageSDK + * + * @param statusCode PublishStatus code to be set through Engage. + */ + private fun setPublishStatusCode(statusCode: Int) { + client + .updatePublishStatus(PublishStatusRequest.Builder().setStatusCode(statusCode).build()) + .addOnSuccessListener { + Log.i(TAG, "Successfully updated publish status code to $statusCode") + } + .addOnFailureListener { exception -> + Log.e( + TAG, + "Failed to update publish status code to $statusCode\n${exception.stackTrace}" + ) + } + } +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageWatchNextService.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageWatchNextService.kt new file mode 100644 index 000000000..42c4a8cda --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/EngageWatchNextService.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.app.Application +import android.content.Context +import android.media.tv.TvContract +import com.android.tv.reference.repository.VideoRepositoryFactory +import com.android.tv.reference.shared.datamodel.Video +import com.android.tv.reference.shared.datamodel.VideoType +import timber.log.Timber + +/** + * A services which synchronizes watch next videos state with your video repository. + */ +class EngageWatchNextService private constructor(private val context: Context) { + private var watchNextVideos: MutableList = mutableListOf() + + /** + * Returns all videos in the watch next queue + */ + fun getAllWatchNextVideos(): List { + return watchNextVideos.toList() + } + + fun handleVideoPlaybackStateChange( + videoId: String?, + currentWatchPosition: Long, + playerState: String, + ) { + if (videoId.isNullOrEmpty()) { + Timber.e("Error.Invalid entry for Watch Next. videoId: $videoId") + return + } + + // Check for invalid player state. + if ((playerState != WatchNextHelper.PLAY_STATE_PAUSED) and + (playerState != WatchNextHelper.PLAY_STATE_ENDED) + ) { + Timber.e("Error.Invalid entry for Watch Next. Player state: $playerState") + return + } + + val video = + VideoRepositoryFactory.getVideoRepository(context.applicationContext as Application) + .getVideoById(videoId) + + when (video?.videoType) { + VideoType.MOVIE -> { + Timber.v("Add Movie to Watch Next : id = ${video.id}") + handleWatchNextForMovie( + video = video, + playerState = playerState, + watchPosition = currentWatchPosition.toInt(), + ) + } + + VideoType.EPISODE -> { + Timber.v("Add Episode to Watch Next : id = ${video.id}") + handleWatchNextForTvEpisode( + video = video, + currentWatchPosition = currentWatchPosition.toInt(), + playerState = playerState, + ) + } + + VideoType.CLIP -> Timber.w( + "NOT recommended to add Clips / Trailers /Short videos to Watch Next " + ) + + else -> Timber.e("Invalid category for Video Type: ${video?.videoType}") + } + } + + private fun handleWatchNextForMovie(video: Video, playerState: String, watchPosition: Int) { + Timber.v("Adding/remove movie to Watch Next. Video Name: ${video.name}") + + when { + // If movie has finished, remove from Watch Next channel. + (playerState == WatchNextHelper.PLAY_STATE_ENDED) or + video.isAfterEndCreditsPosition(watchPosition.toLong()) -> { + WatchNextHelper.removeVideoFromWatchNext(context, video) + } + + // Add or update unfinished movie to Watch Next channel. + WatchNextHelper.hasVideoStarted(video.duration(), watchPosition) -> { + insertOrUpdateIntoWatchNext( + video, + watchPosition, + TvContract.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE + ) + } + + else -> { + Timber.w( + "Video not started yet. Can't add to WatchNext.watchPosition: %s, duration: %d", + watchPosition, + video.duration().toMillis() + ) + } + } + } + + private fun handleWatchNextForTvEpisode( + video: Video, + currentWatchPosition: Int, + playerState: String, + ) { + Timber.v("Adding/remove episode to Watch Next. Video Name: ${video.name}") + + when { + // If episode has finished, remove from Watch Next channel. + (playerState == WatchNextHelper.PLAY_STATE_ENDED) or + video.isAfterEndCreditsPosition(currentWatchPosition.toLong()) -> { + removeFromWatchNext(video) + + // Add next episode from TV series. + VideoRepositoryFactory + .getVideoRepository(context.applicationContext as Application) + .getNextEpisodeInSeries(video)?.let { + insertOrUpdateIntoWatchNext( + it, + currentWatchPosition, + TvContract.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT + ) + } + } + + // Add or update unfinished episode to Watch Next channel. + WatchNextHelper.hasVideoStarted(video.duration(), currentWatchPosition) -> { + insertOrUpdateIntoWatchNext( + video, + currentWatchPosition, + TvContract.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE + ) + } + + else -> { + Timber.w( + "Video not started yet. Can't add to WatchNext.watchPosition: %s, duration: %d", + currentWatchPosition, + video.duration().toMillis() + ) + } + } + } + + /** + * Updates or adds a video entity from the watch next list + */ + private fun insertOrUpdateIntoWatchNext( + video: Video, + watchPosition: Int, + watchNextType: Int + ) { + if (video.videoType != VideoType.MOVIE && video.videoType != VideoType.EPISODE) { + throw IllegalArgumentException( + "Watch Next is not supported for Video Type: ${video.videoType}" + ) + } + + val existingVideoIndex = watchNextVideos.indexOfFirst { it.video.id == video.id } + val watchNextVideo = WatchNextVideo( + video = video, + watchPosition = watchPosition, + watchNextType = watchNextType, + ) + if (existingVideoIndex == -1) { + watchNextVideos.add(watchNextVideo) + } else { + watchNextVideos[existingVideoIndex] = watchNextVideo + } + } + + /** + * Removes a video entity from the watch next list + */ + private fun removeFromWatchNext(video: Video) { + watchNextVideos = watchNextVideos.filter { it.video.id != video.id }.toMutableList() + } + + companion object { + @Volatile + private var instance: EngageWatchNextService? = null + + fun getInstance(context: Context): EngageWatchNextService { + if (instance == null) { + synchronized(this) { + if (instance == null) { + instance = EngageWatchNextService(context) + } + } + } + return instance!! + } + + data class WatchNextVideo( + val video: Video, + val watchPosition: Int, + val watchNextType: Int + ) + } +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Publisher.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Publisher.kt new file mode 100644 index 000000000..15d9a5baf --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/Publisher.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.Context +import android.util.Log +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import com.android.tv.reference.watchnext.Constants.PERIODIC_WORKER_NAME_CONTINUATION +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE_CONTINUATION +import com.android.tv.reference.watchnext.Constants.WORKER_NAME_CONTINUATION +import com.google.android.engage.service.AppEngageErrorCode +import com.google.android.engage.service.AppEngageException +import java.util.concurrent.TimeUnit + +object Publisher { + private const val TAG = "PUBLISHER:" + + /** + * Sets continuation cluster to the appropriate state by publishing or deleting the clusters. + * This occurs immediately then once every 24 hours. + * + * @param context Application's context. + */ + fun publishPeriodically(context: Context) { + periodicallyCallEngageServiceWorker( + PERIODIC_WORKER_NAME_CONTINUATION, + PUBLISH_TYPE_CONTINUATION, + context + ) + } + + /** + * Sets continuation cluster and publish status to the appropriate state using WorkManager. More + * detail on what the appropriate state is described in {@link + * com.google.samples.quickstart.engagesdksamples.watch.publish.Publisher#publishPeriodically(Context)}. + * + * @param context Application's context + */ + fun publishContinuationClusters(context: Context) { + queueOneTimeEngageServiceWorker( + WORKER_NAME_CONTINUATION, + PUBLISH_TYPE_CONTINUATION, + context + ) + } + + private fun periodicallyCallEngageServiceWorker( + workerName: String, + publishType: String, + context: Context + ) { + val data = Data.Builder().apply { + putString(PUBLISH_TYPE, publishType) + }.build() + + val workRequest = + PeriodicWorkRequest.Builder( + EngageServiceWorker::class.java, + /* repeatInterval= */ 24, + /* repeatIntervalTimeUnit= */ TimeUnit.HOURS + ) + .setInputData(data) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + workerName, + ExistingPeriodicWorkPolicy.REPLACE, + workRequest + ) + + } + + private fun queueOneTimeEngageServiceWorker( + workerName: String, + publishType: String, + context: Context + ) { + val data = Data.Builder().apply { + putString(PUBLISH_TYPE, publishType) + }.build() + + val workRequest = OneTimeWorkRequest.Builder(EngageServiceWorker::class.java) + .setInputData(data) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(workerName, ExistingWorkPolicy.REPLACE, workRequest) + } + + fun logPublishing(publishingException: AppEngageException) { + val logMessage = + when (publishingException.errorCode) { + AppEngageErrorCode.SERVICE_NOT_FOUND -> + "SERVICE_NOT_FOUND - The service is not available on the given device" + + AppEngageErrorCode.SERVICE_CALL_EXECUTION_FAILURE -> + "SERVICE_CALL_EXECUTION_FAILURE - The task execution failed due to threading " + + "issues, can be retired" + + AppEngageErrorCode.SERVICE_NOT_AVAILABLE -> + "SERVICE_NOT_AVAILABLE - The service is available on the given device, but " + + "not available at the time of the call" + + AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED -> + "SERVICE_CALL_PERMISSION_DENIED - The The caller is not allowed to make " + + "the service call" + + AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT -> + "SERVICE_CALL_INVALID_ARGUMENT - The request contains invalid data (e.g. " + + "more than allowed number of clusters" + + AppEngageErrorCode.SERVICE_CALL_INTERNAL -> + "SERVICE_CALL_INTERNAL - There is an error on the service side" + + AppEngageErrorCode.SERVICE_CALL_RESOURCE_EXHAUSTED -> + "SERVICE_CALL_RESOURCE_EXHAUSTED - The service call is made too frequently" + + else -> "An unknown error has occurred" + } + Log.d(TAG, logMessage) + } + + fun isErrorRecoverable(publishingException: AppEngageException): Boolean { + return when (publishingException.errorCode) { + // Recoverable Error codes + AppEngageErrorCode.SERVICE_CALL_EXECUTION_FAILURE, + AppEngageErrorCode.SERVICE_CALL_INTERNAL, + AppEngageErrorCode.SERVICE_CALL_RESOURCE_EXHAUSTED -> true + // Non recoverable error codes + AppEngageErrorCode.SERVICE_NOT_FOUND, + AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT, + AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED, + AppEngageErrorCode.SERVICE_NOT_AVAILABLE -> false + + else -> throw IllegalArgumentException(publishingException.localizedMessage) + } + } +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/VideoToEngageEntityConverter.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/VideoToEngageEntityConverter.kt new file mode 100644 index 000000000..9418c12bf --- /dev/null +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/VideoToEngageEntityConverter.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.media.tv.TvContentRating +import android.net.Uri +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import com.android.tv.reference.shared.datamodel.Video +import com.android.tv.reference.shared.datamodel.VideoType +import com.android.tv.reference.watchnext.EngageWatchNextService.Companion.WatchNextVideo +import com.google.android.engage.common.datamodel.ContentAvailability +import com.google.android.engage.common.datamodel.Image +import com.google.android.engage.video.datamodel.MovieEntity +import com.google.android.engage.video.datamodel.RatingSystem +import com.google.android.engage.video.datamodel.TvEpisodeEntity +import com.google.android.engage.video.datamodel.VideoEntity +import com.google.android.engage.video.datamodel.WatchNextType + +object VideoToEngageEntityConverter { + + fun convertVideo(watchNextVideo: WatchNextVideo): VideoEntity { + val video = watchNextVideo.video + val watchPosition = watchNextVideo.watchPosition + val watchNextType = watchNextVideo.watchNextType + + return when (video.videoType) { + VideoType.MOVIE -> convertMovieEntity( + video = video, + watchPosition = watchPosition, + watchNextType = watchNextType + ) + + VideoType.EPISODE -> convertTvEpisodeEntity( + video = video, + watchPosition = watchPosition, + watchNextType = watchNextType + ) + + else -> { + throw IllegalArgumentException( + "Conversion is not supported for Video Type: ${video.videoType}" + ) + } + } + } + + private fun convertTvEpisodeEntity( + video: Video, + watchPosition: Int, + watchNextType: Int + ): TvEpisodeEntity { + return TvEpisodeEntity + .Builder() + .setWatchNextType(convertToEngageWatchNextType(watchNextType)) + .setLastPlayBackPositionTimeMillis(watchPosition.toLong()) + .setLastEngagementTimeMillis(System.currentTimeMillis()) + .setName(video.name) + .setDurationMillis(convertDurationStringToMillis(video.duration)) + .addPosterImage( + Image + .Builder() + .setImageWidthInPixel(200) + .setImageHeightInPixel(200) + .setImageUri(Uri.parse(video.thumbnailUri)) + .build() + ) + .setAvailability(ContentAvailability.AVAILABILITY_AVAILABLE) + .setAirDateEpochMillis(System.currentTimeMillis() - FIVE_YEARS_MILLIS) + .setPlayBackUri(Uri.parse(video.uri)) + .setEntityId(video.id) + .setSeasonNumber(video.seasonNumber) + .setSeasonTitle("${video.category} Season ${video.seasonNumber}") + .addContentRating( + RatingSystem + .Builder() + .setAgencyName("Agency") + .setRating( + TvContentRating + .createRating("com.android.tv", "US_TV", "US_TV_PG") + .flattenToString() + ) + .build() + ) + // TODO: check why this is needed + .addContentRating("bla") + .setEpisodeDisplayNumber(video.name) + .build() + } + + private fun convertMovieEntity( + video: Video, + watchPosition: Int, + watchNextType: Int + ): MovieEntity { + return MovieEntity + .Builder() + .setWatchNextType(convertToEngageWatchNextType(watchNextType)) + .setLastPlayBackPositionTimeMillis(watchPosition.toLong()) + .setLastEngagementTimeMillis(System.currentTimeMillis()) + .setName(video.name) + .setDurationMillis(convertDurationStringToMillis(video.duration)) + .addPosterImage( + Image + .Builder() + .setImageWidthInPixel(200) + .setImageHeightInPixel(200) + .setImageUri(Uri.parse(video.thumbnailUri)) + .build() + ) + .setAvailability(ContentAvailability.AVAILABILITY_AVAILABLE) + .addContentRating( + RatingSystem + .Builder() + .setAgencyName("Agency") + .setRating("PG-13") + .build() + ) + .setPlayBackUri(Uri.parse(video.uri)) + .setEntityId(video.id) + .build() + } + + private fun convertToEngageWatchNextType(watchNextType: Int): Int { + return when (watchNextType) { + WatchNextPrograms.WATCH_NEXT_TYPE_NEXT -> WatchNextType.TYPE_NEXT + WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE -> WatchNextType.TYPE_CONTINUE + WatchNextPrograms.WATCH_NEXT_TYPE_NEW -> WatchNextType.TYPE_NEW + else -> WatchNextType.TYPE_UNKNOWN + } + } + + // This method is specific to "PT00H25M" format of duration + private fun convertDurationStringToMillis(duration: String): Long { + val time = duration.split("PT")[1] + val hours = time.split("H")[0].toLong() + val minutes = time.split("H")[1].split("M")[0].toLong() + return (hours * 60 + minutes) * 60 * 1000 + } + + private const val ONE_DAY_MILLIS = (86400 * 1000).toLong() + private const val ONE_YEAR_MILLIS = ONE_DAY_MILLIS * 365 + private const val FIVE_YEARS_MILLIS = ONE_YEAR_MILLIS * 5 +} diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextHelper.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextHelper.kt index 54d18413e..e844ae76b 100644 --- a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextHelper.kt +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextHelper.kt @@ -120,7 +120,7 @@ object WatchNextHelper { * whichever timestamp is earlier. * https://developer.android.com/training/tv/discovery/guidelines-app-developers */ - private fun hasVideoStarted(duration: Duration, currentPosition: Int): Boolean { + internal fun hasVideoStarted(duration: Duration, currentPosition: Int): Boolean { val durationInMilliSeconds = duration.toMillis().toInt() // Return true if either X minutes or Y % have passed // Following formatting spans over multiple lines to accommodate max 100 limit diff --git a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextPlaybackStateListener.kt b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextPlaybackStateListener.kt index b51c9e337..65d13e706 100644 --- a/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextPlaybackStateListener.kt +++ b/ReferenceAppKotlin/app/src/main/java/com/android/tv/reference/watchnext/WatchNextPlaybackStateListener.kt @@ -46,19 +46,40 @@ class WatchNextPlaybackStateListener(private val context: Context) : PlaybackSta // Set relevant data about playback state and video. val watchData = Data.Builder().apply { - putString(WatchNextHelper.VIDEO_ID, video.id) - putLong(WatchNextHelper.CURRENT_POSITION, position) - putLong(WatchNextHelper.DURATION, video.duration().toMillis()) - putString(WatchNextHelper.PLAYER_STATE, playerState) + putString(Constants.PUBLISH_TYPE, Constants.PUBLISH_TYPE_CONTINUATION) }.build() + EngageWatchNextService.getInstance(context).handleVideoPlaybackStateChange( + videoId = video.id, + currentWatchPosition = position, + playerState = playerState, + ) + // Run on a background thread to process playback states and do relevant operations for // Watch Next. - Timber.d("Trigger WorkManager with updated watchData $watchData") - WorkManager.getInstance(context.applicationContext).enqueue( - OneTimeWorkRequest.Builder(WatchNextWorker::class.java) - .setInputData(watchData) - .build() - ) + Timber.d("Trigger WorkManager with updated watchData $watchData using Engage SDK") + WorkManager.getInstance(context.applicationContext) + .enqueue( + OneTimeWorkRequest.Builder(EngageServiceWorker::class.java) + .setInputData(watchData) + .build() + ) + + // Set relevant data about playback state and video. +// val watchData = Data.Builder().apply { +// putString(WatchNextHelper.VIDEO_ID, video.id) +// putLong(WatchNextHelper.CURRENT_POSITION, position) +// putLong(WatchNextHelper.DURATION, video.duration().toMillis()) +// putString(WatchNextHelper.PLAYER_STATE, playerState) +// }.build() + + // Run on a background thread to process playback states and do relevant operations for + // Watch Next. +// Timber.d("Trigger WorkManager with updated watchData $watchData using TvProvider") +// WorkManager.getInstance(context.applicationContext).enqueue( +// OneTimeWorkRequest.Builder(WatchNextWorker::class.java) +// .setInputData(watchData) +// .build() +// ) } } diff --git a/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageEntityConverterTest.kt b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageEntityConverterTest.kt new file mode 100644 index 000000000..4e737cdbf --- /dev/null +++ b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageEntityConverterTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.net.Uri +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import com.android.tv.reference.shared.datamodel.Video +import com.android.tv.reference.shared.datamodel.VideoType +import com.android.tv.reference.watchnext.EngageWatchNextService.Companion.WatchNextVideo +import com.google.android.engage.video.datamodel.MovieEntity +import com.google.android.engage.video.datamodel.TvEpisodeEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EngageEntityConverterTest { + @Test + fun shouldConvertVideoToTvEpisodeEntity() { + val entity = VideoToEngageEntityConverter.convertVideo( + WatchNextVideo( + video = episodeVideo, + watchPosition = 100, + watchNextType = WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE, + ) + ) as TvEpisodeEntity + + assertEquals(entity.entityId, VIDEO_ID) + assertEquals(entity.name, VIDEO_NAME) + assertEquals(entity.infoPageUri.toString(), URI.toString()) + assertEquals(entity.playBackUri.toString(), VIDEO_URI.toString()) + assertEquals(entity.posterImages.size, 1) + assertEquals(entity.posterImages[0].imageUri.toString(), THUMBNAIL_URI.toString()) + assertEquals(entity.durationMillis, EPISODE_DURATION) + assertEquals(entity.episodeDisplayNumber, EPISODE_NUMBER) + assertEquals(entity.seasonNumber, SEASON_NUMBER) + } + + @Test + fun shouldConvertVideoToMovieEntity() { + val entity = VideoToEngageEntityConverter.convertVideo( + WatchNextVideo( + video = movieVideo, + watchPosition = 100, + watchNextType = WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE, + ) + ) as MovieEntity + + assertEquals(entity.entityId, VIDEO_ID) + assertEquals(entity.name, VIDEO_NAME) + assertEquals(entity.infoPageUri.toString(), URI.toString()) + assertEquals(entity.playBackUri.toString(), VIDEO_URI.toString()) + assertEquals(entity.posterImages.size, 1) + assertEquals(entity.posterImages[0].imageUri.toString(), THUMBNAIL_URI.toString()) + assertEquals(entity.durationMillis, MOVIE_DURATION) + } + + companion object { + private const val VIDEO_ID = "video-id" + private const val VIDEO_NAME = "video-name" + private const val VIDEO_DESCRIPTION = "video-description" + private val URI = Uri.parse("https://google.com/uri") + private val VIDEO_URI = Uri.parse("https://google.com/video.mp4") + private val THUMBNAIL_URI = Uri.parse("https://google.com/thumbnail.mp4") + private const val VIDEO_CATEGORY = "video-category" + + private const val MOVIE_DURATION = "PT02H35M" + + private const val EPISODE_DURATION = "PT00H45M" + private const val EPISODE_NUMBER = "02" + private const val SEASON_NUMBER = "01" + + private val episodeVideo = Video( + id = VIDEO_ID, + name = VIDEO_NAME, + description = VIDEO_DESCRIPTION, + uri = URI.toString(), + videoUri = VIDEO_URI.toString(), + thumbnailUri = THUMBNAIL_URI.toString(), + backgroundImageUri = THUMBNAIL_URI.toString(), + category = VIDEO_CATEGORY, + videoType = VideoType.EPISODE, + duration = EPISODE_DURATION, + episodeNumber = EPISODE_NUMBER, + seasonNumber = SEASON_NUMBER, + ) + + private val movieVideo = Video( + id = VIDEO_ID, + name = VIDEO_NAME, + description = VIDEO_DESCRIPTION, + uri = URI.toString(), + videoUri = VIDEO_URI.toString(), + thumbnailUri = THUMBNAIL_URI.toString(), + backgroundImageUri = THUMBNAIL_URI.toString(), + category = VIDEO_CATEGORY, + videoType = VideoType.MOVIE, + duration = MOVIE_DURATION, + ) + } +} diff --git a/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageServiceWorkerTest.kt b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageServiceWorkerTest.kt new file mode 100644 index 000000000..76c228694 --- /dev/null +++ b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/EngageServiceWorkerTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.ListenableWorker.Result +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import com.android.tv.reference.watchnext.Constants.MAX_PUBLISHING_ATTEMPTS +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE +import com.android.tv.reference.watchnext.Constants.PUBLISH_TYPE_CONTINUATION +import com.google.android.engage.service.AppEngageErrorCode +import com.google.android.engage.service.AppEngageException +import com.google.android.engage.service.AppEngagePublishClient +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.verify + +class EngageServiceWorkerTest { + + @Before + fun setUp() { + mockedContext = ApplicationProvider.getApplicationContext() + } + + @Test + fun publishContinuationFailsWhenServiceUnavailableTest() { + val mockedAvailability = Tasks.forResult(false) + Mockito.`when`(mockedClient.isServiceAvailable()).thenReturn(mockedAvailability) + + val mockedWorker = + createEngageServiceWorker(mockedContext, PUBLISH_TYPE_CONTINUATION, runAttempts = 0) + runBlocking { + val resultFail = mockedWorker.doWork() + assertEquals(Result.failure(), resultFail) + verify(mockedClient, Mockito.never()).publishContinuationCluster(any()) + verify(mockedClient, Mockito.never()).deleteContinuationCluster() + verify(mockedClient, Mockito.never()).updatePublishStatus(any()) + } + } + + @Test + fun publishContinuationFailsOnUnrecoverableExceptions() { + val unrecoverableErrorCodes = + listOf( + AppEngageErrorCode.SERVICE_NOT_FOUND, + AppEngageErrorCode.SERVICE_NOT_AVAILABLE, + AppEngageErrorCode.SERVICE_CALL_INVALID_ARGUMENT, + AppEngageErrorCode.SERVICE_CALL_PERMISSION_DENIED, + ) + for (errorCode in unrecoverableErrorCodes) { + verifyPublishContinuationWithErrorReturnsResultHelper(errorCode, Result.failure()) + } + } + + @Test + fun publishContinuationRetryOnRecoverableExceptions() { + val unrecoverableErrorCodes = + listOf( + AppEngageErrorCode.SERVICE_CALL_RESOURCE_EXHAUSTED, + AppEngageErrorCode.SERVICE_CALL_EXECUTION_FAILURE, + AppEngageErrorCode.SERVICE_CALL_INTERNAL + ) + for (errorCode in unrecoverableErrorCodes) { + verifyPublishContinuationWithErrorReturnsResultHelper(errorCode, Result.retry()) + } + } + + @Test + fun attemptToPublishContinuationAtMaxAttemptsTest() { + val mockedAvailability = Tasks.forResult(true) + Mockito.`when`(mockedClient.isServiceAvailable()).thenReturn(mockedAvailability) + + val resultingTask: Task = Tasks.forResult(null) + + Mockito.`when`(mockedClient.publishContinuationCluster(any())).thenReturn(resultingTask) + Mockito.`when`(mockedClient.updatePublishStatus(any())).thenReturn(resultingTask) + // At least one movie is in progress + + val worker = + createEngageServiceWorker( + mockedContext, + PUBLISH_TYPE_CONTINUATION, + MAX_PUBLISHING_ATTEMPTS + ) + + runBlocking { + worker.doWork() + verify { mockedClient.publishContinuationCluster(any()) } + } + } + + @Test + fun doNotAttemptToPublishOrDeleteContinuationPastMaxAttemptsTest() { + val mockedAvailability = Tasks.forResult(true) + Mockito.`when`(mockedClient.isServiceAvailable()).thenReturn(mockedAvailability) + + val worker = + createEngageServiceWorker( + mockedContext, + PUBLISH_TYPE_CONTINUATION, + MAX_PUBLISHING_ATTEMPTS + 1 + ) + + runBlocking { + worker.doWork() + verify(mockedClient, Mockito.never()).publishContinuationCluster(any()) + verify(mockedClient, Mockito.never()).deleteContinuationCluster() + } + } + + private fun verifyPublishContinuationWithErrorReturnsResultHelper( + errorCode: Int, + expectedResult: Result + ) { + val mockedAvailability = Tasks.forResult(true) + Mockito.`when`(mockedClient.isServiceAvailable).thenReturn(mockedAvailability) + + val resultException = AppEngageException(errorCode) + val resultingTask: Task = Tasks.forException(resultException) + + Mockito.`when`(mockedClient.publishContinuationCluster(any())).thenReturn(resultingTask) + Mockito.`when`(mockedClient.updatePublishStatus(any())).thenReturn(Tasks.forResult(null)) + // At least one movie is in progress + + val worker = + createEngageServiceWorker(mockedContext, PUBLISH_TYPE_CONTINUATION, runAttempts = 0) + + runBlocking { + val actualResult = worker.doWork() + assertEquals(expectedResult, actualResult) + } + } + + private fun createEngageServiceWorker( + context: Context, + publishClusterType: String, + runAttempts: Int + ): EngageServiceWorker { + val workerData = workDataOf(PUBLISH_TYPE to publishClusterType) + return TestListenableWorkerBuilder( + context = context, + inputData = workerData, + runAttemptCount = runAttempts + ) + .setWorkerFactory(EngageServiceWorkerFactory()) + .build() + } + + private class EngageServiceWorkerFactory() : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker { + return EngageServiceWorker(appContext, workerParameters, mockedClient) + } + } + + companion object { + private lateinit var mockedContext: Context + private val mockedClient: AppEngagePublishClient = + Mockito.mock(AppEngagePublishClient::class.java) + } +} diff --git a/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/PublisherTest.kt b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/PublisherTest.kt new file mode 100644 index 000000000..3614d1e77 --- /dev/null +++ b/ReferenceAppKotlin/app/src/test/java/com/android/tv/reference/watchnext/PublisherTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.android.tv.reference.watchnext + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.Configuration +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.android.tv.reference.watchnext.Constants.PERIODIC_WORKER_NAME_CONTINUATION +import com.android.tv.reference.watchnext.Constants.WORKER_NAME_CONTINUATION +import com.android.tv.reference.watchnext.Publisher.publishContinuationClusters +import com.android.tv.reference.watchnext.Publisher.publishPeriodically +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PublisherTest { + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + val config = + Configuration.Builder() + .setExecutor(SynchronousExecutor()) + .setTaskExecutor(SynchronousExecutor()) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + workManager = WorkManager.getInstance(context) + } + + @Test + fun publishContinuationStartsWorkTest() { + publishContinuationClusters(context) + assertSetStateWorkIsQueuedHelper(WORKER_NAME_CONTINUATION) + } + + @Test + fun publishPeriodicWorkersTest() { + publishPeriodically(context) + assertSetStateWorkIsQueuedHelper(PERIODIC_WORKER_NAME_CONTINUATION) + } + + private fun assertSetStateWorkIsQueuedHelper(workName: String) { + val workInfo = workManager.getWorkInfosForUniqueWork(workName).get() + // This should always be true since publishing work is unique and non-chainable + assertTrue(workInfo.size == 0 || workInfo.size == 1) + + // Work info will only be present if the work was triggered. + val hasStarted = workInfo.size == 1 + assertTrue(hasStarted) + } + + private companion object { + lateinit var workManager: WorkManager + lateinit var context: Context + } +}