CaptivePortalTracker.java revision 35152f13b3302dbcb43345458dd5b8b092e37320
1/*
2 * Copyright (C) 2012 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
17package android.net;
18
19import android.app.Activity;
20import android.app.Notification;
21import android.app.NotificationManager;
22import android.app.PendingIntent;
23import android.content.BroadcastReceiver;
24import android.content.Context;
25import android.content.Intent;
26import android.content.IntentFilter;
27import android.content.res.Resources;
28import android.database.ContentObserver;
29import android.net.ConnectivityManager;
30import android.net.IConnectivityManager;
31import android.os.Handler;
32import android.os.UserHandle;
33import android.os.Message;
34import android.os.RemoteException;
35import android.provider.Settings;
36import android.telephony.TelephonyManager;
37import android.text.TextUtils;
38
39import com.android.internal.util.State;
40import com.android.internal.util.StateMachine;
41
42import java.io.IOException;
43import java.net.HttpURLConnection;
44import java.net.InetAddress;
45import java.net.Inet4Address;
46import java.net.SocketTimeoutException;
47import java.net.URL;
48import java.net.UnknownHostException;
49
50import com.android.internal.R;
51
52/**
53 * This class allows captive portal detection on a network.
54 * @hide
55 */
56public class CaptivePortalTracker extends StateMachine {
57    private static final boolean DBG = true;
58    private static final String TAG = "CaptivePortalTracker";
59
60    private static final String DEFAULT_SERVER = "clients3.google.com";
61    private static final String NOTIFICATION_ID = "CaptivePortal.Notification";
62
63    private static final int SOCKET_TIMEOUT_MS = 10000;
64
65    private String mServer;
66    private String mUrl;
67    private boolean mNotificationShown = false;
68    private boolean mIsCaptivePortalCheckEnabled = false;
69    private IConnectivityManager mConnService;
70    private TelephonyManager mTelephonyManager;
71    private Context mContext;
72    private NetworkInfo mNetworkInfo;
73
74    private static final int CMD_DETECT_PORTAL          = 0;
75    private static final int CMD_CONNECTIVITY_CHANGE    = 1;
76    private static final int CMD_DELAYED_CAPTIVE_CHECK  = 2;
77
78    /* This delay happens every time before we do a captive check on a network */
79    private static final int DELAYED_CHECK_INTERVAL_MS = 10000;
80    private int mDelayedCheckToken = 0;
81
82    private State mDefaultState = new DefaultState();
83    private State mNoActiveNetworkState = new NoActiveNetworkState();
84    private State mActiveNetworkState = new ActiveNetworkState();
85    private State mDelayedCaptiveCheckState = new DelayedCaptiveCheckState();
86
87    private static final String SETUP_WIZARD_PACKAGE = "com.google.android.setupwizard";
88    private boolean mDeviceProvisioned = false;
89    private ProvisioningObserver mProvisioningObserver;
90
91    private CaptivePortalTracker(Context context, IConnectivityManager cs) {
92        super(TAG);
93
94        mContext = context;
95        mConnService = cs;
96        mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
97        mProvisioningObserver = new ProvisioningObserver();
98
99        IntentFilter filter = new IntentFilter();
100        filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
101        filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION_IMMEDIATE);
102        mContext.registerReceiver(mReceiver, filter);
103
104        mServer = Settings.Global.getString(mContext.getContentResolver(),
105                Settings.Global.CAPTIVE_PORTAL_SERVER);
106        if (mServer == null) mServer = DEFAULT_SERVER;
107
108        mIsCaptivePortalCheckEnabled = Settings.Global.getInt(mContext.getContentResolver(),
109                Settings.Global.CAPTIVE_PORTAL_DETECTION_ENABLED, 1) == 1;
110
111        addState(mDefaultState);
112            addState(mNoActiveNetworkState, mDefaultState);
113            addState(mActiveNetworkState, mDefaultState);
114                addState(mDelayedCaptiveCheckState, mActiveNetworkState);
115        setInitialState(mNoActiveNetworkState);
116    }
117
118    private class ProvisioningObserver extends ContentObserver {
119        ProvisioningObserver() {
120            super(new Handler());
121            mContext.getContentResolver().registerContentObserver(Settings.Global.getUriFor(
122                    Settings.Global.DEVICE_PROVISIONED), false, this);
123            onChange(false); // load initial value
124        }
125
126        @Override
127        public void onChange(boolean selfChange) {
128            mDeviceProvisioned = Settings.Global.getInt(mContext.getContentResolver(),
129                    Settings.Global.DEVICE_PROVISIONED, 0) != 0;
130        }
131    }
132
133    private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
134        @Override
135        public void onReceive(Context context, Intent intent) {
136            String action = intent.getAction();
137            // Normally, we respond to CONNECTIVITY_ACTION, allowing time for the change in
138            // connectivity to stabilize, but if the device is not yet provisioned, respond
139            // immediately to speed up transit through the setup wizard.
140            if ((mDeviceProvisioned && action.equals(ConnectivityManager.CONNECTIVITY_ACTION))
141                    || (!mDeviceProvisioned
142                            && action.equals(ConnectivityManager.CONNECTIVITY_ACTION_IMMEDIATE))) {
143                NetworkInfo info = intent.getParcelableExtra(
144                        ConnectivityManager.EXTRA_NETWORK_INFO);
145                sendMessage(obtainMessage(CMD_CONNECTIVITY_CHANGE, info));
146            }
147        }
148    };
149
150    public static CaptivePortalTracker makeCaptivePortalTracker(Context context,
151            IConnectivityManager cs) {
152        CaptivePortalTracker captivePortal = new CaptivePortalTracker(context, cs);
153        captivePortal.start();
154        return captivePortal;
155    }
156
157    public void detectCaptivePortal(NetworkInfo info) {
158        sendMessage(obtainMessage(CMD_DETECT_PORTAL, info));
159    }
160
161    private class DefaultState extends State {
162        @Override
163        public void enter() {
164            if (DBG) log(getName() + "\n");
165        }
166
167        @Override
168        public boolean processMessage(Message message) {
169            if (DBG) log(getName() + message.toString() + "\n");
170            switch (message.what) {
171                case CMD_DETECT_PORTAL:
172                    NetworkInfo info = (NetworkInfo) message.obj;
173                    // Checking on a secondary connection is not supported
174                    // yet
175                    notifyPortalCheckComplete(info);
176                    break;
177                case CMD_CONNECTIVITY_CHANGE:
178                case CMD_DELAYED_CAPTIVE_CHECK:
179                    break;
180                default:
181                    loge("Ignoring " + message);
182                    break;
183            }
184            return HANDLED;
185        }
186    }
187
188    private class NoActiveNetworkState extends State {
189        @Override
190        public void enter() {
191            if (DBG) log(getName() + "\n");
192            mNetworkInfo = null;
193            /* Clear any previous notification */
194            setNotificationVisible(false);
195        }
196
197        @Override
198        public boolean processMessage(Message message) {
199            if (DBG) log(getName() + message.toString() + "\n");
200            InetAddress server;
201            NetworkInfo info;
202            switch (message.what) {
203                case CMD_CONNECTIVITY_CHANGE:
204                    info = (NetworkInfo) message.obj;
205                    if (info.isConnected() && isActiveNetwork(info)) {
206                        mNetworkInfo = info;
207                        transitionTo(mDelayedCaptiveCheckState);
208                    }
209                    break;
210                default:
211                    return NOT_HANDLED;
212            }
213            return HANDLED;
214        }
215    }
216
217    private class ActiveNetworkState extends State {
218        @Override
219        public void enter() {
220            if (DBG) log(getName() + "\n");
221        }
222
223        @Override
224        public boolean processMessage(Message message) {
225            NetworkInfo info;
226            switch (message.what) {
227               case CMD_CONNECTIVITY_CHANGE:
228                    info = (NetworkInfo) message.obj;
229                    if (!info.isConnected()
230                            && info.getType() == mNetworkInfo.getType()) {
231                        if (DBG) log("Disconnected from active network " + info);
232                        transitionTo(mNoActiveNetworkState);
233                    } else if (info.getType() != mNetworkInfo.getType() &&
234                            info.isConnected() &&
235                            isActiveNetwork(info)) {
236                        if (DBG) log("Active network switched " + info);
237                        deferMessage(message);
238                        transitionTo(mNoActiveNetworkState);
239                    }
240                    break;
241                default:
242                    return NOT_HANDLED;
243            }
244            return HANDLED;
245        }
246    }
247
248
249
250    private class DelayedCaptiveCheckState extends State {
251        @Override
252        public void enter() {
253            if (DBG) log(getName() + "\n");
254            Message message = obtainMessage(CMD_DELAYED_CAPTIVE_CHECK, ++mDelayedCheckToken, 0);
255            if (mDeviceProvisioned) {
256                sendMessageDelayed(message, DELAYED_CHECK_INTERVAL_MS);
257            } else {
258                sendMessage(message);
259            }
260        }
261
262        @Override
263        public boolean processMessage(Message message) {
264            if (DBG) log(getName() + message.toString() + "\n");
265            switch (message.what) {
266                case CMD_DELAYED_CAPTIVE_CHECK:
267                    if (message.arg1 == mDelayedCheckToken) {
268                        InetAddress server = lookupHost(mServer);
269                        boolean captive = server != null && isCaptivePortal(server);
270                        if (captive) {
271                            if (DBG) log("Captive network " + mNetworkInfo);
272                        } else {
273                            if (DBG) log("Not captive network " + mNetworkInfo);
274                        }
275                        if (mDeviceProvisioned) {
276                            if (captive) {
277                                // Setup Wizard will assist the user in connecting to a captive
278                                // portal, so make the notification visible unless during setup
279                                setNotificationVisible(true);
280                            }
281                        } else {
282                            Intent intent = new Intent(
283                                    ConnectivityManager.ACTION_CAPTIVE_PORTAL_TEST_COMPLETED);
284                            intent.putExtra(ConnectivityManager.EXTRA_IS_CAPTIVE_PORTAL, captive);
285                            intent.setPackage(SETUP_WIZARD_PACKAGE);
286                            mContext.sendBroadcast(intent);
287                        }
288
289                        transitionTo(mActiveNetworkState);
290                    }
291                    break;
292                default:
293                    return NOT_HANDLED;
294            }
295            return HANDLED;
296        }
297    }
298
299    private void notifyPortalCheckComplete(NetworkInfo info) {
300        if (info == null) {
301            loge("notifyPortalCheckComplete on null");
302            return;
303        }
304        try {
305            mConnService.captivePortalCheckComplete(info);
306        } catch(RemoteException e) {
307            e.printStackTrace();
308        }
309    }
310
311    private boolean isActiveNetwork(NetworkInfo info) {
312        try {
313            NetworkInfo active = mConnService.getActiveNetworkInfo();
314            if (active != null && active.getType() == info.getType()) {
315                return true;
316            }
317        } catch (RemoteException e) {
318            e.printStackTrace();
319        }
320        return false;
321    }
322
323    /**
324     * Do a URL fetch on a known server to see if we get the data we expect
325     */
326    private boolean isCaptivePortal(InetAddress server) {
327        HttpURLConnection urlConnection = null;
328        if (!mIsCaptivePortalCheckEnabled) return false;
329
330        mUrl = "http://" + server.getHostAddress() + "/generate_204";
331        if (DBG) log("Checking " + mUrl);
332        try {
333            URL url = new URL(mUrl);
334            urlConnection = (HttpURLConnection) url.openConnection();
335            urlConnection.setInstanceFollowRedirects(false);
336            urlConnection.setConnectTimeout(SOCKET_TIMEOUT_MS);
337            urlConnection.setReadTimeout(SOCKET_TIMEOUT_MS);
338            urlConnection.setUseCaches(false);
339            urlConnection.getInputStream();
340            // we got a valid response, but not from the real google
341            return urlConnection.getResponseCode() != 204;
342        } catch (SocketTimeoutException e) {
343            if (DBG) log("Probably a portal: exception " + e);
344            return true;
345        } catch (IOException e) {
346            if (DBG) log("Probably not a portal: exception " + e);
347            return false;
348        } finally {
349            if (urlConnection != null) {
350                urlConnection.disconnect();
351            }
352        }
353    }
354
355    private InetAddress lookupHost(String hostname) {
356        InetAddress inetAddress[];
357        try {
358            inetAddress = InetAddress.getAllByName(hostname);
359        } catch (UnknownHostException e) {
360            return null;
361        }
362
363        for (InetAddress a : inetAddress) {
364            if (a instanceof Inet4Address) return a;
365        }
366        return null;
367    }
368
369    private void setNotificationVisible(boolean visible) {
370        // if it should be hidden and it is already hidden, then noop
371        if (!visible && !mNotificationShown) {
372            if (DBG) log("setNotivicationVisible: false and not shown, so noop");
373            return;
374        }
375
376        Resources r = Resources.getSystem();
377        NotificationManager notificationManager = (NotificationManager) mContext
378            .getSystemService(Context.NOTIFICATION_SERVICE);
379
380        if (visible) {
381            CharSequence title;
382            CharSequence details;
383            int icon;
384            String url = null;
385            switch (mNetworkInfo.getType()) {
386                case ConnectivityManager.TYPE_WIFI:
387                    title = r.getString(R.string.wifi_available_sign_in, 0);
388                    details = r.getString(R.string.network_available_sign_in_detailed,
389                            mNetworkInfo.getExtraInfo());
390                    icon = R.drawable.stat_notify_wifi_in_range;
391                    url = mUrl;
392                    break;
393                case ConnectivityManager.TYPE_MOBILE:
394                    title = r.getString(R.string.network_available_sign_in, 0);
395                    // TODO: Change this to pull from NetworkInfo once a printable
396                    // name has been added to it
397                    details = mTelephonyManager.getNetworkOperatorName();
398                    icon = R.drawable.stat_notify_rssi_in_range;
399                    try {
400                        url = mConnService.getMobileProvisioningUrl();
401                        if (TextUtils.isEmpty(url)) {
402                            url = mConnService.getMobileRedirectedProvisioningUrl();
403                        }
404                    } catch(RemoteException e) {
405                        e.printStackTrace();
406                    }
407                    if (TextUtils.isEmpty(url)) {
408                        url = mUrl;
409                    }
410                    break;
411                default:
412                    title = r.getString(R.string.network_available_sign_in, 0);
413                    details = r.getString(R.string.network_available_sign_in_detailed,
414                            mNetworkInfo.getExtraInfo());
415                    icon = R.drawable.stat_notify_rssi_in_range;
416                    url = mUrl;
417                    break;
418            }
419
420            Notification notification = new Notification();
421            notification.when = 0;
422            notification.icon = icon;
423            notification.flags = Notification.FLAG_AUTO_CANCEL;
424            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
425            intent.setFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT |
426                    Intent.FLAG_ACTIVITY_NEW_TASK);
427            notification.contentIntent = PendingIntent.getActivity(mContext, 0, intent, 0);
428            notification.tickerText = title;
429            notification.setLatestEventInfo(mContext, title, details, notification.contentIntent);
430
431            if (DBG) log("setNotivicationVisible: make visible");
432            notificationManager.notify(NOTIFICATION_ID, 1, notification);
433        } else {
434            if (DBG) log("setNotivicationVisible: cancel notification");
435            notificationManager.cancel(NOTIFICATION_ID, 1);
436        }
437        mNotificationShown = visible;
438    }
439}
440