/* * 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 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 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); } } }