UnmanagedSessionReceiver.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * 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
 *
 *      http://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 androidx.glance.appwidget

import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.glance.appwidget.action.LambdaActionBroadcasts
import java.lang.IllegalStateException
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * This receiver responds to lambda action clicks for unmanaged sessions (created by
 * [GlanceAppWidget.runComposition]). In managed sessions that compose UI for a bound widget, the
 * widget's [GlanceAppWidgetReceiver] is used as the receiver for lambda actions. However, when
 * running a session with [GlanceAppWidget.runComposition], there is no guarantee that the widget
 * is attached to some GlanceAppWidgetReceiver. Instead, unmanaged sessions register themselves to
 * receive lambdas while they are running (with [UnmanagedSessionReceiver.registerSession]), and set
 * their lambda target to [UnmanagedSessionReceiver]. This is also used by
 * [GlanceRemoteViewsService] to provide list items for unmanaged sessions.
 */
internal class UnmanagedSessionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == LambdaActionBroadcasts.ActionTriggerLambda) {
            val actionKey = intent.getStringExtra(LambdaActionBroadcasts.ExtraActionKey)
                ?: error("Intent is missing ActionKey extra")
            val id = intent.getIntExtra(LambdaActionBroadcasts.ExtraAppWidgetId, -1)
            if (id == -1) error("Intent is missing AppWidgetId extra")
            getSession(id)?.let { session ->
                goAsync(Dispatchers.Main) {
                    session.runLambda(actionKey)
                }
            }
                ?: Log.e(
                    GlanceAppWidgetTag,
                    "A lambda created by an unmanaged glance session cannot be serviced" +
                        "because that session is no longer running."
                )
        }
    }

    companion object {
        @SuppressLint("PrimitiveInCollection")
        private val activeSessions = mutableMapOf<Int, Registration>()
        private class Registration(
            val session: AppWidgetSession,
            val coroutine: CancellableContinuation<Nothing>
        )

        /**
         * Registers [session] to handle lambdas created from an unmanaged session running for
         * [appWidgetId].
         *
         * This call will suspend once the session is registered. On cancellation, this session will
         * be unregistered. That way, the registration is tied to the surrounding coroutine scope
         * and does not need to be manually unregistered.
         *
         * If called from another coroutine with the same [appWidgetId], this call will resume with
         * an exception, and the new registration will succeed. (i.e., only one session per
         * [appWidgetId] can be registered at the same time). By default, [runComposition] uses
         * random fake IDs, so this could only happen if the user calls [runComposition] with two
         * identical real IDs.
         */
        suspend fun registerSession(
            appWidgetId: Int,
            session: AppWidgetSession
        ): Nothing = suspendCancellableCoroutine { coroutine ->
            synchronized(UnmanagedSessionReceiver) {
                activeSessions[appWidgetId]?.coroutine?.resumeWithException(
                    IllegalStateException("Another session for $appWidgetId has started")
                )
                activeSessions[appWidgetId] = Registration(session, coroutine)
            }
            coroutine.invokeOnCancellation {
                synchronized(UnmanagedSessionReceiver) {
                    activeSessions.remove(appWidgetId)
                }
            }
        }

        fun getSession(appWidgetId: Int): AppWidgetSession? =
            synchronized(UnmanagedSessionReceiver) {
                activeSessions[appWidgetId]?.session
            }
    }
}