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 
202         final CaptivePortalDataShim capportData = getCaptivePortalData(networkStatus);
203         final boolean showVenueInfo = capportData != null && capportData.getVenueInfoUrl() != null
204                 // Only show venue info on validated networks, to prevent misuse of the notification
205                 // as an alternate login flow that uses the default browser (which would be broken
206                 // if the device has mobile data).
207                 && networkStatus.isValidated()
208                 && isVenueInfoNotificationEnabled()
209                 // Most browsers do not yet support opening a page on a non-default network, so the
210                 // venue info link should not be shown if the network is not the default one.
211                 && network.equals(mDefaultNetwork)
212                 && hasSsid;
213         final boolean showValidated =
214                 networkStatus.mValidatedNotificationPending && networkStatus.isValidated()
215                 && hasSsid;
216         final String notificationTag = getNotificationTag(network);
217 
218         final Resources res = mContext.getResources();
219         final Notification.Builder builder;
220         if (showVenueInfo) {
221             // Do not re-show the venue info notification even if the previous one had a different
222             // URL, to avoid potential abuse where APs could spam the notification with different
223             // URLs.
224             if (networkStatus.mShownNotification == NOTE_VENUE_INFO) return;
225 
226             final Intent infoIntent = new Intent(Intent.ACTION_VIEW)
227                     .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
228                     .setData(capportData.getVenueInfoUrl())
229                     .putExtra(ConnectivityManager.EXTRA_NETWORK, network)
230                     // Use the network handle as identifier, as there should be only one ACTION_VIEW
231                     // pending intent per network.
232                     .setIdentifier(Long.toString(network.getNetworkHandle()));
233 
234             // If the validated notification should be shown, use the high priority "connected"
235             // channel even if the notification contains venue info: the "venue info" notification
236             // then doubles as a "connected" notification.
237             final String channel = showValidated ? CHANNEL_CONNECTED : CHANNEL_VENUE_INFO;
238             builder = getNotificationBuilder(channel, networkStatus, res, getSsid(networkStatus))
239                     .setContentText(res.getString(R.string.tap_for_info))
240                     .setContentIntent(mDependencies.getActivityPendingIntent(
241                             getContextAsUser(mContext, UserHandle.CURRENT),
242                             infoIntent, PendingIntent.FLAG_IMMUTABLE));
243 
244             networkStatus.mShownNotification = NOTE_VENUE_INFO;
245         } else if (showValidated) {
246             if (networkStatus.mShownNotification == NOTE_CONNECTED) return;
247 
248             builder = getNotificationBuilder(CHANNEL_CONNECTED, networkStatus, res,
249                     getSsid(networkStatus))
250                     .setTimeoutAfter(CONNECTED_NOTIFICATION_TIMEOUT_MS)
251                     .setContentText(res.getString(R.string.connected))
252                     .setContentIntent(mDependencies.getActivityPendingIntent(
253                             getContextAsUser(mContext, UserHandle.CURRENT),
254                             new Intent(Settings.ACTION_WIFI_SETTINGS),
255                             PendingIntent.FLAG_IMMUTABLE));
256 
257             networkStatus.mShownNotification = NOTE_CONNECTED;
258         } else {
259             if (networkStatus.mShownNotification != NOTE_NONE
260                     // Don't dismiss the connected notification: it's generated as one-off and will
261                     // be dismissed after a timeout or if the network disconnects.
262                     && networkStatus.mShownNotification != NOTE_CONNECTED) {
263                 dismissNotification(notificationTag, networkStatus);
264             }
265             return;
266         }
267 
268         if (showValidated) {
269             networkStatus.mValidatedNotificationPending = false;
270         }
271         mNotificationManager.notify(notificationTag, NOTE_ID_NETWORK_INFO, builder.build());
272     }
273 
dismissNotification(@onNull String tag, @NonNull TrackedNetworkStatus status)274     private void dismissNotification(@NonNull String tag, @NonNull TrackedNetworkStatus status) {
275         mNotificationManager.cancel(tag, NOTE_ID_NETWORK_INFO);
276         status.mShownNotification = NOTE_NONE;
277     }
278 
getNotificationBuilder(@onNull String channelId, @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res, @NonNull String ssid)279     private Notification.Builder getNotificationBuilder(@NonNull String channelId,
280             @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res,
281             @NonNull String ssid) {
282         return new Notification.Builder(mContext, channelId)
283                 .setContentTitle(ssid)
284                 .setSmallIcon(R.drawable.icon_wifi);
285     }
286 
287     /**
288      * Replacement for {@link Context#createContextAsUser(UserHandle, int)}, which is not available
289      * in API 29.
290      */
getContextAsUser(Context baseContext, UserHandle user)291     private static Context getContextAsUser(Context baseContext, UserHandle user) {
292         try {
293             return baseContext.createPackageContextAsUser(
294                     baseContext.getPackageName(), 0 /* flags */, user);
295         } catch (PackageManager.NameNotFoundException e) {
296             throw new IllegalStateException("NetworkStack own package not found", e);
297         }
298     }
299 
isVenueInfoNotificationEnabled()300     private boolean isVenueInfoNotificationEnabled() {
301         final NotificationChannel channel = getNotificationManagerForChannels()
302                 .getNotificationChannel(CHANNEL_VENUE_INFO);
303         if (channel == null) return false;
304 
305         return channel.getImportance() != IMPORTANCE_NONE;
306     }
307 
getNotificationTag(@onNull Network network)308     private static String getNotificationTag(@NonNull Network network) {
309         return Long.toString(network.getNetworkHandle());
310     }
311 
312     private class DefaultNetworkCallback extends ConnectivityManager.NetworkCallback {
313         @Override
onAvailable(Network network)314         public void onAvailable(Network network) {
315             updateDefaultNetwork(network);
316         }
317 
318         @Override
onLost(Network network)319         public void onLost(Network network) {
320             updateDefaultNetwork(null);
321         }
322 
updateDefaultNetwork(@ullable Network newNetwork)323         private void updateDefaultNetwork(@Nullable Network newNetwork) {
324             final Network oldDefault = mDefaultNetwork;
325             mDefaultNetwork = newNetwork;
326             if (oldDefault != null) updateNotifications(oldDefault);
327             if (newNetwork != null) updateNotifications(newNetwork);
328         }
329     }
330 
331     private class AllNetworksCallback extends ConnectivityManager.NetworkCallback {
332         @Override
onLinkPropertiesChanged(Network network, LinkProperties linkProperties)333         public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
334             updateNetworkStatus(network, status -> status.mLinkProperties = linkProperties);
335             updateNotifications(network);
336         }
337 
338         @Override
onCapabilitiesChanged(@onNull Network network, @NonNull NetworkCapabilities networkCapabilities)339         public void onCapabilitiesChanged(@NonNull Network network,
340                 @NonNull NetworkCapabilities networkCapabilities) {
341             updateNetworkStatus(network, s -> s.mNetworkCapabilities = networkCapabilities);
342             updateNotifications(network);
343         }
344 
345         @Override
onLost(Network network)346         public void onLost(Network network) {
347             final TrackedNetworkStatus status = mNetworkStatus.remove(network);
348             if (status == null) return;
349             dismissNotification(getNotificationTag(network), status);
350         }
351     }
352 }
353