1 /* 2 * Copyright (C) 2016 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.server.connectivity; 18 19 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET; 20 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR; 21 import static android.net.NetworkCapabilities.TRANSPORT_VPN; 22 import static android.net.NetworkCapabilities.TRANSPORT_WIFI; 23 24 import android.annotation.NonNull; 25 import android.app.ActivityOptions; 26 import android.app.Notification; 27 import android.app.NotificationManager; 28 import android.app.PendingIntent; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.content.res.Resources; 32 import android.graphics.drawable.Icon; 33 import android.net.NetworkSpecifier; 34 import android.net.TelephonyNetworkSpecifier; 35 import android.net.wifi.WifiInfo; 36 import android.os.Build; 37 import android.os.Bundle; 38 import android.os.UserHandle; 39 import android.telephony.SubscriptionManager; 40 import android.telephony.TelephonyManager; 41 import android.text.TextUtils; 42 import android.util.Log; 43 import android.util.SparseArray; 44 import android.util.SparseIntArray; 45 import android.widget.Toast; 46 47 import com.android.connectivity.resources.R; 48 import com.android.internal.annotations.VisibleForTesting; 49 import com.android.internal.messages.nano.SystemMessageProto.SystemMessage; 50 import com.android.modules.utils.build.SdkLevel; 51 52 public class NetworkNotificationManager { 53 54 55 public static enum NotificationType { 56 LOST_INTERNET(SystemMessage.NOTE_NETWORK_LOST_INTERNET), 57 NETWORK_SWITCH(SystemMessage.NOTE_NETWORK_SWITCH), 58 NO_INTERNET(SystemMessage.NOTE_NETWORK_NO_INTERNET), 59 PARTIAL_CONNECTIVITY(SystemMessage.NOTE_NETWORK_PARTIAL_CONNECTIVITY), 60 SIGN_IN(SystemMessage.NOTE_NETWORK_SIGN_IN), 61 PRIVATE_DNS_BROKEN(SystemMessage.NOTE_NETWORK_PRIVATE_DNS_BROKEN); 62 63 public final int eventId; 64 NotificationType(int eventId)65 NotificationType(int eventId) { 66 this.eventId = eventId; 67 Holder.sIdToTypeMap.put(eventId, this); 68 } 69 70 private static class Holder { 71 private static SparseArray<NotificationType> sIdToTypeMap = new SparseArray<>(); 72 } 73 getFromId(int id)74 public static NotificationType getFromId(int id) { 75 return Holder.sIdToTypeMap.get(id); 76 } 77 }; 78 79 private static final String TAG = NetworkNotificationManager.class.getSimpleName(); 80 private static final boolean DBG = true; 81 82 // Notification channels used by ConnectivityService mainline module, it should be aligned with 83 // SystemNotificationChannels so the channels are the same as the ones used as the system 84 // server. 85 public static final String NOTIFICATION_CHANNEL_NETWORK_STATUS = "NETWORK_STATUS"; 86 public static final String NOTIFICATION_CHANNEL_NETWORK_ALERTS = "NETWORK_ALERTS"; 87 88 // The context is for the current user (system server) 89 private final Context mContext; 90 private final ConnectivityResources mResources; 91 private final TelephonyManager mTelephonyManager; 92 // The notification manager is created from a context for User.ALL, so notifications 93 // will be sent to all users. 94 private final NotificationManager mNotificationManager; 95 // Tracks the types of notifications managed by this instance, from creation to cancellation. 96 private final SparseIntArray mNotificationTypeMap; 97 NetworkNotificationManager(@onNull final Context c, @NonNull final TelephonyManager t)98 public NetworkNotificationManager(@NonNull final Context c, @NonNull final TelephonyManager t) { 99 mContext = c; 100 mTelephonyManager = t; 101 mNotificationManager = 102 (NotificationManager) c.createContextAsUser(UserHandle.ALL, 0 /* flags */) 103 .getSystemService(Context.NOTIFICATION_SERVICE); 104 mNotificationTypeMap = new SparseIntArray(); 105 mResources = new ConnectivityResources(mContext); 106 } 107 108 @VisibleForTesting approximateTransportType(NetworkAgentInfo nai)109 protected static int approximateTransportType(NetworkAgentInfo nai) { 110 return nai.isVPN() ? TRANSPORT_VPN : getFirstTransportType(nai); 111 } 112 113 // TODO: deal more gracefully with multi-transport networks. getFirstTransportType(NetworkAgentInfo nai)114 private static int getFirstTransportType(NetworkAgentInfo nai) { 115 // TODO: The range is wrong, the safer and correct way is to change the range from 116 // MIN_TRANSPORT to MAX_TRANSPORT. 117 for (int i = 0; i < 64; i++) { 118 if (nai.networkCapabilities.hasTransport(i)) return i; 119 } 120 return -1; 121 } 122 getTransportName(final int transportType)123 private String getTransportName(final int transportType) { 124 String[] networkTypes = mResources.get().getStringArray(R.array.network_switch_type_name); 125 try { 126 return networkTypes[transportType]; 127 } catch (IndexOutOfBoundsException e) { 128 return mResources.get().getString(R.string.network_switch_type_name_unknown); 129 } 130 } 131 getIcon(int transportType)132 private static int getIcon(int transportType) { 133 return (transportType == TRANSPORT_WIFI) 134 ? R.drawable.stat_notify_wifi_in_range // TODO: Distinguish ! from ?. 135 : R.drawable.stat_notify_rssi_in_range; 136 } 137 138 /** 139 * Show or hide network provisioning notifications. 140 * 141 * We use notifications for two purposes: to notify that a network requires sign in 142 * (NotificationType.SIGN_IN), or to notify that a network does not have Internet access 143 * (NotificationType.NO_INTERNET). We display at most one notification per ID, so on a 144 * particular network we can display the notification type that was most recently requested. 145 * So for example if a captive portal fails to reply within a few seconds of connecting, we 146 * might first display NO_INTERNET, and then when the captive portal check completes, display 147 * SIGN_IN. 148 * 149 * @param id an identifier that uniquely identifies this notification. This must match 150 * between show and hide calls. We use the NetID value but for legacy callers 151 * we concatenate the range of types with the range of NetIDs. 152 * @param notifyType the type of the notification. 153 * @param nai the network with which the notification is associated. For a SIGN_IN, NO_INTERNET, 154 * or LOST_INTERNET notification, this is the network we're connecting to. For a 155 * NETWORK_SWITCH notification it's the network that we switched from. When this network 156 * disconnects the notification is removed. 157 * @param switchToNai for a NETWORK_SWITCH notification, the network we are switching to. Null 158 * in all other cases. Only used to determine the text of the notification. 159 */ showNotification(int id, NotificationType notifyType, NetworkAgentInfo nai, NetworkAgentInfo switchToNai, PendingIntent intent, boolean highPriority)160 public void showNotification(int id, NotificationType notifyType, NetworkAgentInfo nai, 161 NetworkAgentInfo switchToNai, PendingIntent intent, boolean highPriority) { 162 final String tag = tagFor(id); 163 final int eventId = notifyType.eventId; 164 final int transportType; 165 final CharSequence name; 166 if (nai != null) { 167 transportType = approximateTransportType(nai); 168 final String extraInfo = nai.networkInfo.getExtraInfo(); 169 if (nai.linkProperties != null && nai.linkProperties.getCaptivePortalData() != null 170 && !TextUtils.isEmpty(nai.linkProperties.getCaptivePortalData() 171 .getVenueFriendlyName())) { 172 name = nai.linkProperties.getCaptivePortalData().getVenueFriendlyName(); 173 } else if (!TextUtils.isEmpty(extraInfo)) { 174 name = extraInfo; 175 } else { 176 final String ssid = WifiInfo.sanitizeSsid(nai.networkCapabilities.getSsid()); 177 name = ssid == null ? "" : ssid; 178 } 179 // Only notify for Internet-capable networks. 180 if (!nai.networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)) return; 181 } else { 182 // Legacy notifications. 183 transportType = TRANSPORT_CELLULAR; 184 name = ""; 185 } 186 187 // Clear any previous notification with lower priority, otherwise return. http://b/63676954. 188 // A new SIGN_IN notification with a new intent should override any existing one. 189 final int previousEventId = mNotificationTypeMap.get(id); 190 final NotificationType previousNotifyType = NotificationType.getFromId(previousEventId); 191 if (priority(previousNotifyType) > priority(notifyType)) { 192 Log.d(TAG, String.format( 193 "ignoring notification %s for network %s with existing notification %s", 194 notifyType, id, previousNotifyType)); 195 return; 196 } 197 clearNotification(id); 198 199 if (DBG) { 200 Log.d(TAG, String.format( 201 "showNotification tag=%s event=%s transport=%s name=%s highPriority=%s", 202 tag, nameOf(eventId), getTransportName(transportType), name, highPriority)); 203 } 204 205 final Resources r = mResources.get(); 206 if (highPriority && maybeNotifyViaDialog(r, notifyType, intent)) { 207 Log.d(TAG, "Notified via dialog for event " + nameOf(eventId)); 208 return; 209 } 210 211 final CharSequence title; 212 final CharSequence details; 213 Icon icon = Icon.createWithResource( 214 mResources.getResourcesContext(), getIcon(transportType)); 215 final boolean showAsNoInternet = notifyType == NotificationType.PARTIAL_CONNECTIVITY 216 && r.getBoolean(R.bool.config_partialConnectivityNotifiedAsNoInternet); 217 if (showAsNoInternet) { 218 Log.d(TAG, "Showing partial connectivity as NO_INTERNET"); 219 } 220 if ((notifyType == NotificationType.NO_INTERNET || showAsNoInternet) 221 && transportType == TRANSPORT_WIFI) { 222 title = r.getString(R.string.wifi_no_internet, name); 223 details = r.getString(R.string.wifi_no_internet_detailed); 224 } else if (notifyType == NotificationType.PRIVATE_DNS_BROKEN) { 225 if (transportType == TRANSPORT_CELLULAR) { 226 title = r.getString(R.string.mobile_no_internet); 227 } else if (transportType == TRANSPORT_WIFI) { 228 title = r.getString(R.string.wifi_no_internet, name); 229 } else { 230 title = r.getString(R.string.other_networks_no_internet); 231 } 232 details = r.getString(R.string.private_dns_broken_detailed); 233 } else if (notifyType == NotificationType.PARTIAL_CONNECTIVITY 234 && transportType == TRANSPORT_WIFI) { 235 title = r.getString(R.string.network_partial_connectivity, name); 236 details = r.getString(R.string.network_partial_connectivity_detailed); 237 } else if (notifyType == NotificationType.LOST_INTERNET && 238 transportType == TRANSPORT_WIFI) { 239 title = r.getString(R.string.wifi_no_internet, name); 240 details = r.getString(R.string.wifi_no_internet_detailed); 241 } else if (notifyType == NotificationType.SIGN_IN) { 242 switch (transportType) { 243 case TRANSPORT_WIFI: 244 title = r.getString(R.string.wifi_available_sign_in, 0); 245 details = r.getString(R.string.network_available_sign_in_detailed, name); 246 break; 247 case TRANSPORT_CELLULAR: 248 title = r.getString(R.string.mobile_network_available_no_internet); 249 // TODO: Change this to pull from NetworkInfo once a printable 250 // name has been added to it 251 NetworkSpecifier specifier = nai.networkCapabilities.getNetworkSpecifier(); 252 int subId = SubscriptionManager.DEFAULT_SUBSCRIPTION_ID; 253 if (specifier instanceof TelephonyNetworkSpecifier) { 254 subId = ((TelephonyNetworkSpecifier) specifier).getSubscriptionId(); 255 } 256 257 final String operatorName = mTelephonyManager.createForSubscriptionId(subId) 258 .getNetworkOperatorName(); 259 if (TextUtils.isEmpty(operatorName)) { 260 details = r.getString(R.string 261 .mobile_network_available_no_internet_detailed_unknown_carrier); 262 } else { 263 details = r.getString( 264 R.string.mobile_network_available_no_internet_detailed, 265 operatorName); 266 } 267 break; 268 default: 269 title = r.getString(R.string.network_available_sign_in, 0); 270 details = r.getString(R.string.network_available_sign_in_detailed, name); 271 break; 272 } 273 } else if (notifyType == NotificationType.NETWORK_SWITCH) { 274 String fromTransport = getTransportName(transportType); 275 String toTransport = getTransportName(approximateTransportType(switchToNai)); 276 title = r.getString(R.string.network_switch_metered, toTransport); 277 details = r.getString(R.string.network_switch_metered_detail, toTransport, 278 fromTransport); 279 } else if (notifyType == NotificationType.NO_INTERNET 280 || notifyType == NotificationType.PARTIAL_CONNECTIVITY) { 281 // NO_INTERNET and PARTIAL_CONNECTIVITY notification for non-WiFi networks 282 // are sent, but they are not implemented yet. 283 return; 284 } else { 285 Log.wtf(TAG, "Unknown notification type " + notifyType + " on network transport " 286 + getTransportName(transportType)); 287 return; 288 } 289 // When replacing an existing notification for a given network, don't alert, just silently 290 // update the existing notification. Note that setOnlyAlertOnce() will only work for the 291 // same id, and the id used here is the NotificationType which is different in every type of 292 // notification. This is required because the notification metrics only track the ID but not 293 // the tag. 294 final boolean hasPreviousNotification = previousNotifyType != null; 295 final String channelId = (highPriority && !hasPreviousNotification) 296 ? NOTIFICATION_CHANNEL_NETWORK_ALERTS : NOTIFICATION_CHANNEL_NETWORK_STATUS; 297 Notification.Builder builder = new Notification.Builder(mContext, channelId) 298 .setWhen(System.currentTimeMillis()) 299 .setShowWhen(notifyType == NotificationType.NETWORK_SWITCH) 300 .setSmallIcon(icon) 301 .setAutoCancel(r.getBoolean(R.bool.config_autoCancelNetworkNotifications)) 302 .setTicker(title) 303 .setColor(mContext.getColor(android.R.color.system_notification_accent_color)) 304 .setContentTitle(title) 305 .setContentIntent(intent) 306 .setLocalOnly(true) 307 .setOnlyAlertOnce(true) 308 // TODO: consider having action buttons to disconnect on the sign-in notification 309 // especially if it is ongoing 310 .setOngoing(notifyType == NotificationType.SIGN_IN 311 && r.getBoolean(R.bool.config_ongoingSignInNotification)); 312 313 if (notifyType == NotificationType.NETWORK_SWITCH) { 314 builder.setStyle(new Notification.BigTextStyle().bigText(details)); 315 } else { 316 builder.setContentText(details); 317 } 318 319 if (notifyType == NotificationType.SIGN_IN) { 320 builder.extend(new Notification.TvExtender().setChannelId(channelId)); 321 } 322 323 Notification notification = builder.build(); 324 325 mNotificationTypeMap.put(id, eventId); 326 try { 327 mNotificationManager.notify(tag, eventId, notification); 328 } catch (NullPointerException npe) { 329 Log.d(TAG, "setNotificationVisible: visible notificationManager error", npe); 330 } 331 } 332 maybeNotifyViaDialog(Resources res, NotificationType notifyType, PendingIntent intent)333 private boolean maybeNotifyViaDialog(Resources res, NotificationType notifyType, 334 PendingIntent intent) { 335 if (notifyType != NotificationType.LOST_INTERNET 336 && notifyType != NotificationType.NO_INTERNET 337 && notifyType != NotificationType.PARTIAL_CONNECTIVITY) { 338 return false; 339 } 340 if (!res.getBoolean(R.bool.config_notifyNoInternetAsDialogWhenHighPriority)) { 341 return false; 342 } 343 344 try { 345 Bundle options = null; 346 347 if (SdkLevel.isAtLeastU() && intent.isActivity()) { 348 // Also check SDK_INT >= T separately, as the linter in some T-based branches does 349 // not recognize "isAtLeastU && something" as an SDK check for T+ APIs. 350 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 351 // Android U requires pending intent background start mode to be specified: 352 // See #background-activity-restrictions in 353 // https://developer.android.com/about/versions/14/behavior-changes-14 354 // But setPendingIntentBackgroundActivityStartMode is U+, and replaces 355 // setPendingIntentBackgroundActivityLaunchAllowed which is T+ but deprecated. 356 // Use setPendingIntentBackgroundActivityLaunchAllowed as the U+ version is not 357 // yet available in all branches. 358 final ActivityOptions activityOptions = ActivityOptions.makeBasic(); 359 activityOptions.setPendingIntentBackgroundActivityLaunchAllowed(true); 360 options = activityOptions.toBundle(); 361 } 362 } 363 364 intent.send(null, 0, null, null, null, null, options); 365 } catch (PendingIntent.CanceledException e) { 366 Log.e(TAG, "Error sending dialog PendingIntent", e); 367 } 368 return true; 369 } 370 371 /** 372 * Clear the notification with the given id, only if it matches the given type. 373 */ clearNotification(int id, NotificationType notifyType)374 public void clearNotification(int id, NotificationType notifyType) { 375 final int previousEventId = mNotificationTypeMap.get(id); 376 final NotificationType previousNotifyType = NotificationType.getFromId(previousEventId); 377 if (notifyType != previousNotifyType) { 378 return; 379 } 380 clearNotification(id); 381 } 382 clearNotification(int id)383 public void clearNotification(int id) { 384 if (mNotificationTypeMap.indexOfKey(id) < 0) { 385 return; 386 } 387 final String tag = tagFor(id); 388 final int eventId = mNotificationTypeMap.get(id); 389 if (DBG) { 390 Log.d(TAG, String.format("clearing notification tag=%s event=%s", tag, 391 nameOf(eventId))); 392 } 393 try { 394 mNotificationManager.cancel(tag, eventId); 395 } catch (NullPointerException npe) { 396 Log.d(TAG, String.format( 397 "failed to clear notification tag=%s event=%s", tag, nameOf(eventId)), npe); 398 } 399 mNotificationTypeMap.delete(id); 400 } 401 402 /** 403 * Legacy provisioning notifications coming directly from DcTracker. 404 */ setProvNotificationVisible(boolean visible, int id, String action)405 public void setProvNotificationVisible(boolean visible, int id, String action) { 406 if (visible) { 407 // For legacy purposes, action is sent as the action + the phone ID from DcTracker. 408 // Split the string here and send the phone ID as an extra instead. 409 String[] splitAction = action.split(":"); 410 Intent intent = new Intent(splitAction[0]); 411 try { 412 intent.putExtra("provision.phone.id", Integer.parseInt(splitAction[1])); 413 } catch (NumberFormatException ignored) { } 414 PendingIntent pendingIntent = PendingIntent.getBroadcast( 415 mContext, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE); 416 showNotification(id, NotificationType.SIGN_IN, null, null, pendingIntent, false); 417 } else { 418 clearNotification(id); 419 } 420 } 421 showToast(NetworkAgentInfo fromNai, NetworkAgentInfo toNai)422 public void showToast(NetworkAgentInfo fromNai, NetworkAgentInfo toNai) { 423 String fromTransport = getTransportName(approximateTransportType(fromNai)); 424 String toTransport = getTransportName(approximateTransportType(toNai)); 425 String text = mResources.get().getString( 426 R.string.network_switch_metered_toast, fromTransport, toTransport); 427 Toast.makeText(mContext, text, Toast.LENGTH_LONG).show(); 428 } 429 430 /** Get the logging tag for a notification ID */ 431 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) tagFor(int id)432 public static String tagFor(int id) { 433 return String.format("ConnectivityNotification:%d", id); 434 } 435 436 @VisibleForTesting nameOf(int eventId)437 static String nameOf(int eventId) { 438 NotificationType t = NotificationType.getFromId(eventId); 439 return (t != null) ? t.name() : "UNKNOWN"; 440 } 441 442 /** 443 * A notification with a higher number will take priority over a notification with a lower 444 * number. 445 */ 446 @VisibleForTesting priority(NotificationType t)447 public static int priority(NotificationType t) { 448 if (t == null) { 449 return 0; 450 } 451 switch (t) { 452 case SIGN_IN: 453 return 6; 454 case PARTIAL_CONNECTIVITY: 455 return 5; 456 case PRIVATE_DNS_BROKEN: 457 return 4; 458 case NO_INTERNET: 459 return 3; 460 case NETWORK_SWITCH: 461 return 2; 462 case LOST_INTERNET: 463 return 1; 464 default: 465 return 0; 466 } 467 } 468 } 469