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

Merge pull request #43 from rod-hynes/master

AndroidApp: Add preferences UI for configuring Psiphon parameters. Add Quit command.
Adam Pritchard 11 лет назад
Родитель
Сommit
486cf8f451

+ 3 - 0
AndroidApp/app/src/main/AndroidManifest.xml

@@ -19,6 +19,9 @@
                 <category android:name="android.intent.category.LAUNCHER" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
             </intent-filter>
         </activity>
         </activity>
+        <activity
+            android:name=".SettingsActivity" >
+        </activity>
         <service
         <service
             android:name="ca.psiphon.psibot.Service"
             android:name="ca.psiphon.psibot.Service"
             android:label="@string/app_name"
             android:label="@string/app_name"

+ 3 - 0
AndroidApp/app/src/main/java/ca/psiphon/psibot/App.java

@@ -20,6 +20,8 @@
 package ca.psiphon.psibot;
 package ca.psiphon.psibot;
 
 
 import android.app.Application;
 import android.app.Application;
+import android.preference.PreferenceManager;
+
 import go.Go;
 import go.Go;
 
 
 public class App extends Application {
 public class App extends Application {
@@ -28,5 +30,6 @@ public class App extends Application {
     public void onCreate() {
     public void onCreate() {
         Go.init(this.getApplicationContext());
         Go.init(this.getApplicationContext());
         Log.initialize();
         Log.initialize();
+        PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
     }
     }
 }
 }

+ 16 - 2
AndroidApp/app/src/main/java/ca/psiphon/psibot/MainActivity.java

@@ -54,10 +54,17 @@ public class MainActivity extends Activity {
 
 
     @Override
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
     public boolean onOptionsItemSelected(MenuItem item) {
-        int id = item.getItemId();
-        if (id == R.id.action_email_log) {
+        switch (item.getItemId()) {
+        case R.id.action_email_log:
             Log.composeEmail(this);
             Log.composeEmail(this);
             return true;
             return true;
+        case R.id.action_settings:
+            startActivity(new Intent(this, SettingsActivity.class));
+            return true;
+        case R.id.action_quit:
+            stopVpnService();
+            finish();
+            return true;
         }
         }
         return super.onOptionsItemSelected(item);
         return super.onOptionsItemSelected(item);
     }
     }
@@ -84,6 +91,13 @@ public class MainActivity extends Activity {
         startService(new Intent(this, Service.class));
         startService(new Intent(this, Service.class));
     }
     }
 
 
+    protected void stopVpnService() {
+        // Note: Tun2Socks.stop() closes the VpnService file descriptor.
+        // This is necessary in order to stop the VpnService while running.
+        Tun2Socks.stop();
+        stopService(new Intent(this, Service.class));
+    }
+
     @Override
     @Override
     protected void onActivityResult(int request, int result, Intent data) {
     protected void onActivityResult(int request, int result, Intent data) {
         if (request == REQUEST_CODE_PREPARE_VPN && result == RESULT_OK) {
         if (request == REQUEST_CODE_PREPARE_VPN && result == RESULT_OK) {

+ 44 - 0
AndroidApp/app/src/main/java/ca/psiphon/psibot/Psiphon.java

@@ -20,7 +20,9 @@
 package ca.psiphon.psibot;
 package ca.psiphon.psibot;
 
 
 import android.content.Context;
 import android.content.Context;
+import android.content.SharedPreferences;
 import android.net.VpnService;
 import android.net.VpnService;
+import android.preference.PreferenceManager;
 
 
 import org.json.JSONException;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.json.JSONObject;
@@ -125,6 +127,48 @@ public class Psiphon extends Psi.PsiphonProvider.Stub {
         json.put("DataStoreDirectory", mVpnService.getFilesDir());
         json.put("DataStoreDirectory", mVpnService.getFilesDir());
         json.put("DataStoreTempDirectory", mVpnService.getCacheDir());
         json.put("DataStoreTempDirectory", mVpnService.getCacheDir());
 
 
+        // User-specified settings.
+        // Note: currently, validation is not comprehensive, and related errors are
+        // not directly parsed.
+        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+        json.put("EgressRegion",
+                preferences.getString(
+                        context.getString(R.string.preferenceEgressRegion),
+                        context.getString(R.string.preferenceEgressRegionDefaultValue)));
+        json.put("TunnelProtocol",
+                preferences.getString(
+                        context.getString(R.string.preferenceTunnelProtocol),
+                        context.getString(R.string.preferenceTunnelProtocolDefaultValue)));
+        json.put("UpstreamHttpProxyAddress",
+                preferences.getString(
+                        context.getString(R.string.preferenceUpstreamHttpProxyAddress),
+                        context.getString(R.string.preferenceUpstreamHttpProxyAddressDefaultValue)));
+        json.put("LocalHttpProxyPort",
+                Integer.parseInt(
+                        preferences.getString(
+                                context.getString(R.string.preferenceLocalHttpProxyPort),
+                                context.getString(R.string.preferenceLocalHttpProxyPortDefaultValue))));
+        json.put("LocalSocksProxyPort",
+                Integer.parseInt(
+                        preferences.getString(
+                                context.getString(R.string.preferenceLocalSocksProxyPort),
+                                context.getString(R.string.preferenceLocalSocksProxyPortDefaultValue))));
+        json.put("ConnectionWorkerPoolSize",
+                Integer.parseInt(
+                        preferences.getString(
+                                context.getString(R.string.preferenceConnectionWorkerPoolSize),
+                                context.getString(R.string.preferenceConnectionWorkerPoolSizeDefaultValue))));
+        json.put("TunnelPoolSize",
+                Integer.parseInt(
+                        preferences.getString(
+                                context.getString(R.string.preferenceTunnelPoolSize),
+                                context.getString(R.string.preferenceTunnelPoolSizeDefaultValue))));
+        json.put("PortForwardFailureThreshold",
+                Integer.parseInt(
+                        preferences.getString(
+                                context.getString(R.string.preferencePortForwardFailureThreshold),
+                                context.getString(R.string.preferencePortForwardFailureThresholdDefaultValue))));
+
         return json.toString();
         return json.toString();
     }
     }
 
 

+ 68 - 24
AndroidApp/app/src/main/java/ca/psiphon/psibot/Service.java

@@ -22,66 +22,97 @@ package ca.psiphon.psibot;
 import android.app.Notification;
 import android.app.Notification;
 import android.app.PendingIntent;
 import android.app.PendingIntent;
 import android.content.Intent;
 import android.content.Intent;
+import android.content.SharedPreferences;
 import android.net.VpnService;
 import android.net.VpnService;
 import android.os.ParcelFileDescriptor;
 import android.os.ParcelFileDescriptor;
+import android.preference.PreferenceManager;
 
 
 import java.util.Locale;
 import java.util.Locale;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 
-public class Service extends VpnService {
+public class Service extends VpnService
+        implements SharedPreferences.OnSharedPreferenceChangeListener {
 
 
     // Note: assumes only one instance of Service
     // Note: assumes only one instance of Service
     private static AtomicBoolean mIsRunning = new AtomicBoolean();
     private static AtomicBoolean mIsRunning = new AtomicBoolean();
 
 
-    private Thread mThread;
-    private CountDownLatch mStopSignal;
-
     public static boolean isRunning() {
     public static boolean isRunning() {
         return mIsRunning.get();
         return mIsRunning.get();
     }
     }
 
 
+    private Thread mThread;
+    private CountDownLatch mInterruptSignal;
+    private AtomicBoolean mStopFlag;
+
     @Override
     @Override
     public void onCreate() {
     public void onCreate() {
         mIsRunning.set(true);
         mIsRunning.set(true);
         startForeground(R.string.foregroundServiceNotificationId, makeForegroundNotification());
         startForeground(R.string.foregroundServiceNotificationId, makeForegroundNotification());
         startWorking();
         startWorking();
+        PreferenceManager.getDefaultSharedPreferences(this).
+                registerOnSharedPreferenceChangeListener(this);
     }
     }
 
 
     @Override
     @Override
     public void onDestroy() {
     public void onDestroy() {
+        PreferenceManager.getDefaultSharedPreferences(this).
+                unregisterOnSharedPreferenceChangeListener(this);
         stopWorking();
         stopWorking();
         stopForeground(true);
         stopForeground(true);
         mIsRunning.set(false);
         mIsRunning.set(false);
     }
     }
 
 
+    @Override
+    public synchronized void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+        if (!mIsRunning.get()) {
+            return;
+        }
+        // An interrupt without setting stop will restart Psiphon, using the newest preferences.
+        interruptWorking();
+    }
+
     private void startWorking() {
     private void startWorking() {
         stopWorking();
         stopWorking();
-        mStopSignal = new CountDownLatch(1);
+        mInterruptSignal = new CountDownLatch(1);
+        mStopFlag = new AtomicBoolean(false);
         mThread = new Thread(new Runnable() {
         mThread = new Thread(new Runnable() {
             @Override
             @Override
             public void run() {
             public void run() {
                 CountDownLatch tunnelStartedSignal = new CountDownLatch(1);
                 CountDownLatch tunnelStartedSignal = new CountDownLatch(1);
                 Psiphon psiphon = new Psiphon(Service.this, tunnelStartedSignal);
                 Psiphon psiphon = new Psiphon(Service.this, tunnelStartedSignal);
-                try {
-                    // TODO: monitor tunnel messages and update notification UI when re-connecting, etc.
-                    psiphon.start();
-                    while (true) {
-                        if (tunnelStartedSignal.await(100, TimeUnit.MILLISECONDS)) {
-                            break;
+                // An interrupt with mStopFlag=false will cause Psiphon to restart without stopping
+                // the service thread or VPN; this is to apply new preferences to the Psiphon config.
+                // (The VPN is restarted if the preferred SOCKS proxy port changes.)
+                int localSocksProxyPort = -1;
+                while (!mStopFlag.get()) {
+                    try {
+                        // TODO: monitor tunnel messages and update notification UI when re-connecting, etc.
+                        psiphon.start();
+                        while (true) {
+                            if (tunnelStartedSignal.await(100, TimeUnit.MILLISECONDS)) {
+                                break;
+                            }
+                            if (mInterruptSignal.await(0, TimeUnit.MILLISECONDS)) {
+                                throw new Utils.PsibotError("interrupted while waiting tunnel");
+                            }
                         }
                         }
-                        if (mStopSignal.await(0, TimeUnit.MILLISECONDS)) {
-                            throw new Utils.PsibotError("stopped while waiting tunnel");
+                        // [Re]start the VPN when the local SOCKS proxy port changes. Leave the
+                        // VPN up when other preferences change; only Psiphon restarts in this case.
+                        if (psiphon.getLocalSocksProxyPort() != localSocksProxyPort) {
+                            if (localSocksProxyPort != -1) {
+                                stopVpn();
+                            }
+                            localSocksProxyPort = psiphon.getLocalSocksProxyPort();
+                            runVpn(localSocksProxyPort);
                         }
                         }
+                        mInterruptSignal.await();
+                    } catch (Utils.PsibotError e) {
+                        Log.addEntry("Service failed: " + e.getMessage());
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
                     }
                     }
-                    int localSocksProxyPort = psiphon.getLocalSocksProxyPort();
-                    runVpn(localSocksProxyPort);
-                    mStopSignal.await();
-                } catch (Utils.PsibotError e) {
-                    Log.addEntry("Service failed: " + e.getMessage());
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
                 }
                 }
                 stopVpn();
                 stopVpn();
                 psiphon.stop();
                 psiphon.stop();
@@ -91,10 +122,22 @@ public class Service extends VpnService {
         mThread.start();
         mThread.start();
     }
     }
 
 
+    private void interruptWorking() {
+        if (mInterruptSignal == null) {
+            return;
+        }
+        CountDownLatch currentSignal = mInterruptSignal;
+        // This is the new interrupt signal for when work resumes
+        mInterruptSignal = new CountDownLatch(1);
+        // Interrupt work
+        currentSignal.countDown();
+    }
+
     private void stopWorking() {
     private void stopWorking() {
-        if (mStopSignal != null) {
-            mStopSignal.countDown();
+        if (mStopFlag != null) {
+            mStopFlag.set(true);
         }
         }
+        interruptWorking();
         if (mThread != null) {
         if (mThread != null) {
             try {
             try {
                 mThread.join();
                 mThread.join();
@@ -102,7 +145,8 @@ public class Service extends VpnService {
                 Thread.currentThread().interrupt();
                 Thread.currentThread().interrupt();
             }
             }
         }
         }
-        mStopSignal = null;
+        mInterruptSignal = null;
+        mStopFlag = null;
         mThread = null;
         mThread = null;
     }
     }
 
 
@@ -201,7 +245,7 @@ public class Service extends VpnService {
                 new Notification.Builder(this)
                 new Notification.Builder(this)
                         .setContentIntent(pendingIntent)
                         .setContentIntent(pendingIntent)
                         .setContentTitle(getString(R.string.foreground_service_notification_content_title))
                         .setContentTitle(getString(R.string.foreground_service_notification_content_title))
-                        .setSmallIcon(R.drawable.ic_launcher);
+                        .setSmallIcon(R.drawable.ic_notification);
 
 
         return notificationBuilder.build();
         return notificationBuilder.build();
     }
     }

+ 86 - 0
AndroidApp/app/src/main/java/ca/psiphon/psibot/SettingsActivity.java

@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2014, 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.psibot;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+
+public class SettingsActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        getFragmentManager().beginTransaction()
+                .replace(android.R.id.content, new SettingsFragment())
+                .commit();
+    }
+
+    public static class SettingsFragment extends PreferenceFragment
+            implements SharedPreferences.OnSharedPreferenceChangeListener {
+
+        @Override
+        public void onCreate(Bundle savedInstanceState) {
+            super.onCreate(savedInstanceState);
+            addPreferencesFromResource(R.xml.preferences);
+            updateSummary(getPreferenceScreen());
+        }
+
+        @Override
+        public void onResume() {
+            super.onResume();
+            getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
+        }
+
+        @Override
+        public void onPause() {
+            super.onPause();
+            getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
+        }
+
+        @Override
+        public void onSharedPreferenceChanged(SharedPreferences preferences, String key) {
+            updateSummary(findPreference(key));
+        }
+
+        private void updateSummary(PreferenceGroup group) {
+            for (int i = 0; i < group.getPreferenceCount(); i++) {
+                Preference preference = group.getPreference(i);
+                if (preference instanceof PreferenceGroup) {
+                    updateSummary((PreferenceGroup) preference);
+                } else {
+                    updateSummary(preference);
+                }
+            }
+        }
+
+        private void updateSummary(Preference preference) {
+            if (preference instanceof ListPreference) {
+                preference.setSummary(((ListPreference) preference).getEntry());
+            } else if (preference instanceof EditTextPreference) {
+                preference.setSummary(((EditTextPreference) preference).getText());
+            }
+        }
+    }
+}

BIN
AndroidApp/app/src/main/res/drawable-hdpi/ic_notification.png


BIN
AndroidApp/app/src/main/res/drawable-mdpi/ic_notification.png


BIN
AndroidApp/app/src/main/res/drawable-xhdpi/ic_notification.png


BIN
AndroidApp/app/src/main/res/drawable-xxhdpi/ic_notification.png


+ 8 - 0
AndroidApp/app/src/main/res/menu/main.xml

@@ -5,4 +5,12 @@
         android:title="@string/action_email_log"
         android:title="@string/action_email_log"
         android:orderInCategory="100"
         android:orderInCategory="100"
         android:showAsAction="never" />
         android:showAsAction="never" />
+    <item android:id="@+id/action_settings"
+        android:title="@string/action_settings"
+        android:orderInCategory="101"
+        android:showAsAction="never" />
+    <item android:id="@+id/action_quit"
+        android:title="@string/action_quit"
+        android:orderInCategory="102"
+        android:showAsAction="never" />
 </menu>
 </menu>

+ 28 - 0
AndroidApp/app/src/main/res/values/strings.xml

@@ -4,5 +4,33 @@
     <string name="app_name">Psibot</string>
     <string name="app_name">Psibot</string>
     <string name="foreground_service_notification_content_title">Psibot is running</string>
     <string name="foreground_service_notification_content_title">Psibot is running</string>
     <string name="action_email_log">Email Log</string>
     <string name="action_email_log">Email Log</string>
+    <string name="action_settings">Settings</string>
+    <string name="action_quit">Quit</string>
 
 
+    <string name="prompt_stopping_service">Stopping Psiphon...</string>
+
+    <string name="preference_egress_region">Egress Region</string>
+    <string-array name="preference_egress_region_entries">
+        <item>Best Performance</item>
+        <item>United States</item>
+        <item>Great Britain</item>
+        <item>Japan</item>
+        <item>Netherlands</item>
+    </string-array>
+
+    <string name="preference_tunnel_protocol">Tunnel Protocol</string>
+    <string-array name="preference_tunnel_protocol_entries">
+        <item>Best Performance</item>
+        <item>SSH</item>
+        <item>Obfuscated SSH</item>
+        <item>Unfronted Meek</item>
+        <item>Fronted Meek</item>
+    </string-array>
+
+    <string name="preference_upstream_http_proxy_address">Upstream HTTP Proxy Address</string>
+    <string name="preference_local_http_proxy_port">Local HTTP Proxy Port</string>
+    <string name="preference_local_socks_proxy_port">Local SOCKS Proxy Port</string>
+    <string name="preference_connection_worker_pool_size">Connection Workers</string>
+    <string name="preference_tunnel_pool_size">Concurrent Tunnels</string>
+    <string name="preference_port_forward_failure_threshold">Tunnel Failure Threshold</string>
 </resources>
 </resources>

+ 38 - 0
AndroidApp/app/src/main/res/values/symbols.xml

@@ -1,4 +1,42 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
 <resources>
     <string name="foregroundServiceNotificationId">psibotService</string>
     <string name="foregroundServiceNotificationId">psibotService</string>
+
+    <string name="preferenceEgressRegion">preferenceEgressRegion</string>
+    <string-array name="preferenceEgressRegionEntryValues">
+        <item></item>
+        <item>US</item>
+        <item>GB</item>
+        <item>JP</item>
+        <item>NL</item>
+    </string-array>
+
+    <string name="preferenceEgressRegionDefaultValue"></string>
+    <string name="preferenceTunnelProtocol">preferenceTunnelProtocol</string>
+    <string-array name="preferenceTunnelProtocolEntryValues">
+        <item></item>
+        <item>SSH</item>
+        <item>OSSH</item>
+        <item>UNFRONTED-MEEK-OSSH</item>
+        <item>FRONTED-MEEK-OSSH</item>
+    </string-array>
+    <string name="preferenceTunnelProtocolDefaultValue"></string>
+
+    <string name="preferenceUpstreamHttpProxyAddress">preferenceUpstreamHttpProxyAddress</string>
+    <string name="preferenceUpstreamHttpProxyAddressDefaultValue"></string>
+
+    <string name="preferenceLocalHttpProxyPort">preferenceLocalHttpProxyPort</string>
+    <string name="preferenceLocalHttpProxyPortDefaultValue">0</string>
+
+    <string name="preferenceLocalSocksProxyPort">preferenceLocalSocksProxyPort</string>
+    <string name="preferenceLocalSocksProxyPortDefaultValue">0</string>
+
+    <string name="preferenceConnectionWorkerPoolSize">preferenceConnectionWorkerPoolSize</string>
+    <string name="preferenceConnectionWorkerPoolSizeDefaultValue">10</string>
+
+    <string name="preferenceTunnelPoolSize">preferenceTunnelPoolSize</string>
+    <string name="preferenceTunnelPoolSizeDefaultValue">3</string>
+
+    <string name="preferencePortForwardFailureThreshold">preferencePortForwardFailureThreshold</string>
+    <string name="preferencePortForwardFailureThresholdDefaultValue">10</string>
 </resources>
 </resources>

+ 61 - 0
AndroidApp/app/src/main/res/xml/preferences.xml

@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <ListPreference
+        android:key="@string/preferenceEgressRegion"
+        android:title="@string/preference_egress_region"
+        android:dialogTitle="@string/preference_egress_region"
+        android:entries="@array/preference_egress_region_entries"
+        android:entryValues="@array/preferenceEgressRegionEntryValues"
+        android:defaultValue="@string/preferenceEgressRegionDefaultValue" />
+
+    <ListPreference
+        android:key="@string/preferenceTunnelProtocol"
+        android:title="@string/preference_tunnel_protocol"
+        android:dialogTitle="@string/preference_tunnel_protocol"
+        android:entries="@array/preference_tunnel_protocol_entries"
+        android:entryValues="@array/preferenceTunnelProtocolEntryValues"
+        android:defaultValue="@string/preferenceTunnelProtocolDefaultValue" />
+
+    <EditTextPreference
+        android:key="@string/preferenceUpstreamHttpProxyAddress"
+        android:title="@string/preference_upstream_http_proxy_address"
+        android:dialogTitle="@string/preference_upstream_http_proxy_address"
+        android:defaultValue="@string/preferenceUpstreamHttpProxyAddressDefaultValue" />
+
+    <EditTextPreference
+        android:numeric="integer"
+        android:key="@string/preferenceLocalHttpProxyPort"
+        android:title="@string/preference_local_http_proxy_port"
+        android:dialogTitle="@string/preference_local_http_proxy_port"
+        android:defaultValue="@string/preferenceLocalHttpProxyPortDefaultValue" />
+
+    <EditTextPreference
+        android:numeric="integer"
+        android:key="@string/preferenceLocalSocksProxyPort"
+        android:title="@string/preference_local_socks_proxy_port"
+        android:dialogTitle="@string/preference_local_socks_proxy_port"
+        android:defaultValue="@string/preferenceLocalSocksProxyPortDefaultValue" />
+
+    <EditTextPreference
+        android:numeric="integer"
+        android:key="@string/preferenceConnectionWorkerPoolSize"
+        android:title="@string/preference_connection_worker_pool_size"
+        android:dialogTitle="@string/preference_connection_worker_pool_size"
+        android:defaultValue="@string/preferenceConnectionWorkerPoolSizeDefaultValue" />
+
+    <EditTextPreference
+        android:numeric="integer"
+        android:key="@string/preferenceTunnelPoolSize"
+        android:title="@string/preference_tunnel_pool_size"
+        android:dialogTitle="@string/preference_tunnel_pool_size"
+        android:defaultValue="@string/preferenceTunnelPoolSizeDefaultValue" />
+
+    <EditTextPreference
+        android:numeric="integer"
+        android:key="@string/preferencePortForwardFailureThreshold"
+        android:title="@string/preference_port_forward_failure_threshold"
+        android:dialogTitle="@string/preference_port_forward_failure_threshold"
+        android:defaultValue="@string/preferencePortForwardFailureThresholdDefaultValue" />
+
+</PreferenceScreen>