/* * Copyright (C) 2022 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 com.android.server.wifi; import android.app.ActivityOptions; import android.content.Intent; import android.net.wifi.WifiContext; import android.net.wifi.WifiManager; import android.os.UserHandle; import android.util.ArraySet; import android.util.Log; import android.util.SparseArray; import android.view.Display; import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.modules.utils.build.SdkLevel; import java.util.Set; import javax.annotation.concurrent.ThreadSafe; /** * Class to manage launching dialogs via WifiDialog and returning the user reply. * All methods run on the main Wi-Fi thread runner except those annotated with @AnyThread, which can * run on any thread. */ public class WifiDialogManager { private static final String TAG = "WifiDialogManager"; @VisibleForTesting static final String WIFI_DIALOG_ACTIVITY_CLASSNAME = "com.android.wifi.dialog.WifiDialogActivity"; private boolean mVerboseLoggingEnabled; private int mNextDialogId = 0; private final Set mActiveDialogIds = new ArraySet<>(); private final @NonNull SparseArray mActiveDialogHandles = new SparseArray<>(); private final @NonNull WifiContext mContext; private final @NonNull WifiThreadRunner mWifiThreadRunner; /** * Constructs a WifiDialogManager * * @param context Main Wi-Fi context. * @param wifiThreadRunner Main Wi-Fi thread runner. */ public WifiDialogManager( @NonNull WifiContext context, @NonNull WifiThreadRunner wifiThreadRunner) { mContext = context; mWifiThreadRunner = wifiThreadRunner; } /** * Enables verbose logging. */ public void enableVerboseLogging(boolean enabled) { mVerboseLoggingEnabled = enabled; } private int getNextDialogId() { if (mActiveDialogIds.isEmpty() || mNextDialogId == WifiManager.INVALID_DIALOG_ID) { mNextDialogId = 0; } return mNextDialogId++; } private @Nullable Intent getBaseLaunchIntent(@WifiManager.DialogType int dialogType) { Intent intent = new Intent(WifiManager.ACTION_LAUNCH_DIALOG) .putExtra(WifiManager.EXTRA_DIALOG_TYPE, dialogType) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); String wifiDialogApkPkgName = mContext.getWifiDialogApkPkgName(); if (wifiDialogApkPkgName == null) { Log.w(TAG, "Could not get WifiDialog APK package name!"); return null; } intent.setClassName(wifiDialogApkPkgName, WIFI_DIALOG_ACTIVITY_CLASSNAME); return intent; } private @Nullable Intent getDismissIntent(int dialogId) { Intent intent = new Intent(WifiManager.ACTION_DISMISS_DIALOG); intent.putExtra(WifiManager.EXTRA_DIALOG_ID, dialogId); String wifiDialogApkPkgName = mContext.getWifiDialogApkPkgName(); if (wifiDialogApkPkgName == null) { Log.w(TAG, "Could not get WifiDialog APK package name!"); return null; } intent.setClassName(wifiDialogApkPkgName, WIFI_DIALOG_ACTIVITY_CLASSNAME); return intent; } /** * Handle for launching and dismissing a dialog from any thread. */ @ThreadSafe public class DialogHandle { DialogHandleInternal mInternalHandle; private DialogHandle(DialogHandleInternal internalHandle) { mInternalHandle = internalHandle; } /** * Launches the dialog. */ @AnyThread public void launchDialog() { mWifiThreadRunner.post(() -> mInternalHandle.launchDialog(0)); } /** * Launches the dialog with a timeout before it is auto-cancelled. * @param timeoutMs timeout in milliseconds before the dialog is auto-cancelled. A value <=0 * indicates no timeout. */ @AnyThread public void launchDialog(long timeoutMs) { mWifiThreadRunner.post(() -> mInternalHandle.launchDialog(timeoutMs)); } /** * Dismisses the dialog. Dialogs will automatically be dismissed once the user replies, but * this method may be used to dismiss unanswered dialogs that are no longer needed. */ @AnyThread public void dismissDialog() { mWifiThreadRunner.post(() -> mInternalHandle.dismissDialog()); } } /** * Internal handle for launching and dismissing a dialog on the main Wi-Fi thread runner. * @see {@link DialogHandle} */ private class DialogHandleInternal { private int mDialogId = WifiManager.INVALID_DIALOG_ID; private final @NonNull Intent mIntent; private Runnable mTimeoutRunnable; private final int mDisplayId; DialogHandleInternal(@NonNull Intent intent, int displayId) throws IllegalArgumentException { if (intent == null) { throw new IllegalArgumentException("Intent cannot be null!"); } mDisplayId = displayId; mIntent = intent; } /** * @see {@link DialogHandle#launchDialog(long)} */ void launchDialog(long timeoutMs) { if (mDialogId != WifiManager.INVALID_DIALOG_ID) { // Dialog is already active, ignore. return; } registerDialog(); mIntent.putExtra(WifiManager.EXTRA_DIALOG_ID, mDialogId); boolean launched = false; if (SdkLevel.isAtLeastT() && mDisplayId != Display.DEFAULT_DISPLAY) { try { mContext.startActivityAsUser(mIntent, ActivityOptions.makeBasic().setLaunchDisplayId(mDisplayId).toBundle(), UserHandle.CURRENT); launched = true; } catch (Exception e) { Log.e(TAG, "Error startActivityAsUser - " + e); } } if (!launched) { mContext.startActivityAsUser(mIntent, UserHandle.CURRENT); } if (mVerboseLoggingEnabled) { Log.v(TAG, "Launching dialog with id=" + mDialogId); } if (timeoutMs > 0) { mTimeoutRunnable = () -> onTimeout(); mWifiThreadRunner.postDelayed(mTimeoutRunnable, timeoutMs); } } /** * Callback to run when the dialog times out. */ void onTimeout() { dismissDialog(); } /** * @see {@link DialogHandle#dismissDialog()} */ void dismissDialog() { if (mDialogId == WifiManager.INVALID_DIALOG_ID) { // Dialog is not active, ignore. return; } Intent dismissIntent = getDismissIntent(mDialogId); if (dismissIntent == null) { Log.e(TAG, "Could not create intent for dismissing dialog with id: " + mDialogId); return; } mContext.startActivityAsUser(dismissIntent, UserHandle.CURRENT); if (mVerboseLoggingEnabled) { Log.v(TAG, "Dismissing dialog with id=" + mDialogId); } unregisterDialog(); } /** * Assigns a dialog id to the dialog and registers it as an active dialog. */ void registerDialog() { if (mDialogId != WifiManager.INVALID_DIALOG_ID) { // Already registered. return; } mDialogId = getNextDialogId(); mActiveDialogIds.add(mDialogId); mActiveDialogHandles.put(mDialogId, this); if (mVerboseLoggingEnabled) { Log.v(TAG, "Registered dialog with id=" + mDialogId); } } /** * Unregisters the dialog as an active dialog and removes its dialog id. * This should be called after a dialog is replied to or dismissed. */ void unregisterDialog() { if (mDialogId == WifiManager.INVALID_DIALOG_ID) { // Already unregistered. return; } if (mTimeoutRunnable != null) { mWifiThreadRunner.removeCallbacks(mTimeoutRunnable); } mTimeoutRunnable = null; mActiveDialogIds.remove(mDialogId); mActiveDialogHandles.remove(mDialogId); mDialogId = WifiManager.INVALID_DIALOG_ID; if (mVerboseLoggingEnabled) { Log.v(TAG, "Unregistered dialog with id=" + mDialogId); } } } private class SimpleDialogHandle extends DialogHandleInternal { private @NonNull SimpleDialogCallback mCallback; private @NonNull WifiThreadRunner mCallbackThreadRunner; SimpleDialogHandle( final String title, final String message, final String messageUrl, final int messageUrlStart, final int messageUrlEnd, final String positiveButtonText, final String negativeButtonText, final String neutralButtonText, @NonNull SimpleDialogCallback callback, @NonNull WifiThreadRunner callbackThreadRunner) throws IllegalArgumentException { super(getBaseLaunchIntent(WifiManager.DIALOG_TYPE_SIMPLE) .putExtra(WifiManager.EXTRA_DIALOG_TITLE, title) .putExtra(WifiManager.EXTRA_DIALOG_MESSAGE, message) .putExtra(WifiManager.EXTRA_DIALOG_MESSAGE_URL, messageUrl) .putExtra(WifiManager.EXTRA_DIALOG_MESSAGE_URL_START, messageUrlStart) .putExtra(WifiManager.EXTRA_DIALOG_MESSAGE_URL_END, messageUrlEnd) .putExtra(WifiManager.EXTRA_DIALOG_POSITIVE_BUTTON_TEXT, positiveButtonText) .putExtra(WifiManager.EXTRA_DIALOG_NEGATIVE_BUTTON_TEXT, negativeButtonText) .putExtra(WifiManager.EXTRA_DIALOG_NEUTRAL_BUTTON_TEXT, neutralButtonText), Display.DEFAULT_DISPLAY); if (messageUrl != null) { if (message == null) { throw new IllegalArgumentException("Cannot set span for null message!"); } if (messageUrlStart < 0) { throw new IllegalArgumentException("Span start cannot be less than 0!"); } if (messageUrlEnd > message.length()) { throw new IllegalArgumentException("Span end index " + messageUrlEnd + " cannot be greater than message length " + message.length() + "!"); } } if (callback == null) { throw new IllegalArgumentException("Callback cannot be null!"); } if (callbackThreadRunner == null) { throw new IllegalArgumentException("Callback thread runner cannot be null!"); } mCallback = callback; mCallbackThreadRunner = callbackThreadRunner; } void notifyOnPositiveButtonClicked() { mCallbackThreadRunner.post(() -> mCallback.onPositiveButtonClicked()); unregisterDialog(); } void notifyOnNegativeButtonClicked() { mCallbackThreadRunner.post(() -> mCallback.onNegativeButtonClicked()); unregisterDialog(); } void notifyOnNeutralButtonClicked() { mCallbackThreadRunner.post(() -> mCallback.onNeutralButtonClicked()); unregisterDialog(); } void notifyOnCancelled() { mCallbackThreadRunner.post(() -> mCallback.onCancelled()); unregisterDialog(); } @Override void onTimeout() { dismissDialog(); notifyOnCancelled(); } } /** * Callback for receiving simple dialog responses. */ public interface SimpleDialogCallback { /** * The positive button was clicked. */ void onPositiveButtonClicked(); /** * The negative button was clicked. */ void onNegativeButtonClicked(); /** * The neutral button was clicked. */ void onNeutralButtonClicked(); /** * The dialog was cancelled (back button or home button or timeout). */ void onCancelled(); } /** * Creates a simple dialog with optional title, message, and positive/negative/neutral buttons. * * @param title Title of the dialog. * @param message Message of the dialog. * @param positiveButtonText Text of the positive button or {@code null} for no button. * @param negativeButtonText Text of the negative button or {@code null} for no button. * @param neutralButtonText Text of the neutral button or {@code null} for no button. * @param callback Callback to receive the dialog response. * @param callbackThreadRunner WifiThreadRunner to run the callback on. * @return DialogHandle Handle for the dialog, or {@code null} if no dialog could * be created. */ @AnyThread @Nullable public DialogHandle createSimpleDialog( @Nullable String title, @Nullable String message, @Nullable String positiveButtonText, @Nullable String negativeButtonText, @Nullable String neutralButtonText, @NonNull SimpleDialogCallback callback, @NonNull WifiThreadRunner callbackThreadRunner) { try { return new DialogHandle( new SimpleDialogHandle( title, message, null /* messageUrl */, 0 /* messageUrlStart */, 0 /* messageUrlEnd */, positiveButtonText, negativeButtonText, neutralButtonText, callback, callbackThreadRunner) ); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not create DialogHandle for simple dialog: " + e); return null; } } /** * Creates a simple dialog with a URL embedded in the message. * * @param title Title of the dialog. * @param message Message of the dialog. * @param messageUrl URL to embed in the message. If non-null, then message must also * be non-null. * @param messageUrlStart Start index (inclusive) of the URL in the message. Must be * non-negative. * @param messageUrlEnd End index (exclusive) of the URL in the message. Must be less * than the length of message. * @param positiveButtonText Text of the positive button or {@code null} for no button. * @param negativeButtonText Text of the negative button or {@code null} for no button. * @param neutralButtonText Text of the neutral button or {@code null} for no button. * @param callback Callback to receive the dialog response. * @param callbackThreadRunner WifiThreadRunner to run the callback on. * @return DialogHandle Handle for the dialog, or {@code null} if no dialog could * be created. */ @AnyThread @Nullable public DialogHandle createSimpleDialogWithUrl( @Nullable String title, @Nullable String message, @Nullable String messageUrl, int messageUrlStart, int messageUrlEnd, @Nullable String positiveButtonText, @Nullable String negativeButtonText, @Nullable String neutralButtonText, @NonNull SimpleDialogCallback callback, @NonNull WifiThreadRunner callbackThreadRunner) { try { return new DialogHandle( new SimpleDialogHandle( title, message, messageUrl, messageUrlStart, messageUrlEnd, positiveButtonText, negativeButtonText, neutralButtonText, callback, callbackThreadRunner) ); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not create DialogHandle for simple dialog: " + e); return null; } } /** * Returns the reply to a simple dialog to the callback of matching dialogId. * @param dialogId id of the replying dialog. * @param reply reply of the dialog. */ public void replyToSimpleDialog(int dialogId, @WifiManager.DialogReply int reply) { if (mVerboseLoggingEnabled) { Log.i(TAG, "Response received for simple dialog. id=" + dialogId + " reply=" + reply); } DialogHandleInternal internalHandle = mActiveDialogHandles.get(dialogId); if (internalHandle == null) { if (mVerboseLoggingEnabled) { Log.w(TAG, "No matching dialog handle for simple dialog id=" + dialogId); } return; } if (!(internalHandle instanceof SimpleDialogHandle)) { if (mVerboseLoggingEnabled) { Log.w(TAG, "Dialog handle with id " + dialogId + " is not for a simple dialog."); } return; } switch (reply) { case WifiManager.DIALOG_REPLY_POSITIVE: ((SimpleDialogHandle) internalHandle).notifyOnPositiveButtonClicked(); break; case WifiManager.DIALOG_REPLY_NEGATIVE: ((SimpleDialogHandle) internalHandle).notifyOnNegativeButtonClicked(); break; case WifiManager.DIALOG_REPLY_NEUTRAL: ((SimpleDialogHandle) internalHandle).notifyOnNeutralButtonClicked(); break; case WifiManager.DIALOG_REPLY_CANCELLED: ((SimpleDialogHandle) internalHandle).notifyOnCancelled(); break; default: if (mVerboseLoggingEnabled) { Log.w(TAG, "Received invalid reply=" + reply); } } } private class P2pInvitationReceivedDialogHandle extends DialogHandleInternal { private @NonNull P2pInvitationReceivedDialogCallback mCallback; private @NonNull WifiThreadRunner mCallbackThreadRunner; P2pInvitationReceivedDialogHandle( final @NonNull String deviceName, final boolean isPinRequested, @Nullable String displayPin, int displayId, @NonNull P2pInvitationReceivedDialogCallback callback, @NonNull WifiThreadRunner callbackThreadRunner) throws IllegalArgumentException { super(getBaseLaunchIntent(WifiManager.DIALOG_TYPE_P2P_INVITATION_RECEIVED) .putExtra(WifiManager.EXTRA_P2P_DEVICE_NAME, deviceName) .putExtra(WifiManager.EXTRA_P2P_PIN_REQUESTED, isPinRequested) .putExtra(WifiManager.EXTRA_P2P_DISPLAY_PIN, displayPin), displayId); if (deviceName == null) { throw new IllegalArgumentException("Device name cannot be null!"); } if (callback == null) { throw new IllegalArgumentException("Callback cannot be null!"); } if (callbackThreadRunner == null) { throw new IllegalArgumentException("Callback thread runner cannot be null!"); } mCallback = callback; mCallbackThreadRunner = callbackThreadRunner; } void notifyOnAccepted(@Nullable String optionalPin) { mCallbackThreadRunner.post(() -> mCallback.onAccepted(optionalPin)); unregisterDialog(); } void notifyOnDeclined() { mCallbackThreadRunner.post(() -> mCallback.onDeclined()); unregisterDialog(); } @Override void onTimeout() { dismissDialog(); notifyOnDeclined(); } } /** * Callback for receiving P2P Invitation Received dialog responses. */ public interface P2pInvitationReceivedDialogCallback { /** * Invitation was accepted. * * @param optionalPin Optional PIN if a PIN was requested, or {@code null} otherwise. */ void onAccepted(@Nullable String optionalPin); /** * Invitation was declined or cancelled (back button or home button or timeout). */ void onDeclined(); } /** * Creates a P2P Invitation Received dialog. * * @param deviceName Name of the device sending the invitation. * @param isPinRequested True if a PIN was requested and a PIN input UI should be shown. * @param displayPin Display PIN, or {@code null} if no PIN should be displayed * @param displayId The ID of the Display on which to place the dialog * (Display.DEFAULT_DISPLAY * refers to the default display) * @param callback Callback to receive the dialog response. * @param callbackThreadRunner WifiThreadRunner to run the callback on. * @return DialogHandle Handle for the dialog, or {@code null} if no dialog could * be created. */ @AnyThread public DialogHandle createP2pInvitationReceivedDialog( @NonNull String deviceName, boolean isPinRequested, @Nullable String displayPin, int displayId, @NonNull P2pInvitationReceivedDialogCallback callback, @NonNull WifiThreadRunner callbackThreadRunner) { try { return new DialogHandle( new P2pInvitationReceivedDialogHandle( deviceName, isPinRequested, displayPin, displayId, callback, callbackThreadRunner) ); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not create DialogHandle for P2P Invitation Received dialog: " + e); return null; } } /** * Returns the reply to a P2P Invitation Received dialog to the callback of matching dialogId. * Note: Must be invoked only from the main Wi-Fi thread. * * @param dialogId id of the replying dialog. * @param accepted Whether the invitation was accepted. * @param optionalPin PIN of the reply, or {@code null} if none was supplied. */ public void replyToP2pInvitationReceivedDialog( int dialogId, boolean accepted, @Nullable String optionalPin) { if (mVerboseLoggingEnabled) { Log.i(TAG, "Response received for P2P Invitation Received dialog." + " id=" + dialogId + " accepted=" + accepted + " pin=" + optionalPin); } DialogHandleInternal internalHandle = mActiveDialogHandles.get(dialogId); if (internalHandle == null) { if (mVerboseLoggingEnabled) { Log.w(TAG, "No matching dialog handle for P2P Invitation Received dialog" + " id=" + dialogId); } return; } if (!(internalHandle instanceof P2pInvitationReceivedDialogHandle)) { if (mVerboseLoggingEnabled) { Log.w(TAG, "Dialog handle with id " + dialogId + " is not for a P2P Invitation Received dialog."); } return; } if (accepted) { ((P2pInvitationReceivedDialogHandle) internalHandle).notifyOnAccepted(optionalPin); } else { ((P2pInvitationReceivedDialogHandle) internalHandle).notifyOnDeclined(); } } private class P2pInvitationSentDialogHandle extends DialogHandleInternal { P2pInvitationSentDialogHandle( final @NonNull String deviceName, final @NonNull String displayPin, int displayId) throws IllegalArgumentException { super(getBaseLaunchIntent(WifiManager.DIALOG_TYPE_P2P_INVITATION_SENT) .putExtra(WifiManager.EXTRA_P2P_DEVICE_NAME, deviceName) .putExtra(WifiManager.EXTRA_P2P_DISPLAY_PIN, displayPin), displayId); if (deviceName == null) { throw new IllegalArgumentException("Device name cannot be null!"); } if (displayPin == null) { throw new IllegalArgumentException("Display PIN cannot be null!"); } } } /** * Creates a P2P Invitation Sent dialog. * * @param deviceName Name of the device the invitation was sent to. * @param displayPin display PIN * @param displayId display ID * @return DialogHandle Handle for the dialog, or {@code null} if no dialog could * be created. */ @AnyThread public DialogHandle createP2pInvitationSentDialog( @NonNull String deviceName, @Nullable String displayPin, int displayId) { try { return new DialogHandle(new P2pInvitationSentDialogHandle(deviceName, displayPin, displayId)); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not create DialogHandle for P2P Invitation Sent dialog: " + e); return null; } } }