Просмотр исходного кода

Merge pull request #142 from rod-hynes/master

Sample apps update.
Rod Hynes 10 лет назад
Родитель
Сommit
706e7ac4a5
45 измененных файлов с 1881 добавлено и 83 удалено
  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. 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. 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
 Limitations
 --------------------------------------------------------------------------------
 --------------------------------------------------------------------------------

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

@@ -5,7 +5,7 @@
       <GradleProjectSettings>
       <GradleProjectSettings>
         <option name="distributionType" value="LOCAL" />
         <option name="distributionType" value="LOCAL" />
         <option name="externalProjectPath" value="$PROJECT_DIR$" />
         <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="gradleJvm" value="1.8" />
         <option name="modules">
         <option name="modules">
           <set>
           <set>

+ 1 - 3
SampleApps/Psibot/README.md

@@ -4,7 +4,7 @@ Psibot README
 Overview
 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
 an Android app. Psibot uses the Android VpnService API to route all device
 traffic through tun2socks and in turn through Psiphon.
 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).
 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.
 * `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/jni" isTestSource="true" />
       <sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" 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/assets" />
-      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/bundles" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/classes" />
       <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/dependency-cache" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/dex" />
       <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/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/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/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/res" />
+      <excludeFolder url="file://$MODULE_DIR$/build/intermediates/resources" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/rs" />
       <excludeFolder url="file://$MODULE_DIR$/build/intermediates/symbols" />
       <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/outputs" />
       <excludeFolder url="file://$MODULE_DIR$/build/tmp" />
       <excludeFolder url="file://$MODULE_DIR$/build/tmp" />
     </content>
     </content>
     <orderEntry type="jdk" jdkName="Android API 21 Platform" jdkType="Android SDK" />
     <orderEntry type="jdk" jdkName="Android API 21 Platform" jdkType="Android SDK" />
     <orderEntry type="sourceFolder" forTests="false" />
     <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>
   </component>
 </module>
 </module>

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

@@ -27,5 +27,5 @@ repositories {
 
 
 dependencies {
 dependencies {
     compile fileTree(dir: 'libs', include: ['*.jar'])
     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.
  * All rights reserved.
  *
  *
  * This program is free software: you can redistribute it and/or modify
  * 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 org.json.JSONObject;
 
 
 import java.io.File;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.IOException;
 import java.io.PrintStream;
 import java.io.PrintStream;
@@ -57,6 +58,8 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 
 import go.psi.Psi;
 import go.psi.Psi;
 
 
@@ -65,8 +68,8 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     public interface HostService {
     public interface HostService {
         public String getAppName();
         public String getAppName();
         public Context getContext();
         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 String getPsiphonConfig();
         public void onDiagnosticMessage(String message);
         public void onDiagnosticMessage(String message);
         public void onAvailableEgressRegions(List<String> regions);
         public void onAvailableEgressRegions(List<String> regions);
@@ -88,9 +91,9 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
 
     private final HostService mHostService;
     private final HostService mHostService;
     private PrivateAddress mPrivateAddress;
     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 Thread mTun2SocksThread;
     private AtomicBoolean mIsWaitingForNetworkConnectivity;
     private AtomicBoolean mIsWaitingForNetworkConnectivity;
 
 
@@ -98,7 +101,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     // go.psi.Psi and tun2socks implementations each contain global state.
     // go.psi.Psi and tun2socks implementations each contain global state.
     private static PsiphonTunnel mPsiphonTunnel;
     private static PsiphonTunnel mPsiphonTunnel;
 
 
-    public static synchronized PsiphonTunnel newPsiphonVpn(HostService hostService) {
+    public static synchronized PsiphonTunnel newPsiphonTunnel(HostService hostService) {
         if (mPsiphonTunnel != null) {
         if (mPsiphonTunnel != null) {
             mPsiphonTunnel.stop();
             mPsiphonTunnel.stop();
         }
         }
@@ -110,8 +113,9 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
 
 
     private PsiphonTunnel(HostService hostService) {
     private PsiphonTunnel(HostService hostService) {
         mHostService = hostService;
         mHostService = hostService;
-        mLocalSocksProxyPort = 0;
-        mRoutingThroughTunnel = false;
+        mTunFd = new AtomicReference<ParcelFileDescriptor>();
+        mLocalSocksProxyPort = new AtomicInteger(0);
+        mRoutingThroughTunnel = new AtomicBoolean(false);
         mIsWaitingForNetworkConnectivity = new AtomicBoolean(false);
         mIsWaitingForNetworkConnectivity = new AtomicBoolean(false);
     }
     }
 
 
@@ -148,7 +152,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     public synchronized void stop() {
     public synchronized void stop() {
         stopVpn();
         stopVpn();
         stopPsiphon();
         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 String VPN_INTERFACE_NETMASK = "255.255.255.0";
     private final static int VPN_INTERFACE_MTU = 1500;
     private final static int VPN_INTERFACE_MTU = 1500;
     private final static int UDPGW_SERVER_PORT = 7300;
     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)
     @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
     private boolean startVpn() throws Exception {
     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
             // Workaround for https://code.google.com/p/android/issues/detail?id=61096
             Locale.setDefault(new Locale("en"));
             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,
                 // As per http://developer.android.com/reference/android/net/VpnService.Builder.html#establish%28%29,
                 // this application is no longer prepared or was revoked.
                 // this application is no longer prepared or was revoked.
                 return false;
                 return false;
             }
             }
+            mTunFd.set(tunFd);
+
             mHostService.onDiagnosticMessage("VPN established");
             mHostService.onDiagnosticMessage("VPN established");
 
 
         } catch(IllegalArgumentException e) {
         } catch(IllegalArgumentException e) {
@@ -201,19 +214,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         return true;
         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;
             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);
         String udpgwServerAddress = "127.0.0.1:" + Integer.toString(UDPGW_SERVER_PORT);
         startTun2Socks(
         startTun2Socks(
-                mTunFd,
+                mTunFd.get(),
                 VPN_INTERFACE_MTU,
                 VPN_INTERFACE_MTU,
                 mPrivateAddress.mRouter,
                 mPrivateAddress.mRouter,
                 VPN_INTERFACE_NETMASK,
                 VPN_INTERFACE_NETMASK,
@@ -227,17 +243,17 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     }
     }
 
 
     private void stopVpn() {
     private void stopVpn() {
-        if (mTunFd != null) {
+        ParcelFileDescriptor tunFd = mTunFd.getAndSet(null);
+        if (tunFd != null) {
             try {
             try {
-                mTunFd.close();
+                tunFd.close();
             } catch (IOException e) {
             } catch (IOException e) {
             }
             }
-            mTunFd = null;
         }
         }
         waitStopTun2Socks();
         waitStopTun2Socks();
-        mRoutingThroughTunnel = false;
+        mRoutingThroughTunnel.set(false);
     }
     }
-    
+
     //----------------------------------------------------------------------------------------------
     //----------------------------------------------------------------------------------------------
     // PsiphonProvider (Core support) interface implementation
     // PsiphonProvider (Core support) interface implementation
     //----------------------------------------------------------------------------------------------
     //----------------------------------------------------------------------------------------------
@@ -250,7 +266,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
     @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
     @Override
     @Override
     public void BindToDevice(long fileDescriptor) throws Exception {
     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");
             throw new Exception("protect socket failed");
         }
         }
     }
     }
@@ -270,17 +286,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
     }
     }
 
 
     @Override
     @Override
-    public String GetDnsServer() {
+    public String GetPrimaryDnsServer() {
         String dnsResolver = null;
         String dnsResolver = null;
         try {
         try {
-            dnsResolver = getFirstActiveNetworkDnsResolver(mHostService.getVpnService());
+            dnsResolver = getFirstActiveNetworkDnsResolver(mHostService.getContext());
         } catch (Exception e) {
         } catch (Exception e) {
             mHostService.onDiagnosticMessage("failed to get active network DNS resolver: " + e.getMessage());
             mHostService.onDiagnosticMessage("failed to get active network DNS resolver: " + e.getMessage());
-            dnsResolver = DEFAULT_DNS_SERVER;
+            dnsResolver = DEFAULT_PRIMARY_DNS_SERVER;
         }
         }
         return dnsResolver;
         return dnsResolver;
     }
     }
 
 
+    @Override
+    public String GetSecondaryDnsServer() {
+        return DEFAULT_SECONDARY_DNS_SERVER;
+    }
+
     //----------------------------------------------------------------------------------------------
     //----------------------------------------------------------------------------------------------
     // Psiphon Tunnel Core
     // Psiphon Tunnel Core
     //----------------------------------------------------------------------------------------------
     //----------------------------------------------------------------------------------------------
@@ -289,12 +310,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         stopPsiphon();
         stopPsiphon();
         mHostService.onDiagnosticMessage("starting Psiphon library");
         mHostService.onDiagnosticMessage("starting Psiphon library");
         try {
         try {
-            boolean isVpnMode = (mTunFd != null);
             Psi.Start(
             Psi.Start(
-                loadPsiphonConfig(mHostService.getContext(), isVpnMode),
-                embeddedServerEntries,
-                this,
-                isVpnMode);
+                    loadPsiphonConfig(mHostService.getContext()),
+                    embeddedServerEntries,
+                    this,
+                    isVpnMode());
         } catch (java.lang.Exception e) {
         } catch (java.lang.Exception e) {
             throw new Exception("failed to start Psiphon library", 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");
         mHostService.onDiagnosticMessage("Psiphon library stopped");
     }
     }
 
 
-    private String loadPsiphonConfig(Context context, boolean isVpnMode)
+    private String loadPsiphonConfig(Context context)
             throws IOException, JSONException {
             throws IOException, JSONException {
 
 
         // Load settings from the raw resource JSON config file and
         // Load settings from the raw resource JSON config file and
         // update as necessary. Then write JSON to disk for the Go client.
         // update as necessary. Then write JSON to disk for the Go client.
         JSONObject json = new JSONObject(mHostService.getPsiphonConfig());
         JSONObject json = new JSONObject(mHostService.getPsiphonConfig());
-        
+
         // On Android, these directories must be set to the app private storage area.
         // 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
         // The Psiphon library won't be able to use its current working directory
         // and the standard temporary directories do not exist.
         // and the standard temporary directories do not exist.
@@ -327,11 +347,11 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         json.put("EstablishTunnelTimeoutSeconds", 0);
         json.put("EstablishTunnelTimeoutSeconds", 0);
 
 
         // This parameter is for stats reporting
         // This parameter is for stats reporting
-        json.put("TunnelWholeDevice", isVpnMode ? 1 : 0);
+        json.put("TunnelWholeDevice", isVpnMode() ? 1 : 0);
 
 
         json.put("EmitBytesTransferred", true);
         json.put("EmitBytesTransferred", true);
 
 
-        if (mLocalSocksProxyPort != 0) {
+        if (mLocalSocksProxyPort.get() != 0) {
             // When mLocalSocksProxyPort is set, tun2socks is already configured
             // When mLocalSocksProxyPort is set, tun2socks is already configured
             // to use that port value. So we force use of the same port.
             // 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
             // 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);
         json.put("UseIndistinguishableTLS", true);
 
 
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
+            json.put("UseTrustedCACertificatesForStockTLS", true);
+        }
+
         try {
         try {
             // Also enable indistinguishable TLS for HTTPS requests that
             // Also enable indistinguishable TLS for HTTPS requests that
             // require system CAs.
             // require system CAs.
             json.put(
             json.put(
-                "TrustedCACertificatesFilename",
-                setupTrustedCertificates(mHostService.getContext()));
+                    "TrustedCACertificatesFilename",
+                    setupTrustedCertificates(mHostService.getContext()));
         } catch (Exception e) {
         } catch (Exception e) {
             mHostService.onDiagnosticMessage(e.getMessage());
             mHostService.onDiagnosticMessage(e.getMessage());
         }
         }
@@ -359,14 +383,16 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             // All notices are sent on as diagnostic messages
             // All notices are sent on as diagnostic messages
             // except those that may contain private user data.
             // except those that may contain private user data.
             boolean diagnostic = true;
             boolean diagnostic = true;
-            
+
             JSONObject notice = new JSONObject(noticeJSON);
             JSONObject notice = new JSONObject(noticeJSON);
             String noticeType = notice.getString("noticeType");
             String noticeType = notice.getString("noticeType");
-            
+
             if (noticeType.equals("Tunnels")) {
             if (noticeType.equals("Tunnels")) {
                 int count = notice.getJSONObject("data").getInt("count");
                 int count = notice.getJSONObject("data").getInt("count");
                 if (count > 0) {
                 if (count > 0) {
-                    routeThroughTunnel();
+                    if (isVpnMode()) {
+                        routeThroughTunnel();
+                    }
                     mHostService.onConnected();
                     mHostService.onConnected();
                 } else {
                 } else {
                     mHostService.onConnecting();
                     mHostService.onConnecting();
@@ -379,7 +405,7 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
                     regions.add(egressRegions.getString(i));
                     regions.add(egressRegions.getString(i));
                 }
                 }
                 mHostService.onAvailableEgressRegions(regions);
                 mHostService.onAvailableEgressRegions(regions);
-                
+
             } else if (noticeType.equals("SocksProxyPortInUse")) {
             } else if (noticeType.equals("SocksProxyPortInUse")) {
                 mHostService.onSocksProxyPortInUse(notice.getJSONObject("data").getInt("port"));
                 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"));
                 mHostService.onUpstreamProxyError(notice.getJSONObject("data").getString("message"));
 
 
             } else if (noticeType.equals("ClientUpgradeDownloaded")) {
             } else if (noticeType.equals("ClientUpgradeDownloaded")) {
-                mHostService.onHomepage(notice.getJSONObject("data").getString("filename"));
+                mHostService.onClientUpgradeDownloaded(notice.getJSONObject("data").getString("filename"));
 
 
             } else if (noticeType.equals("Homepage")) {
             } else if (noticeType.equals("Homepage")) {
                 mHostService.onHomepage(notice.getJSONObject("data").getString("url"));
                 mHostService.onHomepage(notice.getJSONObject("data").getString("url"));
@@ -459,8 +485,21 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             try {
             try {
                 output = new PrintStream(new FileOutputStream(file));
                 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();
                 Enumeration<String> aliases = keyStore.aliases();
                 while (aliases.hasMoreElements()) {
                 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
     @Override
     public void onCreate() {
     public void onCreate() {
-        mPsiphonTunnel = PsiphonTunnel.newPsiphonVpn(this);
+        mPsiphonTunnel = PsiphonTunnel.newPsiphonTunnel(this);
         startForeground(R.string.foregroundServiceNotificationId, makeForegroundNotification());
         startForeground(R.string.foregroundServiceNotificationId, makeForegroundNotification());
         try {
         try {
             if (!mPsiphonTunnel.startRouting()) {
             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'