SdkActivityLaunchers.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.
 */

@file:JvmName("SdkActivityLaunchers")

// TODO(b/282918396): Stop using app.BundleCompat and change it to os.BundleCompat when permission
// issue is fixed.
@file:Suppress("DEPRECATION")

package androidx.privacysandbox.ui.client

import android.app.Activity
import android.os.Bundle
import android.os.IBinder
import androidx.core.app.BundleCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.privacysandbox.sdkruntime.client.SdkSandboxManagerCompat
import androidx.privacysandbox.ui.core.ISdkActivityLauncher
import androidx.privacysandbox.ui.core.ISdkActivityLauncherCallback
import androidx.privacysandbox.ui.core.ProtocolConstants.sdkActivityLauncherBinderKey
import androidx.privacysandbox.ui.core.SdkActivityLauncher
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * Returns an SdkActivityLauncher that launches activities on behalf of an SDK by using this
 * activity as a starting context.
 *
 * @param T the current activity from which new SDK activities will be launched. If this activity is
 * destroyed any further SDK activity launches will simply be ignored.
 * @param allowLaunch predicate called each time an activity is about to be launched by the
 * SDK, the activity will only be launched if it returns true.
 */
fun <T> T.createSdkActivityLauncher(
    allowLaunch: () -> Boolean
): LocalSdkActivityLauncher<T>
    where T : Activity, T : LifecycleOwner {
    val cancellationJob = Job(parent = lifecycleScope.coroutineContext[Job])
    val launcher = LocalSdkActivityLauncherImpl(
        activity = this,
        allowLaunch = allowLaunch,
        onDispose = { cancellationJob.cancel() },
    )
    cancellationJob.invokeOnCompletion {
        launcher.dispose()
    }
    return launcher
}

/**
 * Returns a [Bundle] with the information necessary to recreate this launcher.
 * Possibly in a different process.
 */
fun SdkActivityLauncher.toLauncherInfo(): Bundle {
    val binderDelegate = SdkActivityLauncherBinderDelegate(this)
    return Bundle().also { bundle ->
        BundleCompat.putBinder(bundle, sdkActivityLauncherBinderKey, binderDelegate)
    }
}

/**
 * Local implementation of an SDK Activity launcher.
 *
 * It allows callers in the app process to dispose resources used to launch SDK activities.
 */
interface LocalSdkActivityLauncher<T> : SdkActivityLauncher where T : Activity, T : LifecycleOwner {
    /**
     * Clears references used to launch activities.
     *
     * After this method is called all further attempts to launch activities wil be rejected.
     * Doesn't do anything if the launcher was already disposed of.
     */
    fun dispose()
}

private class LocalSdkActivityLauncherImpl<T>(
    activity: T,
    allowLaunch: () -> Boolean,
    onDispose: () -> Unit
) : LocalSdkActivityLauncher<T> where T : Activity, T : LifecycleOwner {

    /** Internal state for [LocalSdkActivityLauncher], cleared when the launcher is disposed. */
    private class LocalLauncherState<T>(
        val activity: T,
        val allowLaunch: () -> Boolean,
        val sdkSandboxManager: SdkSandboxManagerCompat,
        val onDispose: () -> Unit
    ) where T : Activity, T : LifecycleOwner

    private val stateReference: AtomicReference<LocalLauncherState<T>?> =
        AtomicReference<LocalLauncherState<T>?>(
        LocalLauncherState(
            activity,
            allowLaunch,
            SdkSandboxManagerCompat.from(activity),
            onDispose
        )
    )

    override suspend fun launchSdkActivity(
        sdkActivityHandlerToken: IBinder
    ): Boolean {
        val state = stateReference.get() ?: return false
        return withContext(Dispatchers.Main.immediate) {
            state.run {
                allowLaunch().also { didAllowLaunch ->
                    if (didAllowLaunch) {
                        sdkSandboxManager.startSdkSandboxActivity(activity, sdkActivityHandlerToken)
                    }
                }
            }
        }
    }

    override fun dispose() {
        stateReference.getAndSet(null)?.run {
            onDispose()
        }
    }
}

private class SdkActivityLauncherBinderDelegate(private val launcher: SdkActivityLauncher) :
    ISdkActivityLauncher.Stub() {

    private val coroutineScope = CoroutineScope(Dispatchers.Main)

    override fun launchSdkActivity(
        sdkActivityHandlerToken: IBinder?,
        callback: ISdkActivityLauncherCallback?
    ) {
        requireNotNull(sdkActivityHandlerToken)
        requireNotNull(callback)

        coroutineScope.launch {
            val accepted = try {
                launcher.launchSdkActivity(sdkActivityHandlerToken)
            } catch (t: Throwable) {
                callback.onLaunchError(t.message ?: "Unknown error launching SDK activity.")
                return@launch
            }

            if (accepted) {
                callback.onLaunchAccepted(sdkActivityHandlerToken)
            } else {
                callback.onLaunchRejected(sdkActivityHandlerToken)
            }
        }
    }
}