1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.networkstack; 18 19 import static android.app.NotificationManager.IMPORTANCE_NONE; 20 21 import android.app.Notification; 22 import android.app.NotificationChannel; 23 import android.app.NotificationManager; 24 import android.app.PendingIntent; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.content.pm.PackageManager; 28 import android.content.res.Resources; 29 import android.net.ConnectivityManager; 30 import android.net.LinkProperties; 31 import android.net.Network; 32 import android.net.NetworkCapabilities; 33 import android.net.NetworkRequest; 34 import android.os.Handler; 35 import android.os.Looper; 36 import android.os.UserHandle; 37 import android.provider.Settings; 38 import android.text.TextUtils; 39 40 import androidx.annotation.NonNull; 41 import androidx.annotation.Nullable; 42 import androidx.annotation.StringRes; 43 import androidx.annotation.VisibleForTesting; 44 45 import com.android.networkstack.apishim.NetworkInformationShimImpl; 46 import com.android.networkstack.apishim.common.CaptivePortalDataShim; 47 import com.android.networkstack.apishim.common.NetworkInformationShim; 48 49 import java.util.Hashtable; 50 import java.util.function.Consumer; 51 52 /** 53 * Displays notification related to connected networks. 54 */ 55 public class NetworkStackNotifier { 56 private final Context mContext; 57 private final Handler mHandler; 58 private final NotificationManager mNotificationManager; 59 private final Dependencies mDependencies; 60 61 @NonNull 62 private final Hashtable<Network, TrackedNetworkStatus> mNetworkStatus = new Hashtable<>(); 63 @Nullable 64 private Network mDefaultNetwork; 65 @NonNull 66 private final NetworkInformationShim mInfoShim = NetworkInformationShimImpl.newInstance(); 67 68 /** 69 * The TrackedNetworkStatus object is a data class that keeps track of the relevant state of the 70 * various networks on the device. For efficiency the members are mutable, which means any 71 * instance of this object should only ever be accessed on the looper thread passed in the 72 * constructor. Any access (read or write) from any other thread would be incorrect. 73 */ 74 private static class TrackedNetworkStatus { 75 private boolean mValidatedNotificationPending; 76 private int mShownNotification = NOTE_NONE; 77 private LinkProperties mLinkProperties; 78 private NetworkCapabilities mNetworkCapabilities; 79 isValidated()80 private boolean isValidated() { 81 if (mNetworkCapabilities == null) return false; 82 return mNetworkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); 83 } 84 } 85 86 @VisibleForTesting 87 protected static final String CHANNEL_CONNECTED = "connected_note_loud"; 88 @VisibleForTesting 89 protected static final String CHANNEL_VENUE_INFO = "connected_note"; 90 91 private static final int NOTE_NONE = 0; 92 private static final int NOTE_CONNECTED = 1; 93 private static final int NOTE_VENUE_INFO = 2; 94 95 private static final int NOTE_ID_NETWORK_INFO = 1; 96 97 @VisibleForTesting 98 protected static final long CONNECTED_NOTIFICATION_TIMEOUT_MS = 20_000L; 99 100 protected static class Dependencies { getActivityPendingIntent(Context context, Intent intent, int flags)101 public PendingIntent getActivityPendingIntent(Context context, Intent intent, int flags) { 102 return PendingIntent.getActivity(context, 0 /* requestCode */, intent, flags); 103 } 104 } 105 NetworkStackNotifier(@onNull Context context, @NonNull Looper looper)106 public NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper) { 107 this(context, looper, new Dependencies()); 108 } 109 NetworkStackNotifier(@onNull Context context, @NonNull Looper looper, @NonNull Dependencies dependencies)110 protected NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper, 111 @NonNull Dependencies dependencies) { 112 mContext = context; 113 mHandler = new Handler(looper); 114 mDependencies = dependencies; 115 mNotificationManager = getContextAsUser(mContext, UserHandle.ALL) 116 .getSystemService(NotificationManager.class); 117 final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class); 118 cm.registerDefaultNetworkCallback(new DefaultNetworkCallback(), mHandler); 119 cm.registerNetworkCallback( 120 new NetworkRequest.Builder() 121 .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(), 122 new AllNetworksCallback(), 123 mHandler); 124 125 createNotificationChannel(CHANNEL_CONNECTED, 126 R.string.notification_channel_name_connected, 127 R.string.notification_channel_description_connected, 128 NotificationManager.IMPORTANCE_HIGH); 129 createNotificationChannel(CHANNEL_VENUE_INFO, 130 R.string.notification_channel_name_network_venue_info, 131 R.string.notification_channel_description_network_venue_info, 132 NotificationManager.IMPORTANCE_DEFAULT); 133 } 134 135 @VisibleForTesting getHandler()136 protected Handler getHandler() { 137 return mHandler; 138 } 139 createNotificationChannel(@onNull String id, @StringRes int title, @StringRes int description, int importance)140 private void createNotificationChannel(@NonNull String id, @StringRes int title, 141 @StringRes int description, int importance) { 142 final Resources resources = mContext.getResources(); 143 NotificationChannel channel = new NotificationChannel(id, 144 resources.getString(title), 145 importance); 146 channel.setDescription(resources.getString(description)); 147 getNotificationManagerForChannels().createNotificationChannel(channel); 148 } 149 150 /** 151 * Get the NotificationManager to use to query channels, as opposed to posting notifications. 152 * 153 * Although notifications are posted as USER_ALL, notification channels are always created 154 * based on the UID calling NotificationManager, regardless of the context UserHandle. 155 * When querying notification channels, using a USER_ALL context would return no channel: the 156 * default context (as UserHandle 0 for NetworkStack) must be used. 157 */ getNotificationManagerForChannels()158 private NotificationManager getNotificationManagerForChannels() { 159 return mContext.getSystemService(NotificationManager.class); 160 } 161 162 /** 163 * Notify the NetworkStackNotifier that the captive portal app was opened to show a login UI to 164 * the user, but the network has not validated yet. The notifier uses this information to show 165 * proper notifications once the network validates. 166 */ notifyCaptivePortalValidationPending(@onNull Network network)167 public void notifyCaptivePortalValidationPending(@NonNull Network network) { 168 mHandler.post(() -> setCaptivePortalValidationPending(network)); 169 } 170 setCaptivePortalValidationPending(@onNull Network network)171 private void setCaptivePortalValidationPending(@NonNull Network network) { 172 updateNetworkStatus(network, status -> { 173 status.mValidatedNotificationPending = true; 174 status.mShownNotification = NOTE_NONE; 175 }); 176 } 177 178 @Nullable getCaptivePortalData(@onNull TrackedNetworkStatus status)179 private CaptivePortalDataShim getCaptivePortalData(@NonNull TrackedNetworkStatus status) { 180 return mInfoShim.getCaptivePortalData(status.mLinkProperties); 181 } 182 getSsid(@onNull TrackedNetworkStatus status)183 private String getSsid(@NonNull TrackedNetworkStatus status) { 184 return mInfoShim.getSsid(status.mNetworkCapabilities); 185 } 186 updateNetworkStatus(@onNull Network network, @NonNull Consumer<TrackedNetworkStatus> mutator)187 private void updateNetworkStatus(@NonNull Network network, 188 @NonNull Consumer<TrackedNetworkStatus> mutator) { 189 final TrackedNetworkStatus status = 190 mNetworkStatus.computeIfAbsent(network, n -> new TrackedNetworkStatus()); 191 mutator.accept(status); 192 } 193 updateNotifications(@onNull Network network)194 private void updateNotifications(@NonNull Network network) { 195 final TrackedNetworkStatus networkStatus = mNetworkStatus.get(network); 196 // The required network attributes callbacks were not fired yet for this network 197 if (networkStatus == null) return; 198 // Don't show the notification when SSID is unknown to prevent sending something vague to 199 // the user. 200 final boolean hasSsid = !TextUtils.isEmpty(getSsid(networkStatus)); 201 final CaptivePortalDataShim capportData = getCaptivePortalData(networkStatus); 202 final boolean showVenueInfo = capportData != null && capportData.getVenueInfoUrl() != null 203 // Only show venue info on validated networks, to prevent misuse of the notification 204 // as an alternate login flow that uses the default browser (which would be broken 205 // if the device has mobile data). 206 && networkStatus.isValidated() 207 && isVenueInfoNotificationEnabled() 208 // Most browsers do not yet support opening a page on a non-default network, so the 209 // venue info link should not be shown if the network is not the default one. 210 && network.equals(mDefaultNetwork) 211 && hasSsid; 212 final boolean showValidated = 213 networkStatus.mValidatedNotificationPending && networkStatus.isValidated() 214 && hasSsid; 215 final String notificationTag = getNotificationTag(network); 216 217 final Resources res = mContext.getResources(); 218 final Notification.Builder builder; 219 if (showVenueInfo) { 220 // Do not re-show the venue info notification even if the previous one had a different 221 // URL, to avoid potential abuse where APs could spam the notification with different 222 // URLs. 223 if (networkStatus.mShownNotification == NOTE_VENUE_INFO) return; 224 225 final Intent infoIntent = new Intent(Intent.ACTION_VIEW) 226 .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 227 .setData(capportData.getVenueInfoUrl()) 228 .putExtra(ConnectivityManager.EXTRA_NETWORK, network) 229 // Use the network handle as identifier, as there should be only one ACTION_VIEW 230 // pending intent per network. 231 .setIdentifier(Long.toString(network.getNetworkHandle())); 232 233 // If the validated notification should be shown, use the high priority "connected" 234 // channel even if the notification contains venue info: the "venue info" notification 235 // then doubles as a "connected" notification. 236 final String channel = showValidated ? CHANNEL_CONNECTED : CHANNEL_VENUE_INFO; 237 238 // If the venue friendly name is available (in Passpoint use-case), display it. 239 // Otherwise, display the SSID. 240 final CharSequence friendlyName = capportData.getVenueFriendlyName(); 241 final CharSequence venueDisplayName = TextUtils.isEmpty(friendlyName) 242 ? getSsid(networkStatus) : friendlyName; 243 244 builder = getNotificationBuilder(channel, networkStatus, res, venueDisplayName) 245 .setContentText(res.getString(R.string.tap_for_info)) 246 .setContentIntent(mDependencies.getActivityPendingIntent( 247 getContextAsUser(mContext, UserHandle.CURRENT), 248 infoIntent, PendingIntent.FLAG_IMMUTABLE)); 249 250 networkStatus.mShownNotification = NOTE_VENUE_INFO; 251 } else if (showValidated) { 252 if (networkStatus.mShownNotification == NOTE_CONNECTED) return; 253 254 builder = getNotificationBuilder(CHANNEL_CONNECTED, networkStatus, res, 255 getSsid(networkStatus)) 256 .setTimeoutAfter(CONNECTED_NOTIFICATION_TIMEOUT_MS) 257 .setContentText(res.getString(R.string.connected)) 258 .setContentIntent(mDependencies.getActivityPendingIntent( 259 getContextAsUser(mContext, UserHandle.CURRENT), 260 new Intent(Settings.ACTION_WIFI_SETTINGS), 261 PendingIntent.FLAG_IMMUTABLE)); 262 263 networkStatus.mShownNotification = NOTE_CONNECTED; 264 } else { 265 if (networkStatus.mShownNotification != NOTE_NONE 266 // Don't dismiss the connected notification: it's generated as one-off and will 267 // be dismissed after a timeout or if the network disconnects. 268 && networkStatus.mShownNotification != NOTE_CONNECTED) { 269 dismissNotification(notificationTag, networkStatus); 270 } 271 return; 272 } 273 274 if (showValidated) { 275 networkStatus.mValidatedNotificationPending = false; 276 } 277 mNotificationManager.notify(notificationTag, NOTE_ID_NETWORK_INFO, builder.build()); 278 } 279 dismissNotification(@onNull String tag, @NonNull TrackedNetworkStatus status)280 private void dismissNotification(@NonNull String tag, @NonNull TrackedNetworkStatus status) { 281 mNotificationManager.cancel(tag, NOTE_ID_NETWORK_INFO); 282 status.mShownNotification = NOTE_NONE; 283 } 284 getNotificationBuilder(@onNull String channelId, @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res, @NonNull CharSequence networkIdentifier)285 private Notification.Builder getNotificationBuilder(@NonNull String channelId, 286 @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res, 287 @NonNull CharSequence networkIdentifier) { 288 return new Notification.Builder(mContext, channelId) 289 .setContentTitle(networkIdentifier) 290 .setSmallIcon(R.drawable.icon_wifi); 291 } 292 293 /** 294 * Replacement for {@link Context#createContextAsUser(UserHandle, int)}, which is not available 295 * in API 29. 296 */ getContextAsUser(Context baseContext, UserHandle user)297 private static Context getContextAsUser(Context baseContext, UserHandle user) { 298 try { 299 return baseContext.createPackageContextAsUser( 300 baseContext.getPackageName(), 0 /* flags */, user); 301 } catch (PackageManager.NameNotFoundException e) { 302 throw new IllegalStateException("NetworkStack own package not found", e); 303 } 304 } 305 isVenueInfoNotificationEnabled()306 private boolean isVenueInfoNotificationEnabled() { 307 final NotificationChannel channel = getNotificationManagerForChannels() 308 .getNotificationChannel(CHANNEL_VENUE_INFO); 309 if (channel == null) return false; 310 311 return channel.getImportance() != IMPORTANCE_NONE; 312 } 313 getNotificationTag(@onNull Network network)314 private static String getNotificationTag(@NonNull Network network) { 315 return Long.toString(network.getNetworkHandle()); 316 } 317 318 private class DefaultNetworkCallback extends ConnectivityManager.NetworkCallback { 319 @Override onAvailable(Network network)320 public void onAvailable(Network network) { 321 updateDefaultNetwork(network); 322 } 323 324 @Override onLost(Network network)325 public void onLost(Network network) { 326 updateDefaultNetwork(null); 327 } 328 updateDefaultNetwork(@ullable Network newNetwork)329 private void updateDefaultNetwork(@Nullable Network newNetwork) { 330 final Network oldDefault = mDefaultNetwork; 331 mDefaultNetwork = newNetwork; 332 if (oldDefault != null) updateNotifications(oldDefault); 333 if (newNetwork != null) updateNotifications(newNetwork); 334 } 335 } 336 337 private class AllNetworksCallback extends ConnectivityManager.NetworkCallback { 338 @Override onLinkPropertiesChanged(Network network, LinkProperties linkProperties)339 public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) { 340 updateNetworkStatus(network, status -> status.mLinkProperties = linkProperties); 341 updateNotifications(network); 342 } 343 344 @Override onCapabilitiesChanged(@onNull Network network, @NonNull NetworkCapabilities networkCapabilities)345 public void onCapabilitiesChanged(@NonNull Network network, 346 @NonNull NetworkCapabilities networkCapabilities) { 347 updateNetworkStatus(network, s -> s.mNetworkCapabilities = networkCapabilities); 348 updateNotifications(network); 349 } 350 351 @Override onLost(Network network)352 public void onLost(Network network) { 353 final TrackedNetworkStatus status = mNetworkStatus.remove(network); 354 if (status == null) return; 355 dismissNotification(getNotificationTag(network), status); 356 } 357 } 358 } 359