Parcourir la source

Merge pull request #142 from rod-hynes/master

Sample apps update.
Rod Hynes il y a 10 ans
Parent
commit
706e7ac4a5
45 fichiers modifiés avec 1881 ajouts et 83 suppressions
  1. 1 12
      AndroidLibrary/README.md
  2. 1 1
      SampleApps/Psibot/.idea/gradle.xml
  3. 1 3
      SampleApps/Psibot/README.md
  4. 4 11
      SampleApps/Psibot/app/app.iml
  5. 1 1
      SampleApps/Psibot/app/build.gradle
  6. 93 54
      SampleApps/Psibot/app/src/main/java/ca/psiphon/PsiphonTunnel.java
  7. 1 1
      SampleApps/Psibot/app/src/main/java/ca/psiphon/psibot/Service.java
  8. 9 0
      SampleApps/TunneledWebView/.gitignore
  9. 1 0
      SampleApps/TunneledWebView/.idea/.name
  10. 22 0
      SampleApps/TunneledWebView/.idea/compiler.xml
  11. 3 0
      SampleApps/TunneledWebView/.idea/copyright/profiles_settings.xml
  12. 19 0
      SampleApps/TunneledWebView/.idea/gradle.xml
  13. 46 0
      SampleApps/TunneledWebView/.idea/misc.xml
  14. 9 0
      SampleApps/TunneledWebView/.idea/modules.xml
  15. 12 0
      SampleApps/TunneledWebView/.idea/runConfigurations.xml
  16. 6 0
      SampleApps/TunneledWebView/.idea/vcs.xml
  17. 159 0
      SampleApps/TunneledWebView/README.md
  18. 1 0
      SampleApps/TunneledWebView/app/.gitignore
  19. 33 0
      SampleApps/TunneledWebView/app/build.gradle
  20. 17 0
      SampleApps/TunneledWebView/app/proguard-rules.pro
  21. 13 0
      SampleApps/TunneledWebView/app/src/androidTest/java/ca/psiphon/tunneledwebview/ApplicationTest.java
  22. 24 0
      SampleApps/TunneledWebView/app/src/main/AndroidManifest.xml
  23. 384 0
      SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/PsiphonTunnel.java
  24. 296 0
      SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/MainActivity.java
  25. 313 0
      SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/WebViewProxySettings.java
  26. 40 0
      SampleApps/TunneledWebView/app/src/main/res/layout/activity_main.xml
  27. 15 0
      SampleApps/TunneledWebView/app/src/main/res/layout/log_message.xml
  28. BIN
      SampleApps/TunneledWebView/app/src/main/res/mipmap-hdpi/ic_launcher.png
  29. BIN
      SampleApps/TunneledWebView/app/src/main/res/mipmap-mdpi/ic_launcher.png
  30. BIN
      SampleApps/TunneledWebView/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  31. BIN
      SampleApps/TunneledWebView/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  32. BIN
      SampleApps/TunneledWebView/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  33. 17 0
      SampleApps/TunneledWebView/app/src/main/res/raw/psiphon_config_stub
  34. 6 0
      SampleApps/TunneledWebView/app/src/main/res/values-w820dp/dimens.xml
  35. 5 0
      SampleApps/TunneledWebView/app/src/main/res/values/dimens.xml
  36. 3 0
      SampleApps/TunneledWebView/app/src/main/res/values/strings.xml
  37. 8 0
      SampleApps/TunneledWebView/app/src/main/res/values/styles.xml
  38. 15 0
      SampleApps/TunneledWebView/app/src/test/java/ca/psiphon/tunneledwebview/ExampleUnitTest.java
  39. 24 0
      SampleApps/TunneledWebView/build.gradle
  40. 18 0
      SampleApps/TunneledWebView/gradle.properties
  41. BIN
      SampleApps/TunneledWebView/gradle/wrapper/gradle-wrapper.jar
  42. 6 0
      SampleApps/TunneledWebView/gradle/wrapper/gradle-wrapper.properties
  43. 164 0
      SampleApps/TunneledWebView/gradlew
  44. 90 0
      SampleApps/TunneledWebView/gradlew.bat
  45. 1 0
      SampleApps/TunneledWebView/settings.gradle

+ 1 - 12
AndroidLibrary/README.md

@@ -28,18 +28,7 @@ Using
 
 1. Build `psi.aar` from source or use the [binary release](https://github.com/Psiphon-Labs/psiphon-tunnel-core/releases)
 1. Add `psi.aar` to your Android Studio project as described in the [gomobile documentation](https://godoc.org/golang.org/x/mobile/cmd/gomobile)
-1. Example usage in [Psibot sample app](../SampleApps/Psibot/README.md)
-
-See sample API usage in [Psibot's PsiphonVpn.java](../SampleApps/Psibot/app/src/main/java/ca/psiphon/PsiphonVpn.java). Uses `gobind` conventions for data passing.
-
-1. Embed a [config file](../README.md#setup)
-1. Call `Go.init(getApplicationContext());` in `Application.onCreate()`
-1. Extend `Psi.Listener.Stub` to receive messages in `Message(String line)`
-1. Call `Psi.Start(configFile, Psi.Listener)` to start Psiphon. Catch `Exception` to receive errors.
-1. Call `Psi.Stop()` to stop Psiphon.
-1. Sample shows how to monitor messages and detect which proxy ports to use and when the tunnel is active.
-
-NOTE: may add more explicit interface for state change events.
+1. Example usage in [TunneledWebView sample app](../SampleApps/TunneledWebView/README.md)
 
 Limitations
 --------------------------------------------------------------------------------

+ 1 - 1
SampleApps/Psibot/.idea/gradle.xml

@@ -5,7 +5,7 @@
       <GradleProjectSettings>
         <option name="distributionType" value="LOCAL" />
         <option name="externalProjectPath" value="$PROJECT_DIR$" />
-        <option name="gradleHome" value="$APPLICATION_HOME_DIR$/gradle/gradle-2.4" />
+        <option name="gradleHome" value="$APPLICATION_HOME_DIR$/gradle/gradle-2.8" />
         <option name="gradleJvm" value="1.8" />
         <option name="modules">
           <set>

+ 1 - 3
SampleApps/Psibot/README.md

@@ -4,7 +4,7 @@ Psibot README
 Overview
 --------------------------------------------------------------------------------
 
-Psibot is a sample app that demonstrates embedding the Psiphon Go client in
+Psibot is a sample app that demonstrates embedding the Psiphon Library in
 an Android app. Psibot uses the Android VpnService API to route all device
 traffic through tun2socks and in turn through Psiphon.
 
@@ -24,5 +24,3 @@ Psiphon Android Library and config file
 Uses the [Psiphon Android Library](../AndroidLibrary/README.md).
 
 * `app/src/main/res/raw/psiphon_config_stub` and its placeholder values must be replaced with `app\src\main\res\raw\psiphon_config` and valid configuration values.
-
-* Install the Android Library shared object binary at `app/src/main/jniLibs/armeabi-v7a/libgojni.so`.

+ 4 - 11
SampleApps/Psibot/app/app.iml

@@ -65,30 +65,23 @@
       <sourceFolder url="file://$MODULE_DIR$/src/androidTest/jni" isTestSource="true" />
       <sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" isTestSource="true" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/assets" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/bundles" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/classes" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/coverage-instrumented-classes" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dependency-cache" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex-cache" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/go.psi/psi/0.0.8/jars" />
+      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/exploded-aar/go.psi/psi/0.0.10/jars" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/incremental" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/jacoco" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/javaResources" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/libs" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/lint" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/manifests" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/ndk" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/pre-dexed" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/proguard" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/res" />
+      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/resources" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/symbols" />
+      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/tmp" />
       <excludeFolder url="file://$MODULE_DIR$/build/outputs" />
       <excludeFolder url="file://$MODULE_DIR$/build/tmp" />
     </content>
     <orderEntry type="jdk" jdkName="Android API 21 Platform" jdkType="Android SDK" />
     <orderEntry type="sourceFolder" forTests="false" />
-    <orderEntry type="library" exported="" name="psi-0.0.8" level="project" />
+    <orderEntry type="library" exported="" name="psi-0.0.10" level="project" />
   </component>
 </module>

+ 1 - 1
SampleApps/Psibot/app/build.gradle

@@ -27,5 +27,5 @@ repositories {
 
 dependencies {
     compile fileTree(dir: 'libs', include: ['*.jar'])
-    compile 'go.psi:psi:0.0.8@aar'
+    compile 'go.psi:psi:0.0.10@aar'
 }

+ 93 - 54
SampleApps/Psibot/app/src/main/java/ca/psiphon/PsiphonTunnel.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2015, Psiphon Inc.
+ * Copyright (c) 2016, Psiphon Inc.
  * All rights reserved.
  *
  * This program is free software: you can redistribute it and/or modify
@@ -35,6 +35,7 @@ import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.PrintStream;
@@ -57,6 +58,8 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 import go.psi.Psi;
 
@@ -65,8 +68,8 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     public interface HostService {
         public String getAppName();
         public Context getContext();
-        public VpnService getVpnService();
-        public VpnService.Builder newVpnServiceBuilder();
+        public Object getVpnService(); // Object must be a VpnService (Android < 4 cannot reference this class name)
+        public Object newVpnServiceBuilder(); // Object must be a VpnService.Builder (Android < 4 cannot reference this class name)
         public String getPsiphonConfig();
         public void onDiagnosticMessage(String message);
         public void onAvailableEgressRegions(List<String> regions);
@@ -88,9 +91,9 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
     private final HostService mHostService;
     private PrivateAddress mPrivateAddress;
-    private ParcelFileDescriptor mTunFd;
-    private int mLocalSocksProxyPort;
-    private boolean mRoutingThroughTunnel;
+    private AtomicReference<ParcelFileDescriptor> mTunFd;
+    private AtomicInteger mLocalSocksProxyPort;
+    private AtomicBoolean mRoutingThroughTunnel;
     private Thread mTun2SocksThread;
     private AtomicBoolean mIsWaitingForNetworkConnectivity;
 
@@ -98,7 +101,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     // go.psi.Psi and tun2socks implementations each contain global state.
     private static PsiphonTunnel mPsiphonTunnel;
 
-    public static synchronized PsiphonTunnel newPsiphonVpn(HostService hostService) {
+    public static synchronized PsiphonTunnel newPsiphonTunnel(HostService hostService) {
         if (mPsiphonTunnel != null) {
             mPsiphonTunnel.stop();
         }
@@ -110,8 +113,9 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
     private PsiphonTunnel(HostService hostService) {
         mHostService = hostService;
-        mLocalSocksProxyPort = 0;
-        mRoutingThroughTunnel = false;
+        mTunFd = new AtomicReference<ParcelFileDescriptor>();
+        mLocalSocksProxyPort = new AtomicInteger(0);
+        mRoutingThroughTunnel = new AtomicBoolean(false);
         mIsWaitingForNetworkConnectivity = new AtomicBoolean(false);
     }
 
@@ -148,7 +152,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     public synchronized void stop() {
         stopVpn();
         stopPsiphon();
-        mLocalSocksProxyPort = 0;
+        mLocalSocksProxyPort.set(0);
     }
 
     //----------------------------------------------------------------------------------------------
@@ -158,7 +162,13 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     private final static String VPN_INTERFACE_NETMASK = "255.255.255.0";
     private final static int VPN_INTERFACE_MTU = 1500;
     private final static int UDPGW_SERVER_PORT = 7300;
-    private final static String DEFAULT_DNS_SERVER = "8.8.4.4";
+    private final static String DEFAULT_PRIMARY_DNS_SERVER = "8.8.4.4";
+    private final static String DEFAULT_SECONDARY_DNS_SERVER = "8.8.8.8";
+
+    // Note: Atomic variables used for getting/setting local proxy port, routing flag, and
+    // tun fd, as these functions may be called via PsiphonProvider callbacks. Do not use
+    // synchronized functions as stop() is synchronized and a deadlock is possible as callbacks
+    // can be called while stop holds the lock.
 
     @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
     private boolean startVpn() throws Exception {
@@ -172,19 +182,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             // Workaround for https://code.google.com/p/android/issues/detail?id=61096
             Locale.setDefault(new Locale("en"));
 
-            mTunFd = mHostService.newVpnServiceBuilder()
-                    .setSession(mHostService.getAppName())
-                    .setMtu(VPN_INTERFACE_MTU)
-                    .addAddress(mPrivateAddress.mIpAddress, mPrivateAddress.mPrefixLength)
-                    .addRoute("0.0.0.0", 0)
-                    .addRoute(mPrivateAddress.mSubnet, mPrivateAddress.mPrefixLength)
-                    .addDnsServer(mPrivateAddress.mRouter)
-                    .establish();
-            if (mTunFd == null) {
+            ParcelFileDescriptor tunFd =
+                    ((VpnService.Builder) mHostService.newVpnServiceBuilder())
+                            .setSession(mHostService.getAppName())
+                            .setMtu(VPN_INTERFACE_MTU)
+                            .addAddress(mPrivateAddress.mIpAddress, mPrivateAddress.mPrefixLength)
+                            .addRoute("0.0.0.0", 0)
+                            .addRoute(mPrivateAddress.mSubnet, mPrivateAddress.mPrefixLength)
+                            .addDnsServer(mPrivateAddress.mRouter)
+                            .establish();
+            if (tunFd == null) {
                 // As per http://developer.android.com/reference/android/net/VpnService.Builder.html#establish%28%29,
                 // this application is no longer prepared or was revoked.
                 return false;
             }
+            mTunFd.set(tunFd);
+
             mHostService.onDiagnosticMessage("VPN established");
 
         } catch(IllegalArgumentException e) {
@@ -201,19 +214,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         return true;
     }
 
-    private synchronized void setLocalSocksProxyPort(int port) {
-        mLocalSocksProxyPort = port;
+    private boolean isVpnMode() {
+        return mTunFd.get() != null;
     }
 
-    private synchronized void routeThroughTunnel() {
-        if (mRoutingThroughTunnel) {
+    private void setLocalSocksProxyPort(int port) {
+        mLocalSocksProxyPort.set(port);
+    }
+
+    private void routeThroughTunnel() {
+        if (!mRoutingThroughTunnel.compareAndSet(false, true)) {
             return;
         }
-        mRoutingThroughTunnel = true;
-        String socksServerAddress = "127.0.0.1:" + Integer.toString(mLocalSocksProxyPort);
+        String socksServerAddress = "127.0.0.1:" + Integer.toString(mLocalSocksProxyPort.get());
         String udpgwServerAddress = "127.0.0.1:" + Integer.toString(UDPGW_SERVER_PORT);
         startTun2Socks(
-                mTunFd,
+                mTunFd.get(),
                 VPN_INTERFACE_MTU,
                 mPrivateAddress.mRouter,
                 VPN_INTERFACE_NETMASK,
@@ -227,17 +243,17 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     }
 
     private void stopVpn() {
-        if (mTunFd != null) {
+        ParcelFileDescriptor tunFd = mTunFd.getAndSet(null);
+        if (tunFd != null) {
             try {
-                mTunFd.close();
+                tunFd.close();
             } catch (IOException e) {
             }
-            mTunFd = null;
         }
         waitStopTun2Socks();
-        mRoutingThroughTunnel = false;
+        mRoutingThroughTunnel.set(false);
     }
-    
+
     //----------------------------------------------------------------------------------------------
     // PsiphonProvider (Core support) interface implementation
     //----------------------------------------------------------------------------------------------
@@ -250,7 +266,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
     @Override
     public void BindToDevice(long fileDescriptor) throws Exception {
-        if (!mHostService.getVpnService().protect((int)fileDescriptor)) {
+        if (!((VpnService)mHostService.getVpnService()).protect((int)fileDescriptor)) {
             throw new Exception("protect socket failed");
         }
     }
@@ -270,17 +286,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     }
 
     @Override
-    public String GetDnsServer() {
+    public String GetPrimaryDnsServer() {
         String dnsResolver = null;
         try {
-            dnsResolver = getFirstActiveNetworkDnsResolver(mHostService.getVpnService());
+            dnsResolver = getFirstActiveNetworkDnsResolver(mHostService.getContext());
         } catch (Exception e) {
             mHostService.onDiagnosticMessage("failed to get active network DNS resolver: " + e.getMessage());
-            dnsResolver = DEFAULT_DNS_SERVER;
+            dnsResolver = DEFAULT_PRIMARY_DNS_SERVER;
         }
         return dnsResolver;
     }
 
+    @Override
+    public String GetSecondaryDnsServer() {
+        return DEFAULT_SECONDARY_DNS_SERVER;
+    }
+
     //----------------------------------------------------------------------------------------------
     // Psiphon Tunnel Core
     //----------------------------------------------------------------------------------------------
@@ -289,12 +310,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         stopPsiphon();
         mHostService.onDiagnosticMessage("starting Psiphon library");
         try {
-            boolean isVpnMode = (mTunFd != null);
             Psi.Start(
-                loadPsiphonConfig(mHostService.getContext(), isVpnMode),
-                embeddedServerEntries,
-                this,
-                isVpnMode);
+                    loadPsiphonConfig(mHostService.getContext()),
+                    embeddedServerEntries,
+                    this,
+                    isVpnMode());
         } catch (java.lang.Exception e) {
             throw new Exception("failed to start Psiphon library", e);
         }
@@ -307,13 +327,13 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         mHostService.onDiagnosticMessage("Psiphon library stopped");
     }
 
-    private String loadPsiphonConfig(Context context, boolean isVpnMode)
+    private String loadPsiphonConfig(Context context)
             throws IOException, JSONException {
 
         // Load settings from the raw resource JSON config file and
         // update as necessary. Then write JSON to disk for the Go client.
         JSONObject json = new JSONObject(mHostService.getPsiphonConfig());
-        
+
         // On Android, these directories must be set to the app private storage area.
         // The Psiphon library won't be able to use its current working directory
         // and the standard temporary directories do not exist.
@@ -327,11 +347,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         json.put("EstablishTunnelTimeoutSeconds", 0);
 
         // This parameter is for stats reporting
-        json.put("TunnelWholeDevice", isVpnMode ? 1 : 0);
+        json.put("TunnelWholeDevice", isVpnMode() ? 1 : 0);
 
         json.put("EmitBytesTransferred", true);
 
-        if (mLocalSocksProxyPort != 0) {
+        if (mLocalSocksProxyPort.get() != 0) {
             // When mLocalSocksProxyPort is set, tun2socks is already configured
             // to use that port value. So we force use of the same port.
             // A side-effect of this is that changing the SOCKS port preference
@@ -341,12 +361,16 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
         json.put("UseIndistinguishableTLS", true);
 
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+            json.put("UseTrustedCACertificatesForStockTLS", true);
+        }
+
         try {
             // Also enable indistinguishable TLS for HTTPS requests that
             // require system CAs.
             json.put(
-                "TrustedCACertificatesFilename",
-                setupTrustedCertificates(mHostService.getContext()));
+                    "TrustedCACertificatesFilename",
+                    setupTrustedCertificates(mHostService.getContext()));
         } catch (Exception e) {
             mHostService.onDiagnosticMessage(e.getMessage());
         }
@@ -359,14 +383,16 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             // All notices are sent on as diagnostic messages
             // except those that may contain private user data.
             boolean diagnostic = true;
-            
+
             JSONObject notice = new JSONObject(noticeJSON);
             String noticeType = notice.getString("noticeType");
-            
+
             if (noticeType.equals("Tunnels")) {
                 int count = notice.getJSONObject("data").getInt("count");
                 if (count > 0) {
-                    routeThroughTunnel();
+                    if (isVpnMode()) {
+                        routeThroughTunnel();
+                    }
                     mHostService.onConnected();
                 } else {
                     mHostService.onConnecting();
@@ -379,7 +405,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
                     regions.add(egressRegions.getString(i));
                 }
                 mHostService.onAvailableEgressRegions(regions);
-                
+
             } else if (noticeType.equals("SocksProxyPortInUse")) {
                 mHostService.onSocksProxyPortInUse(notice.getJSONObject("data").getInt("port"));
 
@@ -399,7 +425,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
                 mHostService.onUpstreamProxyError(notice.getJSONObject("data").getString("message"));
 
             } else if (noticeType.equals("ClientUpgradeDownloaded")) {
-                mHostService.onHomepage(notice.getJSONObject("data").getString("filename"));
+                mHostService.onClientUpgradeDownloaded(notice.getJSONObject("data").getString("filename"));
 
             } else if (noticeType.equals("Homepage")) {
                 mHostService.onHomepage(notice.getJSONObject("data").getString("url"));
@@ -459,8 +485,21 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             try {
                 output = new PrintStream(new FileOutputStream(file));
 
-                KeyStore keyStore = KeyStore.getInstance("AndroidCAStore");
-                keyStore.load(null, null);
+                KeyStore keyStore;
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+                    keyStore = KeyStore.getInstance("AndroidCAStore");
+                    keyStore.load(null, null);
+                } else {
+                    keyStore = KeyStore.getInstance("BKS");
+                    FileInputStream inputStream = new FileInputStream("/etc/security/cacerts.bks");
+                    try {
+                        keyStore.load(inputStream, "changeit".toCharArray());
+                    } finally {
+                        if (inputStream != null) {
+                            inputStream.close();
+                        }
+                    }
+                }
 
                 Enumeration<String> aliases = keyStore.aliases();
                 while (aliases.hasMoreElements()) {

+ 1 - 1
SampleApps/Psibot/app/src/main/java/ca/psiphon/psibot/Service.java

@@ -44,7 +44,7 @@ public class Service extends VpnService
 
     @Override
     public void onCreate() {
-        mPsiphonTunnel = PsiphonTunnel.newPsiphonVpn(this);
+        mPsiphonTunnel = PsiphonTunnel.newPsiphonTunnel(this);
         startForeground(R.string.foregroundServiceNotificationId, makeForegroundNotification());
         try {
             if (!mPsiphonTunnel.startRouting()) {

+ 9 - 0
SampleApps/TunneledWebView/.gitignore

@@ -0,0 +1,9 @@
+*.iml
+*.aar
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures

+ 1 - 0
SampleApps/TunneledWebView/.idea/.name

@@ -0,0 +1 @@
+TunneledWebView

+ 22 - 0
SampleApps/TunneledWebView/.idea/compiler.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <option name="DEFAULT_COMPILER" value="Javac" />
+    <resourceExtensions />
+    <wildcardResourcePatterns>
+      <entry name="!?*.java" />
+      <entry name="!?*.form" />
+      <entry name="!?*.class" />
+      <entry name="!?*.groovy" />
+      <entry name="!?*.scala" />
+      <entry name="!?*.flex" />
+      <entry name="!?*.kt" />
+      <entry name="!?*.clj" />
+    </wildcardResourcePatterns>
+    <annotationProcessing>
+      <profile default="true" name="Default" enabled="false">
+        <processorPath useClasspath="true" />
+      </profile>
+    </annotationProcessing>
+  </component>
+</project>

+ 3 - 0
SampleApps/TunneledWebView/.idea/copyright/profiles_settings.xml

@@ -0,0 +1,3 @@
+<component name="CopyrightManager">
+  <settings default="" />
+</component>

+ 19 - 0
SampleApps/TunneledWebView/.idea/gradle.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GradleSettings">
+    <option name="linkedExternalProjectsSettings">
+      <GradleProjectSettings>
+        <option name="distributionType" value="LOCAL" />
+        <option name="externalProjectPath" value="$PROJECT_DIR$" />
+        <option name="gradleHome" value="$APPLICATION_HOME_DIR$/gradle/gradle-2.4" />
+        <option name="gradleJvm" value="1.8" />
+        <option name="modules">
+          <set>
+            <option value="$PROJECT_DIR$" />
+            <option value="$PROJECT_DIR$/app" />
+          </set>
+        </option>
+      </GradleProjectSettings>
+    </option>
+  </component>
+</project>

+ 46 - 0
SampleApps/TunneledWebView/.idea/misc.xml

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="EntryPointsManager">
+    <entry_points version="2.0" />
+  </component>
+  <component name="NullableNotNullManager">
+    <option name="myDefaultNullable" value="android.support.annotation.Nullable" />
+    <option name="myDefaultNotNull" value="android.support.annotation.NonNull" />
+    <option name="myNullables">
+      <value>
+        <list size="4">
+          <item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
+          <item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
+          <item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
+          <item index="3" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
+        </list>
+      </value>
+    </option>
+    <option name="myNotNulls">
+      <value>
+        <list size="4">
+          <item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
+          <item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
+          <item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
+          <item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
+        </list>
+      </value>
+    </option>
+  </component>
+  <component name="ProjectLevelVcsManager" settingsEditedManually="false">
+    <OptionsSetting value="true" id="Add" />
+    <OptionsSetting value="true" id="Remove" />
+    <OptionsSetting value="true" id="Checkout" />
+    <OptionsSetting value="true" id="Update" />
+    <OptionsSetting value="true" id="Status" />
+    <OptionsSetting value="true" id="Edit" />
+    <ConfirmationsSetting value="0" id="Add" />
+    <ConfirmationsSetting value="0" id="Remove" />
+  </component>
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" assert-keyword="true" jdk-15="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
+    <output url="file://$PROJECT_DIR$/build/classes" />
+  </component>
+  <component name="ProjectType">
+    <option name="id" value="Android" />
+  </component>
+</project>

+ 9 - 0
SampleApps/TunneledWebView/.idea/modules.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/TunneledWebView.iml" filepath="$PROJECT_DIR$/TunneledWebView.iml" />
+      <module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
+    </modules>
+  </component>
+</project>

+ 12 - 0
SampleApps/TunneledWebView/.idea/runConfigurations.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RunConfigurationProducerService">
+    <option name="ignoredProducers">
+      <set>
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
+        <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
+      </set>
+    </option>
+  </component>
+</project>

+ 6 - 0
SampleApps/TunneledWebView/.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="" />
+  </component>
+</project>

+ 159 - 0
SampleApps/TunneledWebView/README.md

@@ -0,0 +1,159 @@
+TunneledWebView README
+================================================================================
+
+Overview
+--------------------------------------------------------------------------------
+
+TunneledWebView is a sample app that demonstrates embedding the Psiphon Library in
+an Android app. TunneledWebView proxies a WebView through the Psiphon tunnel.
+
+Integration
+--------------------------------------------------------------------------------
+
+Uses the [Psiphon Android Library](../AndroidLibrary/README.md).
+
+Integration is illustrated in the main activity source file in the sample app. Here are the key parts.
+
+```Java
+
+/*
+ * Copyright (c) 2016, Psiphon Inc.
+ * All rights reserved.
+ */
+ 
+package ca.psiphon.tunneledwebview;
+
+// ...
+
+import ca.psiphon.PsiphonTunnel;
+
+//----------------------------------------------------------------------------------------------
+// TunneledWebView
+//
+// This sample app demonstrates tunneling a WebView through the
+// Psiphon Library. This app's main activity shows a log of
+// events and a WebView that is loaded once Psiphon is connected.
+//
+// The flow is as follows:
+//
+// - The Psiphon tunnel is started in onResume(). PsiphonTunnel.start()
+//   is an asynchronous call that returns immediately.
+//
+// - Once Psiphon has selected a local HTTP proxy listening port, the
+//   onListeningHttpProxyPort() callback is called. This app records the
+//   port to use for tunneling traffic.
+//
+// - Once Psiphon has established a tunnel, the onConnected() callback
+//   is called. This app now loads the WebView, after setting its proxy
+//   to point to Psiphon's local HTTP proxy.
+//
+// To adapt this sample into your own app:
+//
+// - Embed a Psiphon config file in app/src/main/res/raw/psiphon_config.
+//
+// - Add the Psiphon Library AAR module as a dependency (see this app's
+//   project settings; to build this sample project, you need to drop
+//   psi-0.0.10.aar into app/libs).
+//
+// - Use app/src/main/java/ca/psiphon/PsiphonTunnel.java, which provides
+//   a higher-level wrapper around the Psiphon Library module. This file
+//   shows how to use PsiphonTunnel and PsiphonTunnel.TunneledApp.
+//
+//----------------------------------------------------------------------------------------------
+
+public class MainActivity extends ActionBarActivity
+        implements PsiphonTunnel.TunneledApp {
+
+// ...
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+
+        // ...
+
+        mPsiphonTunnel = PsiphonTunnel.newPsiphonTunnel(this);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+
+        // NOTE: for demonstration purposes, this sample app
+        // restarts Psiphon in onPause/onResume. Since it may take some
+        // time to connect, it's generally recommended to keep
+        // Psiphon running, so start/stop in onCreate/onDestroy or
+        // even consider running a background Service.
+
+        if (!mPsiphonTunnel.start("")) {
+            logMessage("failed to start Psiphon");
+        }
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+
+        // NOTE: stop() can block for a few seconds, so it's generally
+        // recommended to run PsiphonTunnel.start()/stop() in a background
+        // thread and signal the thread appropriately.
+
+        mPsiphonTunnel.stop();
+    }
+
+    private void setHttpProxyPort(int port) {
+
+        // NOTE: here we record the Psiphon proxy port for subsequent
+        // use in tunneling app traffic. In this sample app, we will
+        // use WebViewProxySettings.setLocalProxy to tunnel a WebView
+        // through Psiphon. By default, the local proxy port is selected
+        // dynamically, so it's important to record and use the correct
+        // port number.
+
+        mLocalHttpProxyPort.set(port);
+    }
+
+    private void loadWebView() {
+
+        // NOTE: functions called via PsiphonTunnel.TunneledApp may be
+        // called on background threads. It's important to ensure that
+        // these threads are not blocked and that UI functions are not
+        // called directly from these threads. Here we use runOnUiThread
+        // to handle this.
+
+        runOnUiThread(new Runnable() {
+            public void run() {
+                WebViewProxySettings.setLocalProxy(
+                        MainActivity.this, mLocalHttpProxyPort.get());
+                mWebView.loadUrl("https://ipinfo.io/");
+            }
+        });
+    }
+
+    // ...
+
+    //----------------------------------------------------------------------------------------------
+    // PsiphonTunnel.TunneledApp implementation
+    //
+    // NOTE: these are callbacks from the Psiphon Library
+    //----------------------------------------------------------------------------------------------
+
+    // ...
+
+    @Override
+    public void onListeningSocksProxyPort(int port) {
+        logMessage("local SOCKS proxy listening on port: " + Integer.toString(port));
+    }
+
+    // ...
+
+    @Override
+    public void onConnected() {
+        logMessage("connected");
+        loadWebView();
+    }
+
+    // ...
+
+}
+
+```

+ 1 - 0
SampleApps/TunneledWebView/app/.gitignore

@@ -0,0 +1 @@
+/build

+ 33 - 0
SampleApps/TunneledWebView/app/build.gradle

@@ -0,0 +1,33 @@
+apply plugin: 'com.android.application'
+
+android {
+    compileSdkVersion 21
+    buildToolsVersion "21.1.2"
+
+    defaultConfig {
+        applicationId "ca.psiphon.tunneledwebview"
+        minSdkVersion 15
+        targetSdkVersion 21
+        versionCode 1
+        versionName "1.0"
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+}
+
+repositories {
+    flatDir {
+        dirs 'libs'
+    }
+}
+
+dependencies {
+    compile fileTree(dir: 'libs', include: ['*.jar'])
+    testCompile 'junit:junit:4.12'
+    compile 'com.android.support:appcompat-v7:21.0.3'
+    compile 'go.psi:psi:0.0.10@aar'
+}

+ 17 - 0
SampleApps/TunneledWebView/app/proguard-rules.pro

@@ -0,0 +1,17 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /Users/user/Code/AndroidStudioSDK/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}

+ 13 - 0
SampleApps/TunneledWebView/app/src/androidTest/java/ca/psiphon/tunneledwebview/ApplicationTest.java

@@ -0,0 +1,13 @@
+package ca.psiphon.tunneledwebview;
+
+import android.app.Application;
+import android.test.ApplicationTestCase;
+
+/**
+ * <a href="http://d.android.com/tools/testing/testing_android.html">Testing Fundamentals</a>
+ */
+public class ApplicationTest extends ApplicationTestCase<Application> {
+    public ApplicationTest() {
+        super(Application.class);
+    }
+}

+ 24 - 0
SampleApps/TunneledWebView/app/src/main/AndroidManifest.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="ca.psiphon.tunneledwebview">
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/AppTheme">
+        <activity android:name=".MainActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+    </application>
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+</manifest>

+ 384 - 0
SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/PsiphonTunnel.java

@@ -0,0 +1,384 @@
+/*
+ * Copyright (c) 2016, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package ca.psiphon;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Build;
+import android.util.Base64;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import go.psi.Psi;
+
+public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
+
+    public interface TunneledApp {
+        Context getContext();
+        String getPsiphonConfig();
+        void onDiagnosticMessage(String message);
+        void onAvailableEgressRegions(List<String> regions);
+        void onSocksProxyPortInUse(int port);
+        void onHttpProxyPortInUse(int port);
+        void onListeningSocksProxyPort(int port);
+        void onListeningHttpProxyPort(int port);
+        void onUpstreamProxyError(String message);
+        void onConnecting();
+        void onConnected();
+        void onHomepage(String url);
+        void onClientRegion(String region);
+        void onClientUpgradeDownloaded(String filename);
+        void onSplitTunnelRegion(String region);
+        void onUntunneledAddress(String address);
+        void onBytesTransferred(long sent, long received);
+        void onStartedWaitingForNetworkConnectivity();
+    }
+
+    private final TunneledApp mTunneledApp;
+    private AtomicBoolean mIsWaitingForNetworkConnectivity;
+
+    // Only one PsiphonVpn instance may exist at a time, as the underlying
+    // go.psi.Psi contains global state.
+    private static PsiphonTunnel mPsiphonTunnel;
+
+    public static synchronized PsiphonTunnel newPsiphonTunnel(TunneledApp tunneledApp) {
+        if (mPsiphonTunnel != null) {
+            mPsiphonTunnel.stop();
+        }
+        // Load the native go code embedded in psi.aar
+        System.loadLibrary("gojni");
+        mPsiphonTunnel = new PsiphonTunnel(tunneledApp);
+        return mPsiphonTunnel;
+    }
+
+    private PsiphonTunnel(TunneledApp tunneledApp) {
+        mTunneledApp = tunneledApp;
+        mIsWaitingForNetworkConnectivity = new AtomicBoolean(false);
+    }
+
+    public Object clone() throws CloneNotSupportedException {
+        throw new CloneNotSupportedException();
+    }
+
+    //----------------------------------------------------------------------------------------------
+    // Public API
+    //----------------------------------------------------------------------------------------------
+
+    public synchronized boolean start(String embeddedServerEntries) {
+        return startPsiphon(embeddedServerEntries);
+    }
+
+    public synchronized void stop() {
+        stopPsiphon();
+    }
+
+    //----------------------------------------------------------------------------------------------
+    // PsiphonProvider (Core support) interface implementation
+    //----------------------------------------------------------------------------------------------
+
+    @Override
+    public void Notice(String noticeJSON) {
+        handlePsiphonNotice(noticeJSON);
+    }
+
+    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Override
+    public void BindToDevice(long fileDescriptor) throws Exception {
+        // This PsiphonProvider function is only called in TunnelWholeDevice mode
+        throw new Exception("BindToDevice not supported");
+    }
+
+    @Override
+    public long HasNetworkConnectivity() {
+        boolean hasConnectivity = hasNetworkConnectivity(mTunneledApp.getContext());
+        boolean wasWaitingForNetworkConnectivity = mIsWaitingForNetworkConnectivity.getAndSet(!hasConnectivity);
+        if (!hasConnectivity && !wasWaitingForNetworkConnectivity) {
+            // HasNetworkConnectivity may be called many times, but only call
+            // onStartedWaitingForNetworkConnectivity once per loss of connectivity,
+            // so the HostService may log a single message.
+            mTunneledApp.onStartedWaitingForNetworkConnectivity();
+        }
+        // TODO: change to bool return value once gobind supports that type
+        return hasConnectivity ? 1 : 0;
+    }
+
+    private static boolean hasNetworkConnectivity(Context context) {
+        ConnectivityManager connectivityManager =
+                (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
+        NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
+        return networkInfo != null && networkInfo.isConnected();
+    }
+
+    @Override
+    public String GetPrimaryDnsServer() {
+        // This PsiphonProvider function is only called in TunnelWholeDevice mode
+        return "";
+    }
+
+    @Override
+    public String GetSecondaryDnsServer() {
+        // This PsiphonProvider function is only called in TunnelWholeDevice mode
+        return "";
+    }
+
+    //----------------------------------------------------------------------------------------------
+    // Psiphon Tunnel Core
+    //----------------------------------------------------------------------------------------------
+
+    private boolean startPsiphon(String embeddedServerEntries) {
+        stopPsiphon();
+        mTunneledApp.onDiagnosticMessage("starting Psiphon library");
+        try {
+            Psi.Start(
+                    loadPsiphonConfig(mTunneledApp.getContext()),
+                    embeddedServerEntries,
+                    this,
+                    false);
+        } catch (java.lang.Exception e) {
+            mTunneledApp.onDiagnosticMessage("failed to start Psiphon library: " + e.getMessage());
+            return false;
+        }
+        mTunneledApp.onDiagnosticMessage("Psiphon library started");
+        return true;
+    }
+
+    private void stopPsiphon() {
+        mTunneledApp.onDiagnosticMessage("stopping Psiphon library");
+        Psi.Stop();
+        mTunneledApp.onDiagnosticMessage("Psiphon library stopped");
+    }
+
+    private String loadPsiphonConfig(Context context)
+            throws IOException, JSONException {
+
+        // Load settings from the raw resource JSON config file and
+        // update as necessary. Then write JSON to disk for the Go client.
+        JSONObject json = new JSONObject(mTunneledApp.getPsiphonConfig());
+
+        // On Android, these directories must be set to the app private storage area.
+        // The Psiphon library won't be able to use its current working directory
+        // and the standard temporary directories do not exist.
+        json.put("DataStoreDirectory", context.getFilesDir());
+        json.put("DataStoreTempDirectory", context.getCacheDir());
+
+        // Note: onConnecting/onConnected logic assumes 1 tunnel connection
+        json.put("TunnelPoolSize", 1);
+
+        // Continue to run indefinitely until connected
+        json.put("EstablishTunnelTimeoutSeconds", 0);
+
+        // This parameter is for stats reporting
+        json.put("TunnelWholeDevice", 0);
+
+        json.put("EmitBytesTransferred", true);
+
+        json.put("UseIndistinguishableTLS", true);
+
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+            json.put("UseTrustedCACertificatesForStockTLS", true);
+        }
+
+        try {
+            // Also enable indistinguishable TLS for HTTPS requests that
+            // require system CAs.
+            json.put(
+                    "TrustedCACertificatesFilename",
+                    setupTrustedCertificates(mTunneledApp.getContext()));
+        } catch (Exception e) {
+            mTunneledApp.onDiagnosticMessage(e.getMessage());
+        }
+
+        return json.toString();
+    }
+
+    private void handlePsiphonNotice(String noticeJSON) {
+        try {
+            // All notices are sent on as diagnostic messages
+            // except those that may contain private user data.
+            boolean diagnostic = true;
+
+            JSONObject notice = new JSONObject(noticeJSON);
+            String noticeType = notice.getString("noticeType");
+
+            if (noticeType.equals("Tunnels")) {
+                int count = notice.getJSONObject("data").getInt("count");
+                if (count > 0) {
+                    mTunneledApp.onConnected();
+                } else {
+                    mTunneledApp.onConnecting();
+                }
+
+            } else if (noticeType.equals("AvailableEgressRegions")) {
+                JSONArray egressRegions = notice.getJSONObject("data").getJSONArray("regions");
+                ArrayList<String> regions = new ArrayList<String>();
+                for (int i=0; i<egressRegions.length(); i++) {
+                    regions.add(egressRegions.getString(i));
+                }
+                mTunneledApp.onAvailableEgressRegions(regions);
+
+            } else if (noticeType.equals("SocksProxyPortInUse")) {
+                mTunneledApp.onSocksProxyPortInUse(notice.getJSONObject("data").getInt("port"));
+
+            } else if (noticeType.equals("HttpProxyPortInUse")) {
+                mTunneledApp.onHttpProxyPortInUse(notice.getJSONObject("data").getInt("port"));
+
+            } else if (noticeType.equals("ListeningSocksProxyPort")) {
+                int port = notice.getJSONObject("data").getInt("port");
+                mTunneledApp.onListeningSocksProxyPort(port);
+
+            } else if (noticeType.equals("ListeningHttpProxyPort")) {
+                int port = notice.getJSONObject("data").getInt("port");
+                mTunneledApp.onListeningHttpProxyPort(port);
+
+            } else if (noticeType.equals("UpstreamProxyError")) {
+                mTunneledApp.onUpstreamProxyError(notice.getJSONObject("data").getString("message"));
+
+            } else if (noticeType.equals("ClientUpgradeDownloaded")) {
+                mTunneledApp.onClientUpgradeDownloaded(notice.getJSONObject("data").getString("filename"));
+
+            } else if (noticeType.equals("Homepage")) {
+                mTunneledApp.onHomepage(notice.getJSONObject("data").getString("url"));
+
+            } else if (noticeType.equals("ClientRegion")) {
+                mTunneledApp.onClientRegion(notice.getJSONObject("data").getString("region"));
+
+            } else if (noticeType.equals("SplitTunnelRegion")) {
+                mTunneledApp.onSplitTunnelRegion(notice.getJSONObject("data").getString("region"));
+
+            } else if (noticeType.equals("UntunneledAddress")) {
+                mTunneledApp.onUntunneledAddress(notice.getJSONObject("data").getString("address"));
+
+            } else if (noticeType.equals("BytesTransferred")) {
+                diagnostic = false;
+                JSONObject data = notice.getJSONObject("data");
+                mTunneledApp.onBytesTransferred(data.getLong("sent"), data.getLong("received"));
+            }
+
+            if (diagnostic) {
+                String diagnosticMessage = noticeType + ": " + notice.getJSONObject("data").toString();
+                mTunneledApp.onDiagnosticMessage(diagnosticMessage);
+            }
+
+        } catch (JSONException e) {
+            // Ignore notice
+        }
+    }
+
+    private String setupTrustedCertificates(Context context) throws Exception {
+
+        // Copy the Android system CA store to a local, private cert bundle file.
+        //
+        // This results in a file that can be passed to SSL_CTX_load_verify_locations
+        // for use with OpenSSL modes in tunnel-core.
+        // https://www.openssl.org/docs/manmaster/ssl/SSL_CTX_load_verify_locations.html
+        //
+        // TODO: to use the path mode of load_verify_locations would require emulating
+        // the filename scheme used by c_rehash:
+        // https://www.openssl.org/docs/manmaster/apps/c_rehash.html
+        // http://stackoverflow.com/questions/19237167/the-new-subject-hash-openssl-algorithm-differs
+
+        File directory = context.getDir("PsiphonCAStore", Context.MODE_PRIVATE);
+
+        final String errorMessage = "copy AndroidCAStore failed";
+        try {
+
+            File file = new File(directory, "certs.dat");
+
+            // Pave a fresh copy on every run, which ensures we're not using old certs.
+            // Note: assumes KeyStore doesn't return revoked certs.
+            //
+            // TODO: this takes under 1 second, but should we avoid repaving every time?
+            file.delete();
+
+            PrintStream output = null;
+            try {
+                output = new PrintStream(new FileOutputStream(file));
+
+                KeyStore keyStore;
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+                    keyStore = KeyStore.getInstance("AndroidCAStore");
+                    keyStore.load(null, null);
+                } else {
+                    keyStore = KeyStore.getInstance("BKS");
+                    FileInputStream inputStream = new FileInputStream("/etc/security/cacerts.bks");
+                    try {
+                        keyStore.load(inputStream, "changeit".toCharArray());
+                    } finally {
+                        if (inputStream != null) {
+                            inputStream.close();
+                        }
+                    }
+                }
+
+                Enumeration<String> aliases = keyStore.aliases();
+                while (aliases.hasMoreElements()) {
+                    String alias = aliases.nextElement();
+                    X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
+
+                    output.println("-----BEGIN CERTIFICATE-----");
+                    String pemCert = new String(Base64.encode(cert.getEncoded(), Base64.NO_WRAP), "UTF-8");
+                    // OpenSSL appears to reject the default linebreaking done by Base64.encode,
+                    // so we manually linebreak every 64 characters
+                    for (int i = 0; i < pemCert.length() ; i+= 64) {
+                        output.println(pemCert.substring(i, Math.min(i + 64, pemCert.length())));
+                    }
+                    output.println("-----END CERTIFICATE-----");
+                }
+
+                mTunneledApp.onDiagnosticMessage("prepared PsiphonCAStore");
+
+                return file.getAbsolutePath();
+
+            } finally {
+                if (output != null) {
+                    output.close();
+                }
+            }
+
+        } catch (KeyStoreException e) {
+            throw new Exception(errorMessage, e);
+        } catch (NoSuchAlgorithmException e) {
+            throw new Exception(errorMessage, e);
+        } catch (CertificateException e) {
+            throw new Exception(errorMessage, e);
+        } catch (IOException e) {
+            throw new Exception(errorMessage, e);
+        }
+    }
+}

+ 296 - 0
SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/MainActivity.java

@@ -0,0 +1,296 @@
+/*
+ * Copyright (c) 2016, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package ca.psiphon.tunneledwebview;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.webkit.WebView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import ca.psiphon.PsiphonTunnel;
+
+//----------------------------------------------------------------------------------------------
+// TunneledWebView
+//
+// This sample app demonstrates tunneling a WebView through the
+// Psiphon Library. This app's main activity shows a log of
+// events and a WebView that is loaded once Psiphon is connected.
+//
+// The flow is as follows:
+//
+// - The Psiphon tunnel is started in onResume(). PsiphonTunnel.start()
+//   is an asynchronous call that returns immediately.
+//
+// - Once Psiphon has selected a local HTTP proxy listening port, the
+//   onListeningHttpProxyPort() callback is called. This app records the
+//   port to use for tunneling traffic.
+//
+// - Once Psiphon has established a tunnel, the onConnected() callback
+//   is called. This app now loads the WebView, after setting its proxy
+//   to point to Psiphon's local HTTP proxy.
+//
+// To adapt this sample into your own app:
+//
+// - Embed a Psiphon config file in app/src/main/res/raw/psiphon_config.
+//
+// - Add the Psiphon Library AAR module as a dependency (see this app's
+//   project settings; to build this sample project, you need to drop
+//   psi-0.0.10.aar into app/libs).
+//
+// - Use app/src/main/java/ca/psiphon/PsiphonTunnel.java, which provides
+//   a higher-level wrapper around the Psiphon Library module. This file
+//   shows how to use PsiphonTunnel and PsiphonTunnel.TunneledApp.
+//
+//----------------------------------------------------------------------------------------------
+
+public class MainActivity extends ActionBarActivity
+        implements PsiphonTunnel.TunneledApp {
+
+    private ListView mListView;
+    private WebView mWebView;
+
+    private ArrayAdapter<String> mLogMessages;
+    private AtomicInteger mLocalHttpProxyPort;
+
+    private PsiphonTunnel mPsiphonTunnel;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        mListView = (ListView)findViewById(R.id.listView);
+        mWebView = (WebView)findViewById(R.id.webView);
+
+        mLogMessages = new ArrayAdapter<String>(
+                this, R.layout.log_message, R.id.logMessageTextView);
+
+        mListView.setAdapter(mLogMessages);
+
+        mLocalHttpProxyPort = new AtomicInteger(0);
+
+        mPsiphonTunnel = PsiphonTunnel.newPsiphonTunnel(this);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+
+        // NOTE: for demonstration purposes, this sample app
+        // restarts Psiphon in onPause/onResume. Since it may take some
+        // time to connect, it's generally recommended to keep
+        // Psiphon running, so start/stop in onCreate/onDestroy or
+        // even consider running a background Service.
+
+        if (!mPsiphonTunnel.start("")) {
+            logMessage("failed to start Psiphon");
+        }
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+
+        // NOTE: stop() can block for a few seconds, so it's generally
+        // recommended to run PsiphonTunnel.start()/stop() in a background
+        // thread and signal the thread appropriately.
+
+        mPsiphonTunnel.stop();
+    }
+
+    private void setHttpProxyPort(int port) {
+
+        // NOTE: here we record the Psiphon proxy port for subsequent
+        // use in tunneling app traffic. In this sample app, we will
+        // use WebViewProxySettings.setLocalProxy to tunnel a WebView
+        // through Psiphon. By default, the local proxy port is selected
+        // dynamically, so it's important to record and use the correct
+        // port number.
+
+        mLocalHttpProxyPort.set(port);
+    }
+
+    private void loadWebView() {
+
+        // NOTE: functions called via PsiphonTunnel.TunneledApp may be
+        // called on background threads. It's important to ensure that
+        // these threads are not blocked and that UI functions are not
+        // called directly from these threads. Here we use runOnUiThread
+        // to handle this.
+
+        runOnUiThread(new Runnable() {
+            public void run() {
+                WebViewProxySettings.setLocalProxy(
+                        MainActivity.this, mLocalHttpProxyPort.get());
+                mWebView.loadUrl("https://ipinfo.io/");
+            }
+        });
+    }
+
+    private void logMessage(final String message) {
+        runOnUiThread(new Runnable() {
+            public void run() {
+                mLogMessages.add(message);
+                mListView.setSelection(mLogMessages.getCount() - 1);
+            }
+        });
+    }
+
+    //----------------------------------------------------------------------------------------------
+    // PsiphonTunnel.TunneledApp implementation
+    //
+    // NOTE: these are callbacks from the Psiphon Library
+    //----------------------------------------------------------------------------------------------
+
+    @Override
+    public Context getContext() {
+        return this;
+    }
+
+    @Override
+    public String getPsiphonConfig() {
+        try {
+            JSONObject config = new JSONObject(
+                    readInputStreamToString(
+                            getResources().openRawResource(R.raw.psiphon_config)));
+
+            return config.toString();
+
+        } catch (IOException e) {
+            logMessage("error loading Psiphon config: " + e.getMessage());
+        } catch (JSONException e) {
+            logMessage("error loading Psiphon config: " + e.getMessage());
+        }
+        return "";
+    }
+
+    @Override
+    public void onDiagnosticMessage(String message) {
+        android.util.Log.i(getString(R.string.app_name), message);
+        logMessage(message);
+    }
+
+    @Override
+    public void onAvailableEgressRegions(List<String> regions) {
+        for (String region : regions) {
+            logMessage("available egress region: " + region);
+        }
+    }
+
+    @Override
+    public void onSocksProxyPortInUse(int port) {
+        logMessage("local SOCKS proxy port in use: " + Integer.toString(port));
+    }
+
+    @Override
+    public void onHttpProxyPortInUse(int port) {
+        logMessage("local HTTP proxy port in use: " + Integer.toString(port));
+    }
+
+    @Override
+    public void onListeningSocksProxyPort(int port) {
+        logMessage("local SOCKS proxy listening on port: " + Integer.toString(port));
+    }
+
+    @Override
+    public void onListeningHttpProxyPort(int port) {
+        logMessage("local HTTP proxy listening on port: " + Integer.toString(port));
+        setHttpProxyPort(port);
+    }
+
+    @Override
+    public void onUpstreamProxyError(String message) {
+        logMessage("upstream proxy error: " + message);
+    }
+
+    @Override
+    public void onConnecting() {
+        logMessage("connecting...");
+    }
+
+    @Override
+    public void onConnected() {
+        logMessage("connected");
+        loadWebView();
+    }
+
+    @Override
+    public void onHomepage(String url) {
+        logMessage("home page: " + url);
+    }
+
+    @Override
+    public void onClientUpgradeDownloaded(String filename) {
+        logMessage("client upgrade downloaded");
+    }
+
+    @Override
+    public void onSplitTunnelRegion(String region) {
+        logMessage("split tunnel region: " + region);
+    }
+
+    @Override
+    public void onUntunneledAddress(String address) {
+        logMessage("untunneled address: " + address);
+    }
+
+    @Override
+    public void onBytesTransferred(long sent, long received) {
+        logMessage("bytes sent: " + Long.toString(sent));
+        logMessage("bytes received: " + Long.toString(received));
+    }
+
+    @Override
+    public void onStartedWaitingForNetworkConnectivity() {
+        logMessage("waiting for network connectivity...");
+    }
+
+    @Override
+    public void onClientRegion(String region) {
+        logMessage("client region: " + region);
+    }
+
+    private static String readInputStreamToString(InputStream inputStream) throws IOException {
+        return new String(readInputStreamToBytes(inputStream), "UTF-8");
+    }
+
+    private static byte[] readInputStreamToBytes(InputStream inputStream) throws IOException {
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        int readCount;
+        byte[] buffer = new byte[16384];
+        while ((readCount = inputStream.read(buffer, 0, buffer.length)) != -1) {
+            outputStream.write(buffer, 0, readCount);
+        }
+        outputStream.flush();
+        inputStream.close();
+        return outputStream.toByteArray();
+    }
+}

+ 313 - 0
SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/tunneledwebview/WebViewProxySettings.java

@@ -0,0 +1,313 @@
+/*
+ * Copyright (c) 2013, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ * 
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package ca.psiphon.tunneledwebview;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Proxy;
+import android.os.Build;
+import android.os.Parcelable;
+import android.util.ArrayMap;
+
+import org.apache.http.HttpHost;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class WebViewProxySettings 
+{
+
+    public static void setLocalProxy(Context ctx, int port)
+    {
+        setProxy(ctx, "localhost", port);
+    }
+    
+    /* 
+    Proxy setting code taken directly from Orweb, with some modifications.
+    (...And some of the Orweb code was taken from an earlier version of our code.)
+    See: https://github.com/guardianproject/Orweb/blob/master/src/org/torproject/android/OrbotHelper.java#L39
+    Note that we tried and abandoned doing feature detection by trying the 
+    newer (>= ICS) proxy setting, catching, and then failing over to the older
+    approach. The problem was that on Android 3.0, an exception would be thrown
+    *in another thread*, so we couldn't catch it and the whole app would force-close.
+    Orweb has always been doing an explicit version check, and it seems to work,
+    so we're so going to switch to that approach.
+    */
+    public static boolean setProxy (Context ctx, String host, int port)
+    {
+        boolean worked = false;
+
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+        {
+            worked = setWebkitProxyGingerbread(ctx, host, port);
+        }
+        else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT)
+        {
+            worked = setWebkitProxyICS(ctx, host, port);
+        }
+        else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT)
+        {
+            worked = setWebkitProxyKitKat(ctx.getApplicationContext(), host, port);
+        }
+        else
+        {
+            worked = setWebkitProxyLollipop(ctx.getApplicationContext(), host, port);
+        }
+        
+        return worked;
+    }
+
+    private static boolean setWebkitProxyGingerbread(Context ctx, String host, int port)
+    {
+        try
+        {
+            Object requestQueueObject = getRequestQueue(ctx);
+            if (requestQueueObject != null) {
+                //Create Proxy config object and set it into request Q
+                HttpHost httpHost = new HttpHost(host, port, "http");   
+                setDeclaredField(requestQueueObject, "mProxyHost", httpHost);
+                
+                return true;
+            }
+        }
+        catch (Throwable e)
+        {
+            // Failed. Fall through to false return.
+        }
+        
+        return false;
+    }
+    
+    @SuppressWarnings("rawtypes")
+    private static boolean setWebkitProxyICS(Context ctx, String host, int port)
+    {
+        try 
+        {
+            Class webViewCoreClass = Class.forName("android.webkit.WebViewCore");
+           
+            Class proxyPropertiesClass = Class.forName("android.net.ProxyProperties");
+            if (webViewCoreClass != null && proxyPropertiesClass != null) 
+            {
+                Method m = webViewCoreClass.getDeclaredMethod("sendStaticMessage", Integer.TYPE, Object.class);
+                Constructor c = proxyPropertiesClass.getConstructor(String.class, Integer.TYPE, String.class);
+                
+                if (m != null && c != null)
+                {
+                    m.setAccessible(true);
+                    c.setAccessible(true);
+                    Object properties = c.newInstance(host, port, null);
+                
+                    // android.webkit.WebViewCore.EventHub.PROXY_CHANGED = 193;
+                    m.invoke(null, 193, properties);
+                    return true;
+                }
+            }
+        }
+        catch (Exception e) 
+        {
+        }
+        catch (Error e) 
+        {
+        }
+        
+        return false;
+    }
+
+    // http://stackoverflow.com/questions/19979578/android-webview-set-proxy-programatically-kitkat
+    // http://src.chromium.org/viewvc/chrome/trunk/src/net/android/java/src/org/chromium/net/ProxyChangeListener.java
+    @TargetApi(Build.VERSION_CODES.KITKAT)
+    @SuppressWarnings("rawtypes")
+    private static boolean setWebkitProxyKitKat(Context appContext, String host, int port)
+    {
+        System.setProperty("http.proxyHost", host);
+        System.setProperty("http.proxyPort", port + "");
+        System.setProperty("https.proxyHost", host);
+        System.setProperty("https.proxyPort", port + "");
+        try
+        {
+            Class applicationClass = Class.forName("android.app.Application");
+            Field loadedApkField = applicationClass.getDeclaredField("mLoadedApk");
+            loadedApkField.setAccessible(true);
+            Object loadedApk = loadedApkField.get(appContext);
+            Class loadedApkClass = Class.forName("android.app.LoadedApk");
+            Field receiversField = loadedApkClass.getDeclaredField("mReceivers");
+            receiversField.setAccessible(true);
+            ArrayMap receivers = (ArrayMap) receiversField.get(loadedApk);
+            for (Object receiverMap : receivers.values())
+            {
+                for (Object receiver : ((ArrayMap) receiverMap).keySet())
+                {
+                    Class receiverClass = receiver.getClass();
+                    if (receiverClass.getName().contains("ProxyChangeListener"))
+                    {
+                        Method onReceiveMethod = receiverClass.getDeclaredMethod("onReceive", Context.class, Intent.class);
+                        Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
+
+                        final String CLASS_NAME = "android.net.ProxyProperties";
+                        Class proxyPropertiesClass = Class.forName(CLASS_NAME);
+                        Constructor constructor = proxyPropertiesClass.getConstructor(String.class, Integer.TYPE, String.class);
+                        constructor.setAccessible(true);
+                        Object proxyProperties = constructor.newInstance(host, port, null);
+                        intent.putExtra("proxy", (Parcelable) proxyProperties);
+
+                        onReceiveMethod.invoke(receiver, appContext, intent);
+                    }
+                }
+            }
+            return true;
+        }
+        catch (ClassNotFoundException e)
+        {
+        }
+        catch (NoSuchFieldException e)
+        {
+        }
+        catch (IllegalAccessException e)
+        {
+        }
+        catch (IllegalArgumentException e)
+        {
+        }
+        catch (NoSuchMethodException e)
+        {
+        }
+        catch (InvocationTargetException e)
+        {
+        }
+        catch (InstantiationException e)
+        {
+        }
+        return false;
+    }
+
+    // http://stackanswers.com/questions/25272393/android-webview-set-proxy-programmatically-on-android-l
+    @TargetApi(Build.VERSION_CODES.KITKAT) // for android.util.ArrayMap methods
+    @SuppressWarnings("rawtypes")
+    private static boolean setWebkitProxyLollipop(Context appContext, String host, int port)
+    {
+        System.setProperty("http.proxyHost", host);
+        System.setProperty("http.proxyPort", port + "");
+        System.setProperty("https.proxyHost", host);
+        System.setProperty("https.proxyPort", port + "");
+        try {
+            Class applictionClass = Class.forName("android.app.Application");
+            Field mLoadedApkField = applictionClass.getDeclaredField("mLoadedApk");
+            mLoadedApkField.setAccessible(true);
+            Object mloadedApk = mLoadedApkField.get(appContext);
+            Class loadedApkClass = Class.forName("android.app.LoadedApk");
+            Field mReceiversField = loadedApkClass.getDeclaredField("mReceivers");
+            mReceiversField.setAccessible(true);
+            ArrayMap receivers = (ArrayMap) mReceiversField.get(mloadedApk);
+            for (Object receiverMap : receivers.values())
+            {
+                for (Object receiver : ((ArrayMap) receiverMap).keySet())
+                {
+                    Class clazz = receiver.getClass();
+                    if (clazz.getName().contains("ProxyChangeListener"))
+                    {
+                        Method onReceiveMethod = clazz.getDeclaredMethod("onReceive", Context.class, Intent.class);
+                        Intent intent = new Intent(Proxy.PROXY_CHANGE_ACTION);
+                        onReceiveMethod.invoke(receiver, appContext, intent);
+                    }
+                }
+            }
+            return true;
+        }
+        catch (ClassNotFoundException e)
+        {
+        }
+        catch (NoSuchFieldException e)
+        {
+        }
+        catch (IllegalAccessException e)
+        {
+        }
+        catch (NoSuchMethodException e)
+        {
+        }
+        catch (InvocationTargetException e)
+        {
+        }
+        return false;
+     }
+    
+    @SuppressWarnings("rawtypes")
+    private static Object GetNetworkInstance(Context ctx) throws ClassNotFoundException
+    {
+        Class networkClass = Class.forName("android.webkit.Network");
+        return networkClass;
+    }
+    
+    private static Object getRequestQueue(Context ctx) throws Exception 
+    {
+        Object ret = null;
+        Object networkClass = GetNetworkInstance(ctx);
+        if (networkClass != null) 
+        {
+            Object networkObj = invokeMethod(networkClass, "getInstance", new Object[]{ctx}, Context.class);
+            if (networkObj != null) 
+            {
+                ret = getDeclaredField(networkObj, "mRequestQueue");
+            }
+        }
+        return ret;
+    }
+
+    private static Object getDeclaredField(Object obj, String name)
+            throws SecurityException, NoSuchFieldException,
+            IllegalArgumentException, IllegalAccessException 
+    {
+        Field f = obj.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        Object out = f.get(obj);
+        return out;
+    }
+
+    private static void setDeclaredField(Object obj, String name, Object value)
+            throws SecurityException, NoSuchFieldException,
+            IllegalArgumentException, IllegalAccessException 
+    {
+        Field f = obj.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(obj, value);
+    }
+
+    @SuppressWarnings("rawtypes")
+    private static Object invokeMethod(Object object, String methodName, Object[] params, Class... types) throws Exception 
+    {
+        Object out = null;
+        Class c = object instanceof Class ? (Class) object : object.getClass();
+        
+        if (types != null) 
+        {
+            Method method = c.getMethod(methodName, types);
+            out = method.invoke(object, params);
+        } 
+        else 
+        {
+            Method method = c.getMethod(methodName);
+            out = method.invoke(object);
+        }
+        return out;
+    }
+}

+ 40 - 0
SampleApps/TunneledWebView/app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:paddingBottom="@dimen/activity_vertical_margin"
+    android:paddingLeft="@dimen/activity_horizontal_margin"
+    android:paddingRight="@dimen/activity_horizontal_margin"
+    android:paddingTop="@dimen/activity_vertical_margin"
+    tools:context="ca.psiphon.tunneledwebview.MainActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_weight="1">
+        <WebView
+            android:id="@+id/webView"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent">
+        </WebView>
+    </LinearLayout>
+
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="10dp">
+    </View>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_weight="2">
+        <ListView
+            android:id="@+id/listView"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent">
+        </ListView>
+    </LinearLayout>
+
+</LinearLayout>

+ 15 - 0
SampleApps/TunneledWebView/app/src/main/res/layout/log_message.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
+        android:id="@+id/logMessageTextView"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:padding="1dp"
+        android:textSize="5sp" >
+    </TextView>
+
+</LinearLayout>

BIN
SampleApps/TunneledWebView/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
SampleApps/TunneledWebView/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
SampleApps/TunneledWebView/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
SampleApps/TunneledWebView/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
SampleApps/TunneledWebView/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


+ 17 - 0
SampleApps/TunneledWebView/app/src/main/res/raw/psiphon_config_stub

@@ -0,0 +1,17 @@
+{
+    "PropagationChannelId" : "<placeholder>",
+    "SponsorId" : "<placeholder>",
+    "RemoteServerListUrl" : "<placeholder>",
+    "RemoteServerListSignaturePublicKey" : "<placeholder>",
+    "DataStoreDirectory" : "",
+    "DataStoreTempDirectory" : "",
+    "LogFilename" : "",
+    "LocalHttpProxyPort" : 0,
+    "LocalSocksProxyPort" : 0,
+    "EgressRegion" : "",
+    "TunnelProtocol" : "",
+    "ConnectionWorkerPoolSize" : 10,
+    "TunnelPoolSize" : 1,
+    "PortForwardFailureThreshold" : 10,
+    "UpstreamProxyUrl" : ""
+}

+ 6 - 0
SampleApps/TunneledWebView/app/src/main/res/values-w820dp/dimens.xml

@@ -0,0 +1,6 @@
+<resources>
+    <!-- Example customization of dimensions originally defined in res/values/dimens.xml
+         (such as screen margins) for screens with more than 820dp of available width. This
+         would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
+    <dimen name="activity_horizontal_margin">64dp</dimen>
+</resources>

+ 5 - 0
SampleApps/TunneledWebView/app/src/main/res/values/dimens.xml

@@ -0,0 +1,5 @@
+<resources>
+    <!-- Default screen margins, per the Android Design guidelines. -->
+    <dimen name="activity_horizontal_margin">16dp</dimen>
+    <dimen name="activity_vertical_margin">16dp</dimen>
+</resources>

+ 3 - 0
SampleApps/TunneledWebView/app/src/main/res/values/strings.xml

@@ -0,0 +1,3 @@
+<resources>
+    <string name="app_name">TunneledWebView</string>
+</resources>

+ 8 - 0
SampleApps/TunneledWebView/app/src/main/res/values/styles.xml

@@ -0,0 +1,8 @@
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+    </style>
+
+</resources>

+ 15 - 0
SampleApps/TunneledWebView/app/src/test/java/ca/psiphon/tunneledwebview/ExampleUnitTest.java

@@ -0,0 +1,15 @@
+package ca.psiphon.tunneledwebview;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * To work on unit tests, switch the Test Artifact in the Build Variants view.
+ */
+public class ExampleUnitTest {
+    @Test
+    public void addition_isCorrect() throws Exception {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 24 - 0
SampleApps/TunneledWebView/build.gradle

@@ -0,0 +1,24 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+    repositories {
+        jcenter()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:1.3.0'
+
+        // NOTE: Do not place your application dependencies here; they belong
+        // in the individual module build.gradle files
+    }
+}
+
+allprojects {
+    repositories {
+        jcenter()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
+

+ 18 - 0
SampleApps/TunneledWebView/gradle.properties

@@ -0,0 +1,18 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx10248m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true

BIN
SampleApps/TunneledWebView/gradle/wrapper/gradle-wrapper.jar


+ 6 - 0
SampleApps/TunneledWebView/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,6 @@
+#Thu Jan 21 13:26:45 EST 2016
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip

+ 164 - 0
SampleApps/TunneledWebView/gradlew

@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+    echo "$*"
+}
+
+die ( ) {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+esac
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched.
+if $cygwin ; then
+    [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+fi
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >&-
+APP_HOME="`pwd -P`"
+cd "$SAVED" >&-
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+    JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

+ 90 - 0
SampleApps/TunneledWebView/gradlew.bat

@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 1 - 0
SampleApps/TunneledWebView/settings.gradle

@@ -0,0 +1 @@
+include ':app'