Merge "Extend CarProjectionManager to support wireless"
diff --git a/car-lib/api/system-current.txt b/car-lib/api/system-current.txt
index c4c4385..55fc0c6 100644
--- a/car-lib/api/system-current.txt
+++ b/car-lib/api/system-current.txt
@@ -42,6 +42,8 @@
     method public void onCarDisconnected();
     method public void registerProjectionListener(android.car.CarProjectionManager.CarProjectionListener, int) throws android.car.CarNotConnectedException;
     method public void registerProjectionRunner(android.content.Intent) throws android.car.CarNotConnectedException;
+    method public void startProjectionAccessPoint(android.car.CarProjectionManager.ProjectionAccessPointCallback);
+    method public void stopProjectionAccessPoint();
     method public void unregisterProjectionListener();
     method public void unregisterProjectionRunner(android.content.Intent);
     field public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 2; // 0x2
@@ -52,6 +54,17 @@
     method public abstract void onVoiceAssistantRequest(boolean);
   }
 
+  public static abstract class CarProjectionManager.ProjectionAccessPointCallback {
+    ctor public CarProjectionManager.ProjectionAccessPointCallback();
+    method public void onFailed(int);
+    method public void onStarted(android.net.wifi.WifiConfiguration);
+    method public void onStopped();
+    field public static final int ERROR_GENERIC = 2; // 0x2
+    field public static final int ERROR_INCOMPATIBLE_MODE = 3; // 0x3
+    field public static final int ERROR_NO_CHANNEL = 1; // 0x1
+    field public static final int ERROR_TETHERING_DISALLOWED = 4; // 0x4
+  }
+
   public final class VehicleAreaDoor {
     field public static final int DOOR_HOOD = 268435456; // 0x10000000
     field public static final int DOOR_REAR = 536870912; // 0x20000000
diff --git a/car-lib/src/android/car/CarProjectionManager.java b/car-lib/src/android/car/CarProjectionManager.java
index d1824f6..ba6f92a 100644
--- a/car-lib/src/android/car/CarProjectionManager.java
+++ b/car-lib/src/android/car/CarProjectionManager.java
@@ -18,19 +18,31 @@
 
 import android.annotation.SystemApi;
 import android.content.Intent;
+import android.net.wifi.WifiConfiguration;
+import android.os.Binder;
 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.Log;
 
 import java.lang.ref.WeakReference;
 
 /**
  * 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 implements CarManagerBase {
+    private static final String TAG = CarProjectionManager.class.getSimpleName();
+
     /**
      * Listener to get projected notifications.
      *
@@ -52,6 +64,13 @@
      */
     public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 0x2;
 
+    /** @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 Handler mHandler;
     private final ICarProjectionCallbackImpl mBinderListener;
@@ -59,6 +78,11 @@
     private CarProjectionListener mListener;
     private int mVoiceSearchFilter;
 
+    private ProjectionAccessPointCallbackProxy mProjectionAccessPointCallbackProxy;
+
+    // Only one access point proxy object per process.
+    private static final IBinder mAccessPointProxyToken = new Binder();
+
     /**
      * @hide
      */
@@ -171,6 +195,123 @@
         // 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.
+     */
+    public void startProjectionAccessPoint(ProjectionAccessPointCallback callback) {
+        synchronized (this) {
+            Looper looper = mHandler.getLooper();
+            ProjectionAccessPointCallbackProxy proxy =
+                    new ProjectionAccessPointCallbackProxy(this, looper, callback);
+            try {
+                mService.startProjectionAccessPoint(proxy.getMessenger(), mAccessPointProxyToken);
+                mProjectionAccessPointCallbackProxy = proxy;
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+    }
+
+    /**
+     * Stop Wi-Fi Access Point for wireless projection receiver app.
+     */
+    public void stopProjectionAccessPoint() {
+        ProjectionAccessPointCallbackProxy proxy;
+        synchronized (this) {
+            proxy = mProjectionAccessPointCallbackProxy;
+            mProjectionAccessPointCallbackProxy = null;
+        }
+        if (proxy == null) {
+            return;
+        }
+
+        try {
+            mService.stopProjectionAccessPoint(mAccessPointProxyToken);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * 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. */
+        public void onStarted(WifiConfiguration wifiConfiguration) {}
+        /** 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) {
+                    Log.d(TAG, LOG_PREFIX + "handle message what: " + msg.what + " msg: " + msg);
+
+                    CarProjectionManager manager = mCarProjectionManagerRef.get();
+                    if (manager == null) {
+                        Log.w(TAG, LOG_PREFIX + "handle message post GC");
+                        return;
+                    }
+
+                    switch (msg.what) {
+                        case PROJECTION_AP_STARTED:
+                            WifiConfiguration config = (WifiConfiguration) msg.obj;
+                            if (config == null) {
+                                Log.e(TAG, LOG_PREFIX + "config cannot be null.");
+                                callback.onFailed(ProjectionAccessPointCallback.ERROR_GENERIC);
+                                return;
+                            }
+                            callback.onStarted(config);
+                            break;
+                        case PROJECTION_AP_STOPPED:
+                            Log.i(TAG, LOG_PREFIX + "hotspot stopped");
+                            callback.onStopped();
+                            break;
+                        case PROJECTION_AP_FAILED:
+                            int reasonCode = msg.arg1;
+                            Log.w(TAG, LOG_PREFIX + "failed to start.  reason: "
+                                    + reasonCode);
+                            callback.onFailed(reasonCode);
+                            break;
+                        default:
+                            Log.e(TAG, LOG_PREFIX + "unhandled message.  type: " + msg.what);
+                    }
+                }
+            };
+            mMessenger = new Messenger(mHandler);
+        }
+
+        Messenger getMessenger() {
+            return mMessenger;
+        }
+    }
+
     private void handleVoiceAssistantRequest(boolean fromLongPress) {
         CarProjectionListener listener;
         synchronized (this) {
@@ -196,12 +337,7 @@
             if (manager == null) {
                 return;
             }
-            manager.mHandler.post(new Runnable() {
-                @Override
-                public void run() {
-                    manager.handleVoiceAssistantRequest(fromLongPress);
-                }
-            });
+            manager.mHandler.post(() -> manager.handleVoiceAssistantRequest(fromLongPress));
         }
     }
 }
diff --git a/car-lib/src/android/car/ICarProjection.aidl b/car-lib/src/android/car/ICarProjection.aidl
index 15831cd..4f80e6c 100644
--- a/car-lib/src/android/car/ICarProjection.aidl
+++ b/car-lib/src/android/car/ICarProjection.aidl
@@ -18,6 +18,7 @@
 
 import android.car.ICarProjectionCallback;
 import android.content.Intent;
+import android.os.Messenger;
 
 /**
  * Binder interface for {@link android.car.CarProjectionManager}.
@@ -48,4 +49,15 @@
      * Unregisters projection callback.
      */
     void unregisterProjectionListener(ICarProjectionCallback callback) = 3;
+
+    /**
+     * Starts Wi-Fi access point if it hasn't been started yet for wireless projection and returns
+     * WiFiConfiguration object with access point details.
+     */
+    void startProjectionAccessPoint(in Messenger messenger, in IBinder binder) = 4;
+
+    /**
+     * Stops previously requested Wi-Fi access point.
+     */
+    void stopProjectionAccessPoint(in IBinder binder) = 5;
 }
diff --git a/service/res/values/config.xml b/service/res/values/config.xml
index 8d436e0..5a8fce6 100644
--- a/service/res/values/config.xml
+++ b/service/res/values/config.xml
@@ -135,4 +135,6 @@
         <item>7d,3</item>
     </string-array>
 
+    <string name="config_TetheredProjectionAccessPointSsid" translatable="false">CarAP</string>
+
 </resources>
diff --git a/service/src/com/android/car/CarProjectionService.java b/service/src/com/android/car/CarProjectionService.java
index 5eb453f..f47362c 100644
--- a/service/src/com/android/car/CarProjectionService.java
+++ b/service/src/com/android/car/CarProjectionService.java
@@ -15,21 +15,58 @@
  */
 package com.android.car;
 
+import static android.car.CarProjectionManager.PROJECTION_LONG_PRESS_VOICE_SEARCH;
+import static android.car.CarProjectionManager.PROJECTION_VOICE_SEARCH;
+import static android.car.CarProjectionManager.ProjectionAccessPointCallback.ERROR_GENERIC;
+import static android.net.wifi.WifiManager.EXTRA_PREVIOUS_WIFI_AP_STATE;
+import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_FAILURE_REASON;
+import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_INTERFACE_NAME;
+import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_MODE;
+import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_STATE;
+import static android.net.wifi.WifiManager.WIFI_AP_STATE_DISABLED;
+import static android.net.wifi.WifiManager.WIFI_AP_STATE_ENABLED;
+import static android.net.wifi.WifiManager.WIFI_AP_STATE_ENABLING;
+
+import android.annotation.Nullable;
 import android.car.CarProjectionManager;
+import android.car.CarProjectionManager.ProjectionAccessPointCallback;
 import android.car.ICarProjection;
 import android.car.ICarProjectionCallback;
+import android.content.BroadcastReceiver;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.ServiceConnection;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiConfiguration.GroupCipher;
+import android.net.wifi.WifiConfiguration.KeyMgmt;
+import android.net.wifi.WifiConfiguration.PairwiseCipher;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.LocalOnlyHotspotCallback;
+import android.net.wifi.WifiManager.LocalOnlyHotspotReservation;
+import android.net.wifi.WifiManager.SoftApCallback;
 import android.os.Binder;
+import android.os.Handler;
 import android.os.IBinder;
+import android.os.Message;
+import android.os.Messenger;
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.util.Log;
 import android.view.KeyEvent;
 
+import com.android.internal.annotations.GuardedBy;
+
 import java.io.PrintWriter;
+import java.lang.ref.WeakReference;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Random;
 
 /**
  * Car projection service allows to bound to projected app to boost it prioirity.
@@ -37,9 +74,41 @@
  */
 class CarProjectionService extends ICarProjection.Stub implements CarServiceBase,
         BinderInterfaceContainer.BinderEventHandler<ICarProjectionCallback> {
-    private final ListenerHolder mAllListeners;
+    private static final String TAG = CarLog.TAG_PROJECTION;
+    private static final boolean DBG = true;
+
+    private final ProjectionCallbackHolder mProjectionCallbacks;
     private final CarInputService mCarInputService;
     private final Context mContext;
+    private final WifiManager mWifiManager;
+    private final Handler mHandler;
+    private final Object mLock = new Object();
+
+    @GuardedBy("mLock")
+    private final HashMap<IBinder, WirelessClient> mWirelessClients = new HashMap<>();
+
+    @Nullable
+    @GuardedBy("mLock")
+    private LocalOnlyHotspotReservation mLocalOnlyHotspotReservation;
+
+    @Nullable
+    @GuardedBy("mLock")
+    private SoftApCallback mSoftApCallback;
+
+    @Nullable
+    private String mApBssid;
+
+    private static final int WIFI_MODE_TETHERED = 1;
+    private static final int WIFI_MODE_LOCALONLY = 2;
+
+    private static final int RAND_SSID_INT_MIN = 1000;
+    private static final int RAND_SSID_INT_MAX = 9999;
+
+    // Could be one of the WIFI_MODE_* constants.
+    // TODO: read this from user settings, support runtime switch
+    private int mWifiMode = WIFI_MODE_LOCALONLY;
+
+    private final WifiConfiguration mProjectionWifiConfiguration;
 
     private final CarInputService.KeyEventListener mVoiceAssistantKeyListener =
             new CarInputService.KeyEventListener() {
@@ -62,7 +131,7 @@
     private final ServiceConnection mConnection = new ServiceConnection() {
             @Override
             public void onServiceConnected(ComponentName className, IBinder service) {
-                synchronized (CarProjectionService.this) {
+                synchronized (mLock) {
                     mBound = true;
                 }
             }
@@ -83,14 +152,17 @@
 
     CarProjectionService(Context context, CarInputService carInputService) {
         mContext = context;
+        mHandler = new Handler();
         mCarInputService = carInputService;
-        mAllListeners = new ListenerHolder(this);
+        mProjectionCallbacks = new ProjectionCallbackHolder(this);
+        mWifiManager = context.getSystemService(WifiManager.class);
+        mProjectionWifiConfiguration = createWifiConfiguration(context);
     }
 
     @Override
     public void registerProjectionRunner(Intent serviceIntent) {
         // We assume one active projection app running in the system at one time.
-        synchronized (this) {
+        synchronized (mLock) {
             if (serviceIntent.filterEquals(mRegisteredService)) {
                 return;
             }
@@ -105,7 +177,7 @@
 
     @Override
     public void unregisterProjectionRunner(Intent serviceIntent) {
-        synchronized (this) {
+        synchronized (mLock) {
             if (!serviceIntent.filterEquals(mRegisteredService)) {
                 Log.w(CarLog.TAG_PROJECTION, "Request to unbind unregistered service["
                         + serviceIntent + "]. Registered service[" + mRegisteredService + "]");
@@ -117,7 +189,7 @@
     }
 
     private void bindToService(Intent serviceIntent) {
-        synchronized (this) {
+        synchronized (mLock) {
             mRegisteredService = serviceIntent;
         }
         UserHandle userHandle = UserHandle.getUserHandleForUid(Binder.getCallingUid());
@@ -126,7 +198,7 @@
     }
 
     private void unbindServiceIfBound() {
-        synchronized (this) {
+        synchronized (mLock) {
             if (!mBound) {
                 return;
             }
@@ -135,26 +207,29 @@
         mContext.unbindService(mConnection);
     }
 
-    private synchronized void handleVoiceAssitantRequest(boolean isTriggeredByLongPress) {
-        for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                 mAllListeners.getInterfaces()) {
-            ListenerInfo listenerInfo = (ListenerInfo) listener;
-            if ((listenerInfo.hasFilter(CarProjectionManager.PROJECTION_LONG_PRESS_VOICE_SEARCH)
-                    && isTriggeredByLongPress)
-                    || (listenerInfo.hasFilter(CarProjectionManager.PROJECTION_VOICE_SEARCH)
-                    && !isTriggeredByLongPress)) {
-                dispatchVoiceAssistantRequest(listenerInfo.binderInterface, isTriggeredByLongPress);
+    private void handleVoiceAssitantRequest(boolean isTriggeredByLongPress) {
+        synchronized (mLock) {
+            for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
+                    mProjectionCallbacks.getInterfaces()) {
+                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
+                if ((projectionCallback.hasFilter(PROJECTION_LONG_PRESS_VOICE_SEARCH)
+                        && isTriggeredByLongPress)
+                        || (projectionCallback.hasFilter(PROJECTION_VOICE_SEARCH)
+                        && !isTriggeredByLongPress)) {
+                    dispatchVoiceAssistantRequest(
+                            projectionCallback.binderInterface, isTriggeredByLongPress);
+                }
             }
         }
     }
 
     @Override
-    public void registerProjectionListener(ICarProjectionCallback listener, int filter) {
-        synchronized (this) {
-            ListenerInfo info = (ListenerInfo) mAllListeners.getBinderInterface(listener);
+    public void registerProjectionListener(ICarProjectionCallback callback, int filter) {
+        synchronized (mLock) {
+            ProjectionCallback info = mProjectionCallbacks.get(callback);
             if (info == null) {
-                info = new ListenerInfo(mAllListeners, listener, filter);
-                mAllListeners.addBinderInterface(info);
+                info = new ProjectionCallback(mProjectionCallbacks, callback, filter);
+                mProjectionCallbacks.addBinderInterface(info);
             } else {
                 info.setFilter(filter);
             }
@@ -164,23 +239,221 @@
 
     @Override
     public void unregisterProjectionListener(ICarProjectionCallback listener) {
-        synchronized (this) {
-            mAllListeners.removeBinder(listener);
+        synchronized (mLock) {
+            mProjectionCallbacks.removeBinder(listener);
         }
         updateCarInputServiceListeners();
     }
 
+    @Override
+    public void startProjectionAccessPoint(final Messenger messenger, IBinder binder)
+            throws RemoteException {
+        //TODO: check if access point already started with the desired configuration.
+        registerWirelessClient(WirelessClient.of(messenger, binder));
+        startAccessPoint();
+    }
+
+    @Override
+    public void stopProjectionAccessPoint(IBinder token) {
+        Log.i(TAG, "Received stop access point request from " + token);
+
+        boolean shouldReleaseAp;
+        synchronized (mLock) {
+            if (!unregisterWirelessClientLocked(token)) {
+                Log.w(TAG, "Client " + token + " was not registered");
+                return;
+            }
+            shouldReleaseAp = mWirelessClients.isEmpty();
+        }
+
+        if (shouldReleaseAp) {
+            stopAccessPoint();
+        }
+    }
+
+    private void startAccessPoint() {
+        synchronized (mLock) {
+            switch (mWifiMode) {
+                case WIFI_MODE_LOCALONLY: {
+                    startLocalOnlyApLocked();
+                    break;
+                }
+                case WIFI_MODE_TETHERED: {
+                    startTetheredApLocked();
+                    break;
+                }
+                default: {
+                    Log.wtf(TAG, "Unexpected Access Point mode during starting: " + mWifiMode);
+                    break;
+                }
+            }
+        }
+    }
+
+    private void stopAccessPoint() {
+        sendApStopped();
+
+        synchronized (mLock) {
+            switch (mWifiMode) {
+                case WIFI_MODE_LOCALONLY: {
+                    stopLocalOnlyApLocked();
+                    break;
+                }
+                case WIFI_MODE_TETHERED: {
+                    stopTetheredApLocked();
+                    break;
+                }
+                default: {
+                    Log.wtf(TAG, "Unexpected Access Point mode during stopping : " + mWifiMode);
+                }
+            }
+        }
+    }
+
+    private void startTetheredApLocked() {
+        Log.d(TAG, "startTetheredApLocked");
+
+        final SoftApCallback callback = new ProjectionSoftApCallback();
+        mWifiManager.registerSoftApCallback(callback, mHandler);
+
+        if (!mWifiManager.startSoftAp(mProjectionWifiConfiguration)) {
+            Log.e(TAG, "Failed to start soft AP");
+            mWifiManager.unregisterSoftApCallback(callback);
+            sendApFailed(ERROR_GENERIC);
+        } else {
+            mSoftApCallback = callback;
+        }
+    }
+
+    private void stopTetheredApLocked() {
+        Log.d(TAG, "stopTetheredAp");
+
+        if (mSoftApCallback != null) {
+            mWifiManager.unregisterSoftApCallback(mSoftApCallback);
+            mSoftApCallback = null;
+            if (!mWifiManager.stopSoftAp()) {
+                Log.w(TAG, "Failed to request soft AP to stop.");
+            }
+        }
+    }
+
+    private void startLocalOnlyApLocked() {
+        if (mLocalOnlyHotspotReservation != null) {
+            Log.i(TAG, "Local-only hotspot is already registered.");
+            sendApStarted(mLocalOnlyHotspotReservation.getWifiConfiguration());
+            return;
+        }
+
+        Log.i(TAG, "Requesting to start local-only hotspot.");
+        mWifiManager.startLocalOnlyHotspot(new LocalOnlyHotspotCallback() {
+            @Override
+            public void onStarted(LocalOnlyHotspotReservation reservation) {
+                Log.d(TAG, "Local-only hotspot started");
+                synchronized (mLock) {
+                    mLocalOnlyHotspotReservation = reservation;
+                }
+                sendApStarted(reservation.getWifiConfiguration());
+            }
+
+            @Override
+            public void onStopped() {
+                Log.i(TAG, "Local-only hotspot stopped.");
+                synchronized (mLock) {
+                    mLocalOnlyHotspotReservation = null;
+                }
+                sendApStopped();
+            }
+
+            @Override
+            public void onFailed(int localonlyHostspotFailureReason) {
+                Log.w(TAG, "Local-only hotspot failed, reason: "
+                        + localonlyHostspotFailureReason);
+                synchronized (mLock) {
+                    mLocalOnlyHotspotReservation = null;
+                }
+                int reason;
+                switch (localonlyHostspotFailureReason) {
+                    case LocalOnlyHotspotCallback.ERROR_NO_CHANNEL:
+                        reason = ProjectionAccessPointCallback.ERROR_NO_CHANNEL;
+                        break;
+                    case LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED:
+                        reason = ProjectionAccessPointCallback.ERROR_TETHERING_DISALLOWED;
+                        break;
+                    case LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE:
+                        reason = ProjectionAccessPointCallback.ERROR_INCOMPATIBLE_MODE;
+                        break;
+                    default:
+                        reason = ProjectionAccessPointCallback.ERROR_GENERIC;
+
+                }
+                sendApFailed(reason);
+            }
+        }, mHandler);
+    }
+
+    private void stopLocalOnlyApLocked() {
+        Log.i(TAG, "stopLocalOnlyApLocked");
+
+        if (mLocalOnlyHotspotReservation == null) {
+            Log.w(TAG, "Requested to stop local-only hotspot which was already stopped.");
+            return;
+        }
+
+        mLocalOnlyHotspotReservation.close();
+        mLocalOnlyHotspotReservation = null;
+    }
+
+    private void sendApStarted(WifiConfiguration wifiConfiguration) {
+        WifiConfiguration localWifiConfig = new WifiConfiguration(wifiConfiguration);
+        localWifiConfig.BSSID = mApBssid;
+
+        Message message = Message.obtain();
+        message.what = CarProjectionManager.PROJECTION_AP_STARTED;
+        message.obj = localWifiConfig;
+        Log.i(TAG, "Sending PROJECTION_AP_STARTED, ssid: "
+                + localWifiConfig.getPrintableSsid()
+                + ", apBand: " + localWifiConfig.apBand
+                + ", apChannel: " + localWifiConfig.apChannel
+                + ", bssid: " + localWifiConfig.BSSID);
+        sendApStatusMessage(message);
+    }
+
+    private void sendApStopped() {
+        Message message = Message.obtain();
+        message.what = CarProjectionManager.PROJECTION_AP_STOPPED;
+        sendApStatusMessage(message);
+        unregisterWirelessClients();
+    }
+
+    private void sendApFailed(int reason) {
+        Message message = Message.obtain();
+        message.what = CarProjectionManager.PROJECTION_AP_FAILED;
+        message.arg1 = reason;
+        sendApStatusMessage(message);
+        unregisterWirelessClients();
+    }
+
+    private void sendApStatusMessage(Message message) {
+        List<WirelessClient> clients;
+        synchronized (mLock) {
+            clients = new ArrayList<>(mWirelessClients.values());
+        }
+        for (WirelessClient client : clients) {
+            client.send(message);
+        }
+    }
+
     private void updateCarInputServiceListeners() {
         boolean listenShortPress = false;
         boolean listenLongPress = false;
-        synchronized (this) {
+        synchronized (mLock) {
             for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                         mAllListeners.getInterfaces()) {
-                ListenerInfo listenerInfo = (ListenerInfo) listener;
-                listenShortPress |= listenerInfo.hasFilter(
-                        CarProjectionManager.PROJECTION_VOICE_SEARCH);
-                listenLongPress |= listenerInfo.hasFilter(
-                        CarProjectionManager.PROJECTION_LONG_PRESS_VOICE_SEARCH);
+                         mProjectionCallbacks.getInterfaces()) {
+                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
+                listenShortPress |= projectionCallback.hasFilter(
+                        PROJECTION_VOICE_SEARCH);
+                listenLongPress |= projectionCallback.hasFilter(
+                        PROJECTION_LONG_PRESS_VOICE_SEARCH);
             }
         }
         mCarInputService.setVoiceAssistantKeyListener(listenShortPress
@@ -191,13 +464,48 @@
 
     @Override
     public void init() {
-        // nothing to do
+        mContext.registerReceiver(
+                new BroadcastReceiver() {
+                    @Override
+                    public void onReceive(Context context, Intent intent) {
+                        final int currState = intent.getIntExtra(EXTRA_WIFI_AP_STATE,
+                                WIFI_AP_STATE_DISABLED);
+                        final int prevState = intent.getIntExtra(EXTRA_PREVIOUS_WIFI_AP_STATE,
+                                WIFI_AP_STATE_DISABLED);
+                        final int errorCode = intent.getIntExtra(EXTRA_WIFI_AP_FAILURE_REASON, 0);
+                        final String ifaceName =
+                                intent.getStringExtra(EXTRA_WIFI_AP_INTERFACE_NAME);
+                        final int mode = intent.getIntExtra(EXTRA_WIFI_AP_MODE,
+                                WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+                        handleWifiApStateChange(currState, prevState, errorCode, ifaceName, mode);
+                    }
+                },
+                new IntentFilter(WifiManager.WIFI_AP_STATE_CHANGED_ACTION));
+    }
+
+    private void handleWifiApStateChange(int currState, int prevState, int errorCode,
+            String ifaceName, int mode) {
+        if (currState == WIFI_AP_STATE_ENABLING || currState == WIFI_AP_STATE_ENABLED) {
+            Log.d(TAG,
+                    "handleWifiApStateChange, curState: " + currState + ", prevState: " + prevState
+                            + ", errorCode: " + errorCode + ", ifaceName: " + ifaceName + ", mode: "
+                            + mode);
+
+            try {
+                NetworkInterface iface = NetworkInterface.getByName(ifaceName);
+                byte[] bssid = iface.getHardwareAddress();
+                mApBssid = String.format("%02x:%02x:%02x:%02x:%02x:%02x",
+                        bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]);
+            } catch (SocketException e) {
+                Log.e(TAG, e.toString(), e);
+            }
+        }
     }
 
     @Override
     public void release() {
-        synchronized (this) {
-            mAllListeners.clear();
+        synchronized (mLock) {
+            mProjectionCallbacks.clear();
         }
     }
 
@@ -210,12 +518,17 @@
     @Override
     public void dump(PrintWriter writer) {
         writer.println("**CarProjectionService**");
-        synchronized (this) {
+        synchronized (mLock) {
             for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                         mAllListeners.getInterfaces()) {
-                ListenerInfo listenerInfo = (ListenerInfo) listener;
-                writer.println(listenerInfo.toString());
+                         mProjectionCallbacks.getInterfaces()) {
+                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
+                writer.println(projectionCallback.toString());
             }
+
+            writer.println("Local-only hotspot reservation: " + mLocalOnlyHotspotReservation);
+            writer.println("Wireless clients: " +  mWirelessClients.size());
+            writer.println("Current wifi mode: " + mWifiMode);
+            writer.println("SoftApCallback: " + mSoftApCallback);
         }
     }
 
@@ -227,17 +540,23 @@
         }
     }
 
-    private static class ListenerHolder extends BinderInterfaceContainer<ICarProjectionCallback> {
-        private ListenerHolder(CarProjectionService service) {
+    private static class ProjectionCallbackHolder
+            extends BinderInterfaceContainer<ICarProjectionCallback> {
+        ProjectionCallbackHolder(CarProjectionService service) {
             super(service);
         }
+
+        ProjectionCallback get(ICarProjectionCallback projectionCallback) {
+            return (ProjectionCallback) getBinderInterface(projectionCallback);
+        }
     }
 
-    private static class ListenerInfo extends
+    private static class ProjectionCallback extends
             BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> {
         private int mFilter;
 
-        private ListenerInfo(ListenerHolder holder, ICarProjectionCallback binder, int filter) {
+        private ProjectionCallback(ProjectionCallbackHolder holder, ICarProjectionCallback binder,
+                int filter) {
             super(holder, binder);
             this.mFilter = filter;
         }
@@ -256,9 +575,166 @@
 
         @Override
         public String toString() {
-            synchronized (this) {
-                return "ListenerInfo{filter=" + Integer.toHexString(mFilter) + "}";
+            return "ListenerInfo{filter=" + Integer.toHexString(getFilter()) + "}";
+        }
+    }
+
+    private static WifiConfiguration createWifiConfiguration(Context context) {
+        //TODO: consider to read current AP configuration and modify only parts that matter for
+        //wireless projection (apBand, key management), do not modify password if it was set.
+        WifiConfiguration config = new WifiConfiguration();
+        config.apBand = WifiConfiguration.AP_BAND_5GHZ;
+        config.SSID = context.getResources()
+                .getString(R.string.config_TetheredProjectionAccessPointSsid)
+                + "_" + getRandomIntForDefaultSsid();
+        config.allowedKeyManagement.set(KeyMgmt.WPA2_PSK);
+        config.allowedPairwiseCiphers.set(PairwiseCipher.CCMP);
+        config.allowedGroupCiphers.set(GroupCipher.CCMP);
+        config.preSharedKey = RandomPassword.generate();
+        return config;
+    }
+
+    private void registerWirelessClient(WirelessClient client) throws RemoteException {
+        synchronized (mLock) {
+            if (unregisterWirelessClientLocked(client.token)) {
+                Log.i(TAG, "Client was already registered, override it.");
+            }
+            mWirelessClients.put(client.token, client);
+        }
+        client.token.linkToDeath(new WirelessClientDeathRecipient(this, client), 0);
+    }
+
+    private void unregisterWirelessClients() {
+        synchronized (mLock) {
+            for (WirelessClient client: mWirelessClients.values()) {
+                client.token.unlinkToDeath(client.deathRecipient, 0);
+            }
+            mWirelessClients.clear();
+        }
+    }
+
+    private boolean unregisterWirelessClientLocked(IBinder token) {
+        WirelessClient client = mWirelessClients.remove(token);
+        if (client != null) {
+            token.unlinkToDeath(client.deathRecipient, 0);
+        }
+
+        return client != null;
+    }
+
+    private class ProjectionSoftApCallback implements SoftApCallback {
+        @Override
+        public void onStateChanged(int state, int softApFailureReason) {
+            Log.i(TAG, "ProjectionSoftApCallback, onStateChanged, state: " + state
+                    + ", failed reason: softApFailureReason");
+
+            switch (state) {
+                case WifiManager.WIFI_AP_STATE_ENABLED: {
+                    sendApStarted(mProjectionWifiConfiguration);
+                    break;
+                }
+                case WIFI_AP_STATE_DISABLED: {
+                    sendApStopped();
+                    break;
+                }
+                case WifiManager.WIFI_AP_STATE_FAILED: {
+                    Log.w(TAG, "WIFI_AP_STATE_FAILED, reason: " + softApFailureReason);
+                    int reason;
+                    switch (softApFailureReason) {
+                        case WifiManager.SAP_START_FAILURE_NO_CHANNEL:
+                            reason = ProjectionAccessPointCallback.ERROR_NO_CHANNEL;
+                            break;
+                        default:
+                            reason = ERROR_GENERIC;
+                    }
+                    sendApFailed(reason);
+                    break;
+                }
             }
         }
+
+        @Override
+        public void onNumClientsChanged(int numClients) {
+            Log.i(TAG, "ProjectionSoftApCallback, onNumClientsChanged: " + numClients);
+        }
+    }
+
+    private static class WirelessClient {
+        public final Messenger messenger;
+        public final IBinder token;
+        public @Nullable DeathRecipient deathRecipient;
+
+        private WirelessClient(Messenger messenger, IBinder token) {
+            this.messenger = messenger;
+            this.token = token;
+        }
+
+        private static WirelessClient of(Messenger messenger, IBinder token) {
+            return new WirelessClient(messenger, token);
+        }
+
+        void send(Message message) {
+            try {
+                Log.d(TAG, "Sending message " + message.what + " to " + this);
+                messenger.send(message);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Failed to send message", e);
+            }
+        }
+
+        @Override
+        public String toString() {
+            return getClass().getSimpleName()
+                    + "{token= " + token
+                    + ", deathRecipient=" + deathRecipient + "}";
+        }
+    }
+
+    private static class WirelessClientDeathRecipient implements DeathRecipient {
+        final WeakReference<CarProjectionService> mServiceRef;
+        final WirelessClient mClient;
+
+        WirelessClientDeathRecipient(CarProjectionService service, WirelessClient client) {
+            mServiceRef = new WeakReference<>(service);
+            mClient = client;
+            mClient.deathRecipient = this;
+        }
+
+        @Override
+        public void binderDied() {
+            Log.w(TAG, "Wireless client " + mClient + " died.");
+            CarProjectionService service = mServiceRef.get();
+            if (service == null) return;
+
+            synchronized (service.mLock) {
+                service.unregisterWirelessClientLocked(mClient.token);
+            }
+        }
+    }
+
+    private static class RandomPassword {
+        private static final int PASSWORD_LENGTH = 12;
+        private static final String PW_NUMBER = "0123456789";
+        private static final String PW_LOWER_CASE = "abcdefghijklmnopqrstuvwxyz";
+        private static final String PW_UPPER_CASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+        private static final char[] SYMBOLS =
+                (PW_NUMBER + PW_LOWER_CASE + PW_UPPER_CASE).toCharArray();
+
+        static String generate() {
+            SecureRandom random = new SecureRandom();
+
+            StringBuilder password = new StringBuilder();
+            while (password.length() < PASSWORD_LENGTH) {
+                int randomIndex = random.nextInt(SYMBOLS.length);
+                password.append(SYMBOLS[randomIndex]);
+            }
+            return password.toString();
+        }
+    }
+
+    private static int getRandomIntForDefaultSsid() {
+        Random random = new Random();
+        return random.nextInt((RAND_SSID_INT_MAX - RAND_SSID_INT_MIN) + 1) + RAND_SSID_INT_MIN;
     }
 }
diff --git a/tests/android_car_api_test/src/android/car/apitest/CarProjectionManagerTest.java b/tests/android_car_api_test/src/android/car/apitest/CarProjectionManagerTest.java
index 889a60a..5b3953f 100644
--- a/tests/android_car_api_test/src/android/car/apitest/CarProjectionManagerTest.java
+++ b/tests/android_car_api_test/src/android/car/apitest/CarProjectionManagerTest.java
@@ -18,12 +18,18 @@
 import android.app.Service;
 import android.car.Car;
 import android.car.CarProjectionManager;
+import android.car.CarProjectionManager.ProjectionAccessPointCallback;
 import android.content.Intent;
+import android.net.wifi.WifiConfiguration;
 import android.os.Binder;
 import android.os.IBinder;
-import android.test.suitebuilder.annotation.MediumTest;
+import android.support.test.filters.RequiresDevice;
+import android.test.suitebuilder.annotation.LargeTest;
 
-@MediumTest
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@LargeTest
 public class CarProjectionManagerTest extends CarApiTestBase {
     private static final String TAG = CarProjectionManagerTest.class.getSimpleName();
 
@@ -96,4 +102,20 @@
         assertTrue(TestService.getBound());
         mManager.unregisterProjectionRunner(intent);
     }
+
+    @RequiresDevice
+    public void testAccessPoint() throws Exception {
+        CountDownLatch startedLatch = new CountDownLatch(1);
+
+        mManager.startProjectionAccessPoint(new ProjectionAccessPointCallback() {
+            @Override
+            public void onStarted(WifiConfiguration wifiConfiguration) {
+                startedLatch.countDown();
+            }
+        });
+
+        assertTrue(startedLatch.await(30, TimeUnit.SECONDS));
+
+        mManager.stopProjectionAccessPoint();
+    }
 }