blob: acf3c95a1a13a05dba5c62135c923bb8bc4abeca [file] [log] [blame]
/*
* Copyright (C) 2020 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.networkstack;
import static android.app.NotificationManager.IMPORTANCE_NONE;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import android.os.Handler;
import android.os.Looper;
import android.os.UserHandle;
import android.provider.Settings;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import com.android.networkstack.apishim.NetworkInformationShimImpl;
import com.android.networkstack.apishim.common.CaptivePortalDataShim;
import com.android.networkstack.apishim.common.NetworkInformationShim;
import java.util.Hashtable;
import java.util.function.Consumer;
/**
* Displays notification related to connected networks.
*/
public class NetworkStackNotifier {
private final Context mContext;
private final Handler mHandler;
private final NotificationManager mNotificationManager;
private final Dependencies mDependencies;
@NonNull
private final Hashtable<Network, TrackedNetworkStatus> mNetworkStatus = new Hashtable<>();
@Nullable
private Network mDefaultNetwork;
@NonNull
private final NetworkInformationShim mInfoShim = NetworkInformationShimImpl.newInstance();
/**
* The TrackedNetworkStatus object is a data class that keeps track of the relevant state of the
* various networks on the device. For efficiency the members are mutable, which means any
* instance of this object should only ever be accessed on the looper thread passed in the
* constructor. Any access (read or write) from any other thread would be incorrect.
*/
private static class TrackedNetworkStatus {
private boolean mValidatedNotificationPending;
private int mShownNotification = NOTE_NONE;
private LinkProperties mLinkProperties;
private NetworkCapabilities mNetworkCapabilities;
private boolean isValidated() {
if (mNetworkCapabilities == null) return false;
return mNetworkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
}
}
@VisibleForTesting
protected static final String CHANNEL_CONNECTED = "connected_note_loud";
@VisibleForTesting
protected static final String CHANNEL_VENUE_INFO = "connected_note";
private static final int NOTE_NONE = 0;
private static final int NOTE_CONNECTED = 1;
private static final int NOTE_VENUE_INFO = 2;
private static final int NOTE_ID_NETWORK_INFO = 1;
@VisibleForTesting
protected static final long CONNECTED_NOTIFICATION_TIMEOUT_MS = 20_000L;
protected static class Dependencies {
public PendingIntent getActivityPendingIntent(Context context, Intent intent, int flags) {
return PendingIntent.getActivity(context, 0 /* requestCode */, intent, flags);
}
}
public NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper) {
this(context, looper, new Dependencies());
}
protected NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper,
@NonNull Dependencies dependencies) {
mContext = context;
mHandler = new Handler(looper);
mDependencies = dependencies;
mNotificationManager = getContextAsUser(mContext, UserHandle.ALL)
.getSystemService(NotificationManager.class);
final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
cm.registerDefaultNetworkCallback(new DefaultNetworkCallback(), mHandler);
cm.registerNetworkCallback(
new NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(),
new AllNetworksCallback(),
mHandler);
createNotificationChannel(CHANNEL_CONNECTED,
R.string.notification_channel_name_connected,
R.string.notification_channel_description_connected,
NotificationManager.IMPORTANCE_HIGH);
createNotificationChannel(CHANNEL_VENUE_INFO,
R.string.notification_channel_name_network_venue_info,
R.string.notification_channel_description_network_venue_info,
NotificationManager.IMPORTANCE_DEFAULT);
}
@VisibleForTesting
protected Handler getHandler() {
return mHandler;
}
private void createNotificationChannel(@NonNull String id, @StringRes int title,
@StringRes int description, int importance) {
final Resources resources = mContext.getResources();
NotificationChannel channel = new NotificationChannel(id,
resources.getString(title),
importance);
channel.setDescription(resources.getString(description));
getNotificationManagerForChannels().createNotificationChannel(channel);
}
/**
* Get the NotificationManager to use to query channels, as opposed to posting notifications.
*
* Although notifications are posted as USER_ALL, notification channels are always created
* based on the UID calling NotificationManager, regardless of the context UserHandle.
* When querying notification channels, using a USER_ALL context would return no channel: the
* default context (as UserHandle 0 for NetworkStack) must be used.
*/
private NotificationManager getNotificationManagerForChannels() {
return mContext.getSystemService(NotificationManager.class);
}
/**
* Notify the NetworkStackNotifier that the captive portal app was opened to show a login UI to
* the user, but the network has not validated yet. The notifier uses this information to show
* proper notifications once the network validates.
*/
public void notifyCaptivePortalValidationPending(@NonNull Network network) {
mHandler.post(() -> setCaptivePortalValidationPending(network));
}
private void setCaptivePortalValidationPending(@NonNull Network network) {
updateNetworkStatus(network, status -> {
status.mValidatedNotificationPending = true;
status.mShownNotification = NOTE_NONE;
});
}
@Nullable
private CaptivePortalDataShim getCaptivePortalData(@NonNull TrackedNetworkStatus status) {
return mInfoShim.getCaptivePortalData(status.mLinkProperties);
}
private String getSsid(@NonNull TrackedNetworkStatus status) {
return mInfoShim.getSsid(status.mNetworkCapabilities);
}
private void updateNetworkStatus(@NonNull Network network,
@NonNull Consumer<TrackedNetworkStatus> mutator) {
final TrackedNetworkStatus status =
mNetworkStatus.computeIfAbsent(network, n -> new TrackedNetworkStatus());
mutator.accept(status);
}
private void updateNotifications(@NonNull Network network) {
final TrackedNetworkStatus networkStatus = mNetworkStatus.get(network);
// The required network attributes callbacks were not fired yet for this network
if (networkStatus == null) return;
// Don't show the notification when SSID is unknown to prevent sending something vague to
// the user.
final boolean hasSsid = !TextUtils.isEmpty(getSsid(networkStatus));
final CaptivePortalDataShim capportData = getCaptivePortalData(networkStatus);
final boolean showVenueInfo = capportData != null && capportData.getVenueInfoUrl() != null
// Only show venue info on validated networks, to prevent misuse of the notification
// as an alternate login flow that uses the default browser (which would be broken
// if the device has mobile data).
&& networkStatus.isValidated()
&& isVenueInfoNotificationEnabled()
// Most browsers do not yet support opening a page on a non-default network, so the
// venue info link should not be shown if the network is not the default one.
&& network.equals(mDefaultNetwork)
&& hasSsid;
final boolean showValidated =
networkStatus.mValidatedNotificationPending && networkStatus.isValidated()
&& hasSsid;
final String notificationTag = getNotificationTag(network);
final Resources res = mContext.getResources();
final Notification.Builder builder;
if (showVenueInfo) {
// Do not re-show the venue info notification even if the previous one had a different
// URL, to avoid potential abuse where APs could spam the notification with different
// URLs.
if (networkStatus.mShownNotification == NOTE_VENUE_INFO) return;
final Intent infoIntent = new Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(capportData.getVenueInfoUrl())
.putExtra(ConnectivityManager.EXTRA_NETWORK, network)
// Use the network handle as identifier, as there should be only one ACTION_VIEW
// pending intent per network.
.setIdentifier(Long.toString(network.getNetworkHandle()));
// If the validated notification should be shown, use the high priority "connected"
// channel even if the notification contains venue info: the "venue info" notification
// then doubles as a "connected" notification.
final String channel = showValidated ? CHANNEL_CONNECTED : CHANNEL_VENUE_INFO;
// If the venue friendly name is available (in Passpoint use-case), display it.
// Otherwise, display the SSID.
final CharSequence friendlyName = capportData.getVenueFriendlyName();
final CharSequence venueDisplayName = TextUtils.isEmpty(friendlyName)
? getSsid(networkStatus) : friendlyName;
builder = getNotificationBuilder(channel, networkStatus, res, venueDisplayName)
.setContentText(res.getString(R.string.tap_for_info))
.setContentIntent(mDependencies.getActivityPendingIntent(
getContextAsUser(mContext, UserHandle.CURRENT),
infoIntent, PendingIntent.FLAG_IMMUTABLE));
networkStatus.mShownNotification = NOTE_VENUE_INFO;
} else if (showValidated) {
if (networkStatus.mShownNotification == NOTE_CONNECTED) return;
builder = getNotificationBuilder(CHANNEL_CONNECTED, networkStatus, res,
getSsid(networkStatus))
.setTimeoutAfter(CONNECTED_NOTIFICATION_TIMEOUT_MS)
.setContentText(res.getString(R.string.connected))
.setContentIntent(mDependencies.getActivityPendingIntent(
getContextAsUser(mContext, UserHandle.CURRENT),
new Intent(Settings.ACTION_WIFI_SETTINGS),
PendingIntent.FLAG_IMMUTABLE));
networkStatus.mShownNotification = NOTE_CONNECTED;
} else {
if (networkStatus.mShownNotification != NOTE_NONE
// Don't dismiss the connected notification: it's generated as one-off and will
// be dismissed after a timeout or if the network disconnects.
&& networkStatus.mShownNotification != NOTE_CONNECTED) {
dismissNotification(notificationTag, networkStatus);
}
return;
}
if (showValidated) {
networkStatus.mValidatedNotificationPending = false;
}
mNotificationManager.notify(notificationTag, NOTE_ID_NETWORK_INFO, builder.build());
}
private void dismissNotification(@NonNull String tag, @NonNull TrackedNetworkStatus status) {
mNotificationManager.cancel(tag, NOTE_ID_NETWORK_INFO);
status.mShownNotification = NOTE_NONE;
}
private Notification.Builder getNotificationBuilder(@NonNull String channelId,
@NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res,
@NonNull CharSequence networkIdentifier) {
return new Notification.Builder(mContext, channelId)
.setContentTitle(networkIdentifier)
.setSmallIcon(R.drawable.icon_wifi);
}
/**
* Replacement for {@link Context#createContextAsUser(UserHandle, int)}, which is not available
* in API 29.
*/
private static Context getContextAsUser(Context baseContext, UserHandle user) {
try {
return baseContext.createPackageContextAsUser(
baseContext.getPackageName(), 0 /* flags */, user);
} catch (PackageManager.NameNotFoundException e) {
throw new IllegalStateException("NetworkStack own package not found", e);
}
}
private boolean isVenueInfoNotificationEnabled() {
final NotificationChannel channel = getNotificationManagerForChannels()
.getNotificationChannel(CHANNEL_VENUE_INFO);
if (channel == null) return false;
return channel.getImportance() != IMPORTANCE_NONE;
}
private static String getNotificationTag(@NonNull Network network) {
return Long.toString(network.getNetworkHandle());
}
private class DefaultNetworkCallback extends ConnectivityManager.NetworkCallback {
@Override
public void onAvailable(Network network) {
updateDefaultNetwork(network);
}
@Override
public void onLost(Network network) {
updateDefaultNetwork(null);
}
private void updateDefaultNetwork(@Nullable Network newNetwork) {
final Network oldDefault = mDefaultNetwork;
mDefaultNetwork = newNetwork;
if (oldDefault != null) updateNotifications(oldDefault);
if (newNetwork != null) updateNotifications(newNetwork);
}
}
private class AllNetworksCallback extends ConnectivityManager.NetworkCallback {
@Override
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
updateNetworkStatus(network, status -> status.mLinkProperties = linkProperties);
updateNotifications(network);
}
@Override
public void onCapabilitiesChanged(@NonNull Network network,
@NonNull NetworkCapabilities networkCapabilities) {
updateNetworkStatus(network, s -> s.mNetworkCapabilities = networkCapabilities);
updateNotifications(network);
}
@Override
public void onLost(Network network) {
final TrackedNetworkStatus status = mNetworkStatus.remove(network);
if (status == null) return;
dismissNotification(getNotificationTag(network), status);
}
}
}