• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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