71
71
import java .io .IOException ;
72
72
import java .net .HttpURLConnection ;
73
73
import java .net .InetAddress ;
74
+ import java .net .MalformedURLException ;
75
+ import java .net .UnknownHostException ;
74
76
import java .net .URL ;
77
+ import java .util .concurrent .CountDownLatch ;
78
+ import java .util .concurrent .atomic .AtomicReference ;
75
79
import java .util .List ;
76
80
import java .util .Random ;
77
81
@@ -228,7 +232,8 @@ public class NetworkMonitor extends StateMachine {
228
232
private final AlarmManager mAlarmManager ;
229
233
private final NetworkRequest mDefaultRequest ;
230
234
231
- private boolean mIsCaptivePortalCheckEnabled = false ;
235
+ private boolean mIsCaptivePortalCheckEnabled ;
236
+ private boolean mUseHttps ;
232
237
233
238
// Set if the user explicitly selected "Do not use this network" in captive portal sign-in app.
234
239
private boolean mUserDoesNotWant = false ;
@@ -276,6 +281,8 @@ public NetworkMonitor(Context context, Handler handler, NetworkAgentInfo network
276
281
277
282
mIsCaptivePortalCheckEnabled = Settings .Global .getInt (mContext .getContentResolver (),
278
283
Settings .Global .CAPTIVE_PORTAL_DETECTION_ENABLED , 1 ) == 1 ;
284
+ mUseHttps = Settings .Global .getInt (mContext .getContentResolver (),
285
+ Settings .Global .CAPTIVE_PORTAL_USE_HTTPS , 1 ) == 1 ;
279
286
280
287
start ();
281
288
}
@@ -324,6 +331,21 @@ public boolean processMessage(Message message) {
324
331
return HANDLED ;
325
332
case CMD_CAPTIVE_PORTAL_APP_FINISHED :
326
333
log ("CaptivePortal App responded with " + message .arg1 );
334
+
335
+ // If the user has seen and acted on a captive portal notification, and the
336
+ // captive portal app is now closed, disable HTTPS probes. This avoids the
337
+ // following pathological situation:
338
+ //
339
+ // 1. HTTP probe returns a captive portal, HTTPS probe fails or times out.
340
+ // 2. User opens the app and logs into the captive portal.
341
+ // 3. HTTP starts working, but HTTPS still doesn't work for some other reason -
342
+ // perhaps due to the network blocking HTTPS?
343
+ //
344
+ // In this case, we'll fail to validate the network even after the app is
345
+ // dismissed. There is now no way to use this network, because the app is now
346
+ // gone, so the user cannot select "Use this network as is".
347
+ mUseHttps = false ;
348
+
327
349
switch (message .arg1 ) {
328
350
case APP_RETURN_DISMISSED :
329
351
sendMessage (CMD_FORCE_REEVALUATION , 0 /* no UID */ , 0 );
@@ -424,13 +446,20 @@ public void exit() {
424
446
*/
425
447
@ VisibleForTesting
426
448
public static final class CaptivePortalProbeResult {
449
+ static final CaptivePortalProbeResult FAILED = new CaptivePortalProbeResult (599 , null );
450
+
427
451
final int mHttpResponseCode ; // HTTP response code returned from Internet probe.
428
452
final String mRedirectUrl ; // Redirect destination returned from Internet probe.
429
453
430
454
public CaptivePortalProbeResult (int httpResponseCode , String redirectUrl ) {
431
455
mHttpResponseCode = httpResponseCode ;
432
456
mRedirectUrl = redirectUrl ;
433
457
}
458
+
459
+ boolean isSuccessful () { return mHttpResponseCode == 204 ; }
460
+ boolean isPortal () {
461
+ return !isSuccessful () && mHttpResponseCode >= 200 && mHttpResponseCode <= 399 ;
462
+ }
434
463
}
435
464
436
465
// Being in the EvaluatingState State indicates the Network is being evaluated for internet
@@ -481,6 +510,7 @@ public boolean processMessage(Message message) {
481
510
// expensive metered network, or unwanted leaking of the User Agent string.
482
511
if (!mDefaultRequest .networkCapabilities .satisfiedByNetworkCapabilities (
483
512
mNetworkAgentInfo .networkCapabilities )) {
513
+ validationLog ("Network would not satisfy default request, not validating" );
484
514
transitionTo (mValidatedState );
485
515
return HANDLED ;
486
516
}
@@ -492,10 +522,9 @@ public boolean processMessage(Message message) {
492
522
// will be unresponsive. isCaptivePortal() could be executed on another Thread
493
523
// if this is found to cause problems.
494
524
CaptivePortalProbeResult probeResult = isCaptivePortal ();
495
- if (probeResult .mHttpResponseCode == 204 ) {
525
+ if (probeResult .isSuccessful () ) {
496
526
transitionTo (mValidatedState );
497
- } else if (probeResult .mHttpResponseCode >= 200 &&
498
- probeResult .mHttpResponseCode <= 399 ) {
527
+ } else if (probeResult .isPortal ()) {
499
528
mConnectivityServiceHandler .sendMessage (obtainMessage (EVENT_NETWORK_TESTED ,
500
529
NETWORK_TEST_RESULT_INVALID , mNetId , probeResult .mRedirectUrl ));
501
530
transitionTo (mCaptivePortalState );
@@ -659,72 +688,127 @@ public void exit() {
659
688
}
660
689
}
661
690
662
- public static String getCaptivePortalServerUrl (Context context ) {
691
+ private static String getCaptivePortalServerUrl (Context context , boolean isHttps ) {
663
692
String server = Settings .Global .getString (context .getContentResolver (),
664
693
Settings .Global .CAPTIVE_PORTAL_SERVER );
665
694
if (server == null ) server = DEFAULT_SERVER ;
666
- return "http://" + server + "/generate_204" ;
695
+ return (isHttps ? "https" : "http" ) + "://" + server + "/generate_204" ;
696
+ }
697
+
698
+ public static String getCaptivePortalServerUrl (Context context ) {
699
+ return getCaptivePortalServerUrl (context , false );
667
700
}
668
701
669
- /**
670
- * Do a URL fetch on a known server to see if we get the data we expect.
671
- * Returns HTTP response code.
672
- */
673
702
@ VisibleForTesting
674
703
protected CaptivePortalProbeResult isCaptivePortal () {
675
704
if (!mIsCaptivePortalCheckEnabled ) return new CaptivePortalProbeResult (204 , null );
676
705
677
- HttpURLConnection urlConnection = null ;
678
- int httpResponseCode = 599 ;
679
- String redirectUrl = null ;
680
- final Stopwatch probeTimer = new Stopwatch ().start ();
681
- try {
682
- URL url = new URL (getCaptivePortalServerUrl (mContext ));
683
- // On networks with a PAC instead of fetching a URL that should result in a 204
684
- // response, we instead simply fetch the PAC script. This is done for a few reasons:
685
- // 1. At present our PAC code does not yet handle multiple PACs on multiple networks
686
- // until something like https://android-review.googlesource.com/#/c/115180/ lands.
687
- // Network.openConnection() will ignore network-specific PACs and instead fetch
688
- // using NO_PROXY. If a PAC is in place, the only fetch we know will succeed with
689
- // NO_PROXY is the fetch of the PAC itself.
690
- // 2. To proxy the generate_204 fetch through a PAC would require a number of things
691
- // happen before the fetch can commence, namely:
692
- // a) the PAC script be fetched
693
- // b) a PAC script resolver service be fired up and resolve the captive portal
694
- // server.
695
- // Network validation could be delayed until these prerequisities are satisifed or
696
- // could simply be left to race them. Neither is an optimal solution.
697
- // 3. PAC scripts are sometimes used to block or restrict Internet access and may in
698
- // fact block fetching of the generate_204 URL which would lead to false negative
699
- // results for network validation.
700
- boolean fetchPac = false ;
701
- final ProxyInfo proxyInfo = mNetworkAgentInfo .linkProperties .getHttpProxy ();
702
- if (proxyInfo != null && !Uri .EMPTY .equals (proxyInfo .getPacFileUrl ())) {
703
- url = new URL (proxyInfo .getPacFileUrl ().toString ());
704
- fetchPac = true ;
706
+ URL pacUrl = null , httpUrl = null , httpsUrl = null ;
707
+
708
+ // On networks with a PAC instead of fetching a URL that should result in a 204
709
+ // response, we instead simply fetch the PAC script. This is done for a few reasons:
710
+ // 1. At present our PAC code does not yet handle multiple PACs on multiple networks
711
+ // until something like https://android-review.googlesource.com/#/c/115180/ lands.
712
+ // Network.openConnection() will ignore network-specific PACs and instead fetch
713
+ // using NO_PROXY. If a PAC is in place, the only fetch we know will succeed with
714
+ // NO_PROXY is the fetch of the PAC itself.
715
+ // 2. To proxy the generate_204 fetch through a PAC would require a number of things
716
+ // happen before the fetch can commence, namely:
717
+ // a) the PAC script be fetched
718
+ // b) a PAC script resolver service be fired up and resolve the captive portal
719
+ // server.
720
+ // Network validation could be delayed until these prerequisities are satisifed or
721
+ // could simply be left to race them. Neither is an optimal solution.
722
+ // 3. PAC scripts are sometimes used to block or restrict Internet access and may in
723
+ // fact block fetching of the generate_204 URL which would lead to false negative
724
+ // results for network validation.
725
+ final ProxyInfo proxyInfo = mNetworkAgentInfo .linkProperties .getHttpProxy ();
726
+ if (proxyInfo != null && !Uri .EMPTY .equals (proxyInfo .getPacFileUrl ())) {
727
+ try {
728
+ pacUrl = new URL (proxyInfo .getPacFileUrl ().toString ());
729
+ } catch (MalformedURLException e ) {
730
+ validationLog ("Invalid PAC URL: " + proxyInfo .getPacFileUrl ().toString ());
731
+ return CaptivePortalProbeResult .FAILED ;
705
732
}
706
- final StringBuffer connectInfo = new StringBuffer ();
707
- String hostToResolve = null ;
708
- // Only resolve a host if HttpURLConnection is about to, to avoid any potentially
709
- // unnecessary resolution.
710
- if (proxyInfo == null || fetchPac ) {
711
- hostToResolve = url .getHost ();
712
- } else if (proxyInfo != null ) {
713
- hostToResolve = proxyInfo .getHost ();
733
+ }
734
+
735
+ if (pacUrl == null ) {
736
+ try {
737
+ httpUrl = new URL (getCaptivePortalServerUrl (mContext , false ));
738
+ httpsUrl = new URL (getCaptivePortalServerUrl (mContext , true ));
739
+ } catch (MalformedURLException e ) {
740
+ validationLog ("Bad validation URL: " + getCaptivePortalServerUrl (mContext , false ));
741
+ return CaptivePortalProbeResult .FAILED ;
714
742
}
715
- if (!TextUtils .isEmpty (hostToResolve )) {
716
- connectInfo .append (", " + hostToResolve + "=" );
717
- final InetAddress [] addresses =
718
- mNetworkAgentInfo .network .getAllByName (hostToResolve );
743
+ }
744
+
745
+ long startTime = SystemClock .elapsedRealtime ();
746
+
747
+ // Pre-resolve the captive portal server host so we can log it.
748
+ // Only do this if HttpURLConnection is about to, to avoid any potentially
749
+ // unnecessary resolution.
750
+ String hostToResolve = null ;
751
+ if (pacUrl != null ) {
752
+ hostToResolve = pacUrl .getHost ();
753
+ } else if (proxyInfo != null ) {
754
+ hostToResolve = proxyInfo .getHost ();
755
+ } else {
756
+ hostToResolve = httpUrl .getHost ();
757
+ }
758
+
759
+ if (!TextUtils .isEmpty (hostToResolve )) {
760
+ String probeName = ValidationProbeEvent .getProbeName (ValidationProbeEvent .PROBE_DNS );
761
+ final Stopwatch dnsTimer = new Stopwatch ().start ();
762
+ try {
763
+ InetAddress [] addresses = mNetworkAgentInfo .network .getAllByName (hostToResolve );
764
+ long dnsLatency = dnsTimer .stop ();
765
+ ValidationProbeEvent .logEvent (mNetId , dnsLatency ,
766
+ ValidationProbeEvent .PROBE_DNS , ValidationProbeEvent .DNS_SUCCESS );
767
+ final StringBuffer connectInfo = new StringBuffer (", " + hostToResolve + "=" );
719
768
for (InetAddress address : addresses ) {
720
769
connectInfo .append (address .getHostAddress ());
721
770
if (address != addresses [addresses .length -1 ]) connectInfo .append ("," );
722
771
}
772
+ validationLog (probeName + " OK " + dnsLatency + "ms" + connectInfo );
773
+ } catch (UnknownHostException e ) {
774
+ long dnsLatency = dnsTimer .stop ();
775
+ ValidationProbeEvent .logEvent (mNetId , dnsLatency ,
776
+ ValidationProbeEvent .PROBE_DNS , ValidationProbeEvent .DNS_FAILURE );
777
+ validationLog (probeName + " FAIL " + dnsLatency + "ms, " + hostToResolve );
723
778
}
724
- validationLog ("Checking " + url .toString () + " on " +
725
- mNetworkAgentInfo .networkInfo .getExtraInfo () + connectInfo );
779
+ }
780
+
781
+ CaptivePortalProbeResult result ;
782
+ if (pacUrl != null ) {
783
+ result = sendHttpProbe (pacUrl , ValidationProbeEvent .PROBE_PAC );
784
+ } else if (mUseHttps ) {
785
+ result = sendParallelHttpProbes (httpsUrl , httpUrl );
786
+ } else {
787
+ result = sendHttpProbe (httpUrl , ValidationProbeEvent .PROBE_HTTP );
788
+ }
789
+
790
+ long endTime = SystemClock .elapsedRealtime ();
791
+
792
+ sendNetworkConditionsBroadcast (true /* response received */ ,
793
+ result .isPortal () /* isCaptivePortal */ ,
794
+ startTime , endTime );
795
+
796
+ return result ;
797
+ }
798
+
799
+ /**
800
+ * Do a URL fetch on a known server to see if we get the data we expect.
801
+ * Returns HTTP response code.
802
+ */
803
+ @ VisibleForTesting
804
+ protected CaptivePortalProbeResult sendHttpProbe (URL url , int probeType ) {
805
+ HttpURLConnection urlConnection = null ;
806
+ int httpResponseCode = 599 ;
807
+ String redirectUrl = null ;
808
+ final Stopwatch probeTimer = new Stopwatch ().start ();
809
+ try {
726
810
urlConnection = (HttpURLConnection ) mNetworkAgentInfo .network .openConnection (url );
727
- urlConnection .setInstanceFollowRedirects (fetchPac );
811
+ urlConnection .setInstanceFollowRedirects (probeType == ValidationProbeEvent . PROBE_PAC );
728
812
urlConnection .setConnectTimeout (SOCKET_TIMEOUT_MS );
729
813
urlConnection .setReadTimeout (SOCKET_TIMEOUT_MS );
730
814
urlConnection .setUseCaches (false );
@@ -738,7 +822,9 @@ protected CaptivePortalProbeResult isCaptivePortal() {
738
822
// Time how long it takes to get a response to our request
739
823
long responseTimestamp = SystemClock .elapsedRealtime ();
740
824
741
- validationLog ("isCaptivePortal: ret=" + httpResponseCode +
825
+ validationLog (ValidationProbeEvent .getProbeName (probeType ) + " " + url +
826
+ " time=" + (responseTimestamp - requestTimestamp ) + "ms" +
827
+ " ret=" + httpResponseCode +
742
828
" headers=" + urlConnection .getHeaderFields ());
743
829
// NOTE: We may want to consider an "HTTP/1.0 204" response to be a captive
744
830
// portal. The only example of this seen so far was a captive portal. For
@@ -756,14 +842,10 @@ protected CaptivePortalProbeResult isCaptivePortal() {
756
842
httpResponseCode = 204 ;
757
843
}
758
844
759
- if (httpResponseCode == 200 && fetchPac ) {
845
+ if (httpResponseCode == 200 && probeType == ValidationProbeEvent . PROBE_PAC ) {
760
846
validationLog ("PAC fetch 200 response interpreted as 204 response." );
761
847
httpResponseCode = 204 ;
762
848
}
763
-
764
- sendNetworkConditionsBroadcast (true /* response received */ ,
765
- httpResponseCode != 204 /* isCaptivePortal */ ,
766
- requestTimestamp , responseTimestamp );
767
849
} catch (IOException e ) {
768
850
validationLog ("Probably not a portal: exception " + e );
769
851
if (httpResponseCode == 599 ) {
@@ -774,11 +856,68 @@ protected CaptivePortalProbeResult isCaptivePortal() {
774
856
urlConnection .disconnect ();
775
857
}
776
858
}
777
- final int probeType = ValidationProbeEvent .PROBE_HTTP ;
778
859
ValidationProbeEvent .logEvent (mNetId , probeTimer .stop (), probeType , httpResponseCode );
779
860
return new CaptivePortalProbeResult (httpResponseCode , redirectUrl );
780
861
}
781
862
863
+ private CaptivePortalProbeResult sendParallelHttpProbes (URL httpsUrl , URL httpUrl ) {
864
+ // Number of probes to wait for. We might wait for all of them, but we might also return if
865
+ // only one of them has replied. For example, we immediately return if the HTTP probe finds
866
+ // a captive portal, even if the HTTPS probe is timing out.
867
+ final CountDownLatch latch = new CountDownLatch (2 );
868
+
869
+ // Which probe result we're going to use. This doesn't need to be atomic, but it does need
870
+ // to be final because otherwise we can't set it from the ProbeThreads.
871
+ final AtomicReference <CaptivePortalProbeResult > finalResult = new AtomicReference <>();
872
+
873
+ final class ProbeThread extends Thread {
874
+ private final boolean mIsHttps ;
875
+ private volatile CaptivePortalProbeResult mResult ;
876
+
877
+ public ProbeThread (boolean isHttps ) {
878
+ mIsHttps = isHttps ;
879
+ }
880
+
881
+ public CaptivePortalProbeResult getResult () {
882
+ return mResult ;
883
+ }
884
+
885
+ @ Override
886
+ public void run () {
887
+ if (mIsHttps ) {
888
+ mResult = sendHttpProbe (httpsUrl , ValidationProbeEvent .PROBE_HTTPS );
889
+ } else {
890
+ mResult = sendHttpProbe (httpUrl , ValidationProbeEvent .PROBE_HTTP );
891
+ }
892
+ if ((mIsHttps && mResult .isSuccessful ()) || (!mIsHttps && mResult .isPortal ())) {
893
+ // HTTPS succeeded, or HTTP found a portal. Don't wait for the other probe.
894
+ finalResult .compareAndSet (null , mResult );
895
+ latch .countDown ();
896
+ }
897
+ // Signal that one probe has completed. If we've already made a decision, or if this
898
+ // is the second probe, the latch will be at zero and we'll return a result.
899
+ latch .countDown ();
900
+ }
901
+ }
902
+
903
+ ProbeThread httpsProbe = new ProbeThread (true );
904
+ ProbeThread httpProbe = new ProbeThread (false );
905
+ httpsProbe .start ();
906
+ httpProbe .start ();
907
+
908
+ try {
909
+ latch .await ();
910
+ } catch (InterruptedException e ) {
911
+ validationLog ("Error: probe wait interrupted!" );
912
+ return CaptivePortalProbeResult .FAILED ;
913
+ }
914
+
915
+ // If there was no deciding probe, that means that both probes completed. Return HTTPS.
916
+ finalResult .compareAndSet (null , httpsProbe .getResult ());
917
+
918
+ return finalResult .get ();
919
+ }
920
+
782
921
/**
783
922
* @param responseReceived - whether or not we received a valid HTTP response to our request.
784
923
* If false, isCaptivePortal and responseTimestampMs are ignored
0 commit comments