/*
 * Copyright (C) 2015 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 android.car;

import static android.car.feature.Flags.FLAG_PROJECTION_QUERY_BT_PROFILE_INHIBIT;

import static com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport.DEPRECATED_CODE;

import android.annotation.CallbackExecutor;
import android.annotation.FlaggedApi;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.annotation.SystemApi;
import android.bluetooth.BluetoothDevice;
import android.car.projection.ProjectionOptions;
import android.car.projection.ProjectionStatus;
import android.car.projection.ProjectionStatus.ProjectionState;
import android.content.Intent;
import android.net.wifi.SoftApConfiguration;
import android.net.wifi.WifiConfiguration;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.util.ArraySet;
import android.util.Pair;
import android.util.Slog;
import android.view.KeyEvent;

import com.android.car.internal.ExcludeFromCodeCoverageGeneratedReport;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.Preconditions;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executor;

/**
 * CarProjectionManager allows applications implementing projection to register/unregister itself
 * with projection manager, listen for voice notification.
 *
 * A client must have {@link Car#PERMISSION_CAR_PROJECTION} permission in order to access this
 * manager.
 *
 * @hide
 */
@SystemApi
public final class CarProjectionManager extends CarManagerBase {
    private static final String TAG = CarProjectionManager.class.getSimpleName();

    private final Binder mToken = new Binder();
    private final Object mLock = new Object();

    /**
     * Listener to get projected notifications.
     *
     * Currently only voice search request is supported.
     */
    public interface CarProjectionListener {
        /**
         * Voice search was requested by the user.
         */
        void onVoiceAssistantRequest(boolean fromLongPress);
    }

    /**
     * Interface for projection apps to receive and handle key events from the system.
     */
    public interface ProjectionKeyEventHandler {
        /**
         * Called when a projection key event occurs.
         *
         * @param event The projection key event that occurred.
         */
        void onKeyEvent(@KeyEventNum int event);
    }
    /**
     * Flag for {@link #registerProjectionListener(CarProjectionListener, int)}: subscribe to
     * voice-search short-press requests.
     *
     * @deprecated Use {@link #addKeyEventHandler(Set, ProjectionKeyEventHandler)} with the
     * {@link #KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP} event instead.
     */
    @Deprecated
    public static final int PROJECTION_VOICE_SEARCH = 0x1;
    /**
     * Flag for {@link #registerProjectionListener(CarProjectionListener, int)}: subscribe to
     * voice-search long-press requests.
     *
     * @deprecated Use {@link #addKeyEventHandler(Set, ProjectionKeyEventHandler)} with the
     * {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN} event instead.
     */
    @Deprecated
    public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 0x2;

    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
     * key is pressed down.
     *
     * If the key is released before the long-press timeout,
     * {@link #KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP} will be fired. If the key is held past the
     * long-press timeout, {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN} will be fired,
     * followed by {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP}.
     */
    public static final int KEY_EVENT_VOICE_SEARCH_KEY_DOWN = 0;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
     * key is released after a short-press.
     */
    public static final int KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP = 1;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
     * key is held down past the long-press timeout.
     */
    public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN = 2;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
     * key is released after a long-press.
     */
    public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP = 3;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
     * pressed down.
     *
     * If the key is released before the long-press timeout,
     * {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP} will be fired. If the key is held past the
     * long-press timeout, {@link #KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN} will be fired, followed by
     * {@link #KEY_EVENT_CALL_LONG_PRESS_KEY_UP}.
     */
    public static final int KEY_EVENT_CALL_KEY_DOWN = 4;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
     * released after a short-press.
     */
    public static final int KEY_EVENT_CALL_SHORT_PRESS_KEY_UP = 5;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
     * held down past the long-press timeout.
     */
    public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN = 6;
    /**
     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
     * released after a long-press.
     */
    public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_UP = 7;

    /** @hide */
    public static final int NUM_KEY_EVENTS = 8;

    /** @hide */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(prefix = "KEY_EVENT_", value = {
            KEY_EVENT_VOICE_SEARCH_KEY_DOWN,
            KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP,
            KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN,
            KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP,
            KEY_EVENT_CALL_KEY_DOWN,
            KEY_EVENT_CALL_SHORT_PRESS_KEY_UP,
            KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN,
            KEY_EVENT_CALL_LONG_PRESS_KEY_UP,
    })
    @Target({ElementType.TYPE_USE})
    public @interface KeyEventNum {}

    /** @hide */
    public static final int PROJECTION_AP_STARTED = 0;
    /** @hide */
    public static final int PROJECTION_AP_STOPPED = 1;
    /** @hide */
    public static final int PROJECTION_AP_FAILED = 2;

    private final ICarProjection mService;
    private final Executor mHandlerExecutor;

    @GuardedBy("mLock")
    private CarProjectionListener mListener;
    @GuardedBy("mLock")
    private int mVoiceSearchFilter;
    private final ProjectionKeyEventHandler mLegacyListenerTranslator =
            this::translateKeyEventToLegacyListener;

    private final ICarProjectionKeyEventHandlerImpl mBinderHandler =
            new ICarProjectionKeyEventHandlerImpl(this);

    @GuardedBy("mLock")
    private final Map<ProjectionKeyEventHandler, KeyEventHandlerRecord> mKeyEventHandlers =
            new HashMap<>();
    @GuardedBy("mLock")
    private BitSet mHandledEvents = new BitSet();

    private ProjectionAccessPointCallbackProxy mProjectionAccessPointCallbackProxy;

    private final Set<ProjectionStatusListener> mProjectionStatusListeners = new LinkedHashSet<>();
    private CarProjectionStatusListenerImpl mCarProjectionStatusListener;

    // Only one access point proxy object per process.
    private static final IBinder mAccessPointProxyToken = new Binder();

    /**
     * Interface to receive for projection status updates.
     */
    public interface ProjectionStatusListener {
        /**
         * This method gets invoked if projection status has been changed.
         *
         * @param state - current projection state
         * @param packageName - if projection is currently running either in the foreground or
         *                      in the background this argument will contain its package name
         * @param details - contains detailed information about all currently registered projection
         *                  receivers.
         */
        void onProjectionStatusChanged(@ProjectionState int state, @Nullable String packageName,
                @NonNull List<ProjectionStatus> details);
    }

    /**
     * @hide
     */
    public CarProjectionManager(Car car, IBinder service) {
        super(car);
        mService = ICarProjection.Stub.asInterface(service);
        Handler handler = getEventHandler();
        mHandlerExecutor = handler::post;
    }

    /**
     * Compatibility with previous APIs due to typo
     *
     * @deprecated Use
     * {@link CarProjectionManager#registerProjectionListener(CarProjectionListener, int)} instead.
     * @hide
     */
    @ExcludeFromCodeCoverageGeneratedReport(reason = DEPRECATED_CODE)
    @Deprecated
    public void regsiterProjectionListener(CarProjectionListener listener, int voiceSearchFilter) {
        registerProjectionListener(listener, voiceSearchFilter);
    }

    /**
     * Register listener to monitor projection. Only one listener can be registered and
     * registering multiple times will lead into only the last listener to be active.
     *
     * @param listener the listener to get projected notifications
     * @param voiceSearchFilter Flags of voice search requests to get notification.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void registerProjectionListener(@NonNull CarProjectionListener listener,
            int voiceSearchFilter) {
        Objects.requireNonNull(listener, "listener cannot be null");
        synchronized (mLock) {
            if (mListener == null || mVoiceSearchFilter != voiceSearchFilter) {
                addKeyEventHandler(
                        translateVoiceSearchFilter(voiceSearchFilter),
                        mLegacyListenerTranslator);
            }
            mListener = listener;
            mVoiceSearchFilter = voiceSearchFilter;
        }
    }

    /**
     * Compatibility with previous APIs due to typo
     *
     * @deprecated Use {@link CarProjectionManager#unregisterProjectionListener() instead.
     * @hide
     */
    @ExcludeFromCodeCoverageGeneratedReport(reason = DEPRECATED_CODE)
    @Deprecated
    public void unregsiterProjectionListener() {
        unregisterProjectionListener();
    }

    /**
     * Unregister listener and stop listening projection events.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void unregisterProjectionListener() {
        synchronized (mLock) {
            removeKeyEventHandler(mLegacyListenerTranslator);
            mListener = null;
            mVoiceSearchFilter = 0;
        }
    }

    @SuppressWarnings("deprecation")
    private static Set<Integer> translateVoiceSearchFilter(int voiceSearchFilter) {
        Set<Integer> rv = new ArraySet<>(Integer.bitCount(voiceSearchFilter));
        if ((voiceSearchFilter & PROJECTION_VOICE_SEARCH) != 0) {
            rv.add(KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
        }
        if ((voiceSearchFilter & PROJECTION_LONG_PRESS_VOICE_SEARCH) != 0) {
            rv.add(KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
        }
        return rv;
    }

    private void translateKeyEventToLegacyListener(@KeyEventNum int keyEvent) {
        CarProjectionListener legacyListener;
        boolean fromLongPress;

        synchronized (mLock) {
            if (mListener == null) {
                return;
            }
            legacyListener = mListener;

            if (keyEvent == KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP) {
                fromLongPress = false;
            } else if (keyEvent == KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN) {
                fromLongPress = true;
            } else {
                Slog.e(TAG, "Unexpected key event " + keyEvent);
                return;
            }
        }

        Slog.d(TAG, "Voice assistant request, long-press = " + fromLongPress);

        legacyListener.onVoiceAssistantRequest(fromLongPress);
    }

    /**
     * Adds a {@link ProjectionKeyEventHandler} to be called for the given set of key events.
     *
     * If the given event handler is already registered, the event set and {@link Executor} for that
     * event handler will be replaced with those provided.
     *
     * For any event with a defined event handler, the system will suppress its default behavior for
     * that event, and call the event handler instead. (For instance, if an event handler is defined
     * for {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP}, the system will not open the dialer when the
     * {@link KeyEvent#KEYCODE_CALL CALL} key is short-pressed.)
     *
     * Callbacks on the event handler will be run on the {@link Handler} designated to run callbacks
     * from {@link Car}.
     *
     * @param events        The set of key events to which to subscribe.
     * @param eventHandler  The {@link ProjectionKeyEventHandler} to call when those events occur.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void addKeyEventHandler(
            @NonNull Set<@KeyEventNum Integer> events,
            @NonNull ProjectionKeyEventHandler eventHandler) {
        addKeyEventHandler(events, null, eventHandler);
    }

    /**
     * Adds a {@link ProjectionKeyEventHandler} to be called for the given set of key events.
     *
     * If the given event handler is already registered, the event set and {@link Executor} for that
     * event handler will be replaced with those provided.
     *
     * For any event with a defined event handler, the system will suppress its default behavior for
     * that event, and call the event handler instead. (For instance, if an event handler is defined
     * for {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP}, the system will not open the dialer when the
     * {@link KeyEvent#KEYCODE_CALL CALL} key is short-pressed.)
     *
     * Callbacks on the event handler will be run on the given {@link Executor}, or, if it is null,
     * the {@link Handler} designated to run callbacks for {@link Car}.
     *
     * @param events            The set of key events to which to subscribe.
     * @param callbackExecutor  An {@link Executor} on which to run callbacks.
     * @param eventHandler      The {@link ProjectionKeyEventHandler} to call when those events
     *                          occur.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void addKeyEventHandler(
            @NonNull Set<@KeyEventNum Integer> events,
            @CallbackExecutor @Nullable Executor callbackExecutor,
            @NonNull ProjectionKeyEventHandler eventHandler) {
        Executor executor = callbackExecutor;

        BitSet eventMask = new BitSet();
        for (int event : events) {
            Preconditions.checkArgument(event >= 0 && event < NUM_KEY_EVENTS, "Invalid key event");
            eventMask.set(event);
        }

        if (eventMask.isEmpty()) {
            removeKeyEventHandler(eventHandler);
            return;
        }

        if (executor == null) {
            executor = mHandlerExecutor;
        }

        synchronized (mLock) {
            KeyEventHandlerRecord record = mKeyEventHandlers.get(eventHandler);
            if (record == null) {
                record = new KeyEventHandlerRecord(executor, eventMask);
                mKeyEventHandlers.put(eventHandler, record);
            } else {
                record.mExecutor = executor;
                record.mSubscribedEvents = eventMask;
            }

            updateHandledEventsLocked();
        }
    }

    /**
     * Removes a previously registered {@link ProjectionKeyEventHandler}.
     *
     * @param eventHandler The listener to remove.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void removeKeyEventHandler(@NonNull ProjectionKeyEventHandler eventHandler) {
        synchronized (mLock) {
            KeyEventHandlerRecord record = mKeyEventHandlers.remove(eventHandler);
            if (record != null) {
                updateHandledEventsLocked();
            }
        }
    }

    @GuardedBy("mLock")
    private void updateHandledEventsLocked() {
        BitSet events = new BitSet();

        for (KeyEventHandlerRecord record : mKeyEventHandlers.values()) {
            events.or(record.mSubscribedEvents);
        }

        if (events.equals(mHandledEvents)) {
            // No changes.
            return;
        }

        try {
            if (!events.isEmpty()) {
                Slog.d(TAG, "Registering handler with system for " + events);
                byte[] eventMask = events.toByteArray();
                mService.registerKeyEventHandler(mBinderHandler, eventMask);
            } else {
                Slog.d(TAG, "Unregistering handler with system");
                mService.unregisterKeyEventHandler(mBinderHandler);
            }
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
            return;
        }

        mHandledEvents = events;
    }

    /**
     * Registers projection runner on projection start with projection service
     * to create reverse binding.
     *
     * @param serviceIntent
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void registerProjectionRunner(@NonNull Intent serviceIntent) {
        Objects.requireNonNull(serviceIntent, "serviceIntent cannot be null");
        synchronized (mLock) {
            try {
                mService.registerProjectionRunner(serviceIntent);
            } catch (RemoteException e) {
                handleRemoteExceptionFromCarService(e);
            }
        }
    }

    /**
     * Unregisters projection runner on projection stop with projection service to create
     * reverse binding.
     *
     * @param serviceIntent
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void unregisterProjectionRunner(@NonNull Intent serviceIntent) {
        Objects.requireNonNull(serviceIntent, "serviceIntent cannot be null");
        synchronized (mLock) {
            try {
                mService.unregisterProjectionRunner(serviceIntent);
            } catch (RemoteException e) {
                handleRemoteExceptionFromCarService(e);
            }
        }
    }

    /** @hide */
    @Override
    public void onCarDisconnected() {
        // nothing to do
    }

    /**
     * Request to start Wi-Fi access point if it hasn't been started yet for wireless projection
     * receiver app.
     *
     * <p>A process can have only one request to start an access point, subsequent call of this
     * method will invalidate previous calls.
     *
     * @param callback to receive notifications when access point status changed for the request
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void startProjectionAccessPoint(@NonNull ProjectionAccessPointCallback callback) {
        Objects.requireNonNull(callback, "callback cannot be null");
        synchronized (mLock) {
            Looper looper = getEventHandler().getLooper();
            ProjectionAccessPointCallbackProxy proxy =
                    new ProjectionAccessPointCallbackProxy(this, looper, callback);
            try {
                mService.startProjectionAccessPoint(proxy.getMessenger(), mAccessPointProxyToken);
                mProjectionAccessPointCallbackProxy = proxy;
            } catch (RemoteException e) {
                handleRemoteExceptionFromCarService(e);
            }
        }
    }

    /**
     * Returns a list of available Wi-Fi channels. A channel is specified as frequency in MHz,
     * e.g. channel 1 will be represented as 2412 in the list.
     *
     * @param band one of the values from {@code android.net.wifi.WifiScanner#WIFI_BAND_*}
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public @NonNull List<Integer> getAvailableWifiChannels(int band) {
        try {
            int[] channels = mService.getAvailableWifiChannels(band);
            List<Integer> channelList = new ArrayList<>(channels.length);
            for (int v : channels) {
                channelList.add(v);
            }
            return channelList;
        } catch (RemoteException e) {
            return handleRemoteExceptionFromCarService(e, Collections.emptyList());
        }
    }

    /**
     * Stop Wi-Fi Access Point for wireless projection receiver app.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void stopProjectionAccessPoint() {
        ProjectionAccessPointCallbackProxy proxy;
        synchronized (mLock) {
            proxy = mProjectionAccessPointCallbackProxy;
            mProjectionAccessPointCallbackProxy = null;
        }
        if (proxy == null) {
            return;
        }

        try {
            mService.stopProjectionAccessPoint(mAccessPointProxyToken);
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
        }
    }

    /**
     * Request to disconnect the given profile on the given device, and prevent it from reconnecting
     * until either the request is released, or the process owning the given token dies. Mainly
     * intended to use with the {@code A2DP_SINK} profile.
     *
     * @param device  The device on which to inhibit a profile.
     * @param profile The {@link android.bluetooth.BluetoothProfile} to inhibit.
     * @return True if the profile was successfully inhibited, false if an error occurred.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public boolean requestBluetoothProfileInhibit(
            @NonNull BluetoothDevice device, int profile) {
        Objects.requireNonNull(device, "device cannot be null");
        try {
            return mService.requestBluetoothProfileInhibit(device, profile, mToken);
        } catch (RemoteException e) {
            return handleRemoteExceptionFromCarService(e, false);
        }
    }

    /**
     * Release an inhibit request made by {@link #requestBluetoothProfileInhibit}, and reconnect the
     * profile if no other inhibit requests are active. Mainly intended to use with the {@code
     * A2DP_SINK} profile.
     *
     * @param device  The device on which to release the inhibit request.
     * @param profile The profile on which to release the inhibit request.
     * @return True if the request was released, false if an error occurred.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public boolean releaseBluetoothProfileInhibit(@NonNull BluetoothDevice device, int profile) {
        Objects.requireNonNull(device, "device cannot be null");
        try {
            return mService.releaseBluetoothProfileInhibit(device, profile, mToken);
        } catch (RemoteException e) {
            return handleRemoteExceptionFromCarService(e, false);
        }
    }


    /**
     * Checks whether a request to disconnect the given profile on the given device has been made
     * and if the inhibit request is still active. Mainly intended to use with the {@code A2DP_SINK}
     * profile.
     *
     * @param device  The device on which to check the inhibit request.
     * @param profile The profile on which to check the inhibit request.
     * @return True if inhibit was requested and is still active, false if an error occurred or
     *         inactive.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    @FlaggedApi(FLAG_PROJECTION_QUERY_BT_PROFILE_INHIBIT)
    public boolean isBluetoothProfileInhibited(@NonNull BluetoothDevice device, int profile) {
        Objects.requireNonNull(device, "device cannot be null");
        try {
            return mService.isBluetoothProfileInhibited(device, profile, mToken);
        } catch (RemoteException e) {
            return handleRemoteExceptionFromCarService(e, false);
        }
    }

    /**
     * Call this method to report projection status of your app. The aggregated status (from other
     * projection apps if available) will be broadcasted to interested parties.
     *
     * @param status the reported status that will be distributed to the interested listeners
     *
     * @see #registerProjectionStatusListener(ProjectionStatusListener)
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void updateProjectionStatus(@NonNull ProjectionStatus status) {
        Objects.requireNonNull(status, "status cannot be null");
        try {
            mService.updateProjectionStatus(status, mToken);
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
        }
    }

    /**
     * Register projection status listener. See {@link ProjectionStatusListener} for details. It is
     * allowed to register multiple listeners.
     *
     * <p>Note: provided listener will be called immediately with the most recent status.
     *
     * @param listener the listener to receive notification for any projection status changes
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION_STATUS)
    public void registerProjectionStatusListener(@NonNull ProjectionStatusListener listener) {
        Objects.requireNonNull(listener, "listener cannot be null");
        synchronized (mLock) {
            mProjectionStatusListeners.add(listener);

            if (mCarProjectionStatusListener == null) {
                mCarProjectionStatusListener = new CarProjectionStatusListenerImpl(this);
                try {
                    mService.registerProjectionStatusListener(mCarProjectionStatusListener);
                } catch (RemoteException e) {
                    handleRemoteExceptionFromCarService(e);
                }
            } else {
                // Already subscribed to Car Service, immediately notify listener with the current
                // projection status in the event handler thread.
                int currentProjectionState = mCarProjectionStatusListener.mCurrentState;
                String currentProjectionPackageName =
                        mCarProjectionStatusListener.mCurrentPackageName;
                List<ProjectionStatus> projectionStatusDetails =
                        Collections.unmodifiableList(mCarProjectionStatusListener.mDetails);

                getEventHandler().post(() ->
                        listener.onProjectionStatusChanged(
                                currentProjectionState,
                                currentProjectionPackageName,
                                projectionStatusDetails));
            }
        }
    }

    /**
     * Unregister provided listener from projection status notifications
     *
     * @param listener the listener for projection status notifications that was previously
     * registered with {@link #unregisterProjectionStatusListener(ProjectionStatusListener)}
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION_STATUS)
    public void unregisterProjectionStatusListener(@NonNull ProjectionStatusListener listener) {
        Objects.requireNonNull(listener, "listener cannot be null");
        synchronized (mLock) {
            if (!mProjectionStatusListeners.remove(listener)
                    || !mProjectionStatusListeners.isEmpty()) {
                return;
            }
            unregisterProjectionStatusListenerFromCarServiceLocked();
        }
    }

    private void unregisterProjectionStatusListenerFromCarServiceLocked() {
        try {
            mService.unregisterProjectionStatusListener(mCarProjectionStatusListener);
            mCarProjectionStatusListener = null;
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
        }
    }

    private void handleProjectionStatusChanged(@ProjectionState int state,
            String packageName, List<ProjectionStatus> details) {
        List<ProjectionStatusListener> listeners;
        synchronized (mLock) {
            listeners = new ArrayList<>(mProjectionStatusListeners);
        }
        for (ProjectionStatusListener listener : listeners) {
            listener.onProjectionStatusChanged(state, packageName, details);
        }
    }

    /**
     * Returns {@link Bundle} object that contains customization for projection app. This bundle
     * can be parsed using {@link ProjectionOptions}.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public @NonNull Bundle getProjectionOptions() {
        try {
            return mService.getProjectionOptions();
        } catch (RemoteException e) {
            return handleRemoteExceptionFromCarService(e, Bundle.EMPTY);
        }
    }

    /**
     * Resets projection access point credentials if system was configured to persist local-only
     * hotspot credentials.
     */
    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
    public void resetProjectionAccessPointCredentials() {
        try {
            mService.resetProjectionAccessPointCredentials();
        } catch (RemoteException e) {
            handleRemoteExceptionFromCarService(e);
        }
    }

    /**
     * Callback class for applications to receive updates about the LocalOnlyHotspot status.
     */
    public abstract static class ProjectionAccessPointCallback {
        public static final int ERROR_NO_CHANNEL = 1;
        public static final int ERROR_GENERIC = 2;
        public static final int ERROR_INCOMPATIBLE_MODE = 3;
        public static final int ERROR_TETHERING_DISALLOWED = 4;

        /**
         * Called when access point started successfully.
         * <p>
         * Note that AP detail may contain configuration which is cannot be represented
         * by the legacy WifiConfiguration, in such cases a null will be returned.
         * For example:
         * <li> SoftAp band in {@link WifiConfiguration.apBand} only supports
         * 2GHz, 5GHz, 2GHz+5GHz bands, so conversion is limited to these bands. </li>
         * <li> SoftAp security type in {@link WifiConfiguration.KeyMgmt} only supports
         * NONE, WPA2_PSK, so conversion is limited to these security type.</li>
         *
         * @param wifiConfiguration  the {@link WifiConfiguration} of the current hotspot.
         * @deprecated This callback is deprecated. Use {@link #onStarted(SoftApConfiguration))}
         * instead.
         */
        @Deprecated
        public void onStarted(@Nullable WifiConfiguration wifiConfiguration) {}

        /**
         * Called when access point started successfully.
         *
         * @param softApConfiguration the {@link SoftApConfiguration} of the current hotspot.
         */
        public void onStarted(@NonNull SoftApConfiguration softApConfiguration) {
            onStarted(softApConfiguration.toWifiConfiguration());
        }

        /** Called when access point is stopped. No events will be sent after that. */
        public void onStopped() {}
        /** Called when access point failed to start. No events will be sent after that. */
        public void onFailed(int reason) {}
    }

    /**
     * Callback proxy for LocalOnlyHotspotCallback objects.
     */
    private static class ProjectionAccessPointCallbackProxy {
        private static final String LOG_PREFIX =
                ProjectionAccessPointCallbackProxy.class.getSimpleName() + ": ";

        private final Handler mHandler;
        private final WeakReference<CarProjectionManager> mCarProjectionManagerRef;
        private final Messenger mMessenger;

        ProjectionAccessPointCallbackProxy(CarProjectionManager manager, Looper looper,
                final ProjectionAccessPointCallback callback) {
            mCarProjectionManagerRef = new WeakReference<>(manager);

            mHandler = new Handler(looper) {
                @Override
                public void handleMessage(Message msg) {
                    Slog.d(TAG, LOG_PREFIX + "handle message what: " + msg.what + " msg: " + msg);

                    CarProjectionManager manager = mCarProjectionManagerRef.get();
                    if (manager == null) {
                        Slog.w(TAG, LOG_PREFIX + "handle message post GC");
                        return;
                    }

                    switch (msg.what) {
                        case PROJECTION_AP_STARTED:
                            if (msg.obj == null) {
                                Slog.e(TAG, LOG_PREFIX + "config cannot be null.");
                                callback.onFailed(ProjectionAccessPointCallback.ERROR_GENERIC);
                                return;
                            }
                            if (msg.obj instanceof SoftApConfiguration) {
                                callback.onStarted((SoftApConfiguration) msg.obj);
                            } else if (msg.obj instanceof WifiConfiguration) {
                                callback.onStarted((WifiConfiguration) msg.obj);
                            }
                            break;
                        case PROJECTION_AP_STOPPED:
                            Slog.i(TAG, LOG_PREFIX + "hotspot stopped");
                            callback.onStopped();
                            break;
                        case PROJECTION_AP_FAILED:
                            int reasonCode = msg.arg1;
                            Slog.w(TAG, LOG_PREFIX + "failed to start.  reason: "
                                    + reasonCode);
                            callback.onFailed(reasonCode);
                            break;
                        default:
                            Slog.e(TAG, LOG_PREFIX + "unhandled message.  type: " + msg.what);
                    }
                }
            };
            mMessenger = new Messenger(mHandler);
        }

        Messenger getMessenger() {
            return mMessenger;
        }
    }

    private static class ICarProjectionKeyEventHandlerImpl
            extends ICarProjectionKeyEventHandler.Stub {

        private final WeakReference<CarProjectionManager> mManager;

        private ICarProjectionKeyEventHandlerImpl(CarProjectionManager manager) {
            mManager = new WeakReference<>(manager);
        }

        @Override
        public void onKeyEvent(@KeyEventNum int event) {
            Slog.d(TAG, "Received projection key event " + event);
            final CarProjectionManager manager = mManager.get();
            if (manager == null) {
                return;
            }

            List<Pair<ProjectionKeyEventHandler, Executor>> toDispatch = new ArrayList<>();
            synchronized (manager.mLock) {
                for (Map.Entry<ProjectionKeyEventHandler, KeyEventHandlerRecord> entry :
                        manager.mKeyEventHandlers.entrySet()) {
                    if (entry.getValue().mSubscribedEvents.get(event)) {
                        toDispatch.add(Pair.create(entry.getKey(), entry.getValue().mExecutor));
                    }
                }
            }

            for (Pair<ProjectionKeyEventHandler, Executor> entry : toDispatch) {
                ProjectionKeyEventHandler listener = entry.first;
                entry.second.execute(() -> listener.onKeyEvent(event));
            }
        }
    }

    private static class KeyEventHandlerRecord {
        @NonNull Executor mExecutor;
        @NonNull BitSet mSubscribedEvents;

        KeyEventHandlerRecord(@NonNull Executor executor, @NonNull BitSet subscribedEvents) {
            mExecutor = executor;
            mSubscribedEvents = subscribedEvents;
        }
    }

    private static class CarProjectionStatusListenerImpl
            extends ICarProjectionStatusListener.Stub {

        private @ProjectionState int mCurrentState;
        private @Nullable String mCurrentPackageName;
        private List<ProjectionStatus> mDetails = new ArrayList<>(0);

        private final WeakReference<CarProjectionManager> mManagerRef;

        private CarProjectionStatusListenerImpl(CarProjectionManager mgr) {
            mManagerRef = new WeakReference<>(mgr);
        }

        @Override
        public void onProjectionStatusChanged(int projectionState,
                String packageName,
                List<ProjectionStatus> details) {
            CarProjectionManager mgr = mManagerRef.get();
            if (mgr != null) {
                mgr.getEventHandler().post(() -> {
                    mCurrentState = projectionState;
                    mCurrentPackageName = packageName;
                    mDetails = Collections.unmodifiableList(details);

                    mgr.handleProjectionStatusChanged(projectionState, packageName, mDetails);
                });
            }
        }
    }
}
