blob: 68329993728df25e942101228cc152d78a15c447 [file] [log] [blame]
/*
* 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<Integer> mActiveDialogIds = new ArraySet<>();
private final @NonNull SparseArray<DialogHandleInternal> 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;
}
}
}