diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index add94cb9..9bf714de 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + ()); // default to empty list mUseRoot = (CheckBoxPreference) findPreference(SyncthingService.PREF_USE_ROOT); Preference appVersion = screen.findPreference(APP_VERSION_KEY); mOptionsScreen = (PreferenceScreen) screen.findPreference(SYNCTHING_OPTIONS_KEY); @@ -144,6 +151,7 @@ public class SettingsFragment extends PreferenceFragment mAlwaysRunInBackground.setOnPreferenceChangeListener(this); mSyncOnlyCharging.setOnPreferenceChangeListener(this); mSyncOnlyWifi.setOnPreferenceChangeListener(this); + mSyncOnlyOnSSIDs.setOnPreferenceChangeListener(this); mUseRoot.setOnPreferenceClickListener(this); screen.findPreference(EXPORT_CONFIG).setOnPreferenceClickListener(this); screen.findPreference(IMPORT_CONFIG).setOnPreferenceClickListener(this); @@ -153,8 +161,9 @@ public class SettingsFragment extends PreferenceFragment sttrace.setOnPreferenceChangeListener(this); // Force summary update and wifi/charging preferences enable/disable. - onPreferenceChange(mAlwaysRunInBackground, mAlwaysRunInBackground.isChecked()); SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity()); + onPreferenceChange(mAlwaysRunInBackground, mAlwaysRunInBackground.isChecked()); + onPreferenceChange(mSyncOnlyOnSSIDs, sp.getStringSet("sync_only_wifi_ssids_set", new TreeSet())); user.setSummary(sp.getString("gui_user", "")); sttrace.setSummary(sp.getString("sttrace", "")); } @@ -236,11 +245,19 @@ public class SettingsFragment extends PreferenceFragment : R.string.always_run_in_background_disabled); mSyncOnlyCharging.setEnabled(value); mSyncOnlyWifi.setEnabled(value); + mSyncOnlyOnSSIDs.setEnabled(mSyncOnlyWifi.isChecked()); // Uncheck items when disabled, so it is clear they have no effect. if (!value) { mSyncOnlyCharging.setChecked(false); mSyncOnlyWifi.setChecked(false); } + } else if (preference.equals(mSyncOnlyWifi)) { + mSyncOnlyOnSSIDs.setEnabled((Boolean) o); + } else if (preference.equals(mSyncOnlyOnSSIDs)) { + String ssids = formatWifiNameList((Set) o); + mSyncOnlyOnSSIDs.setSummary(ssids.isEmpty() + ? getString(R.string.sync_only_wifi_ssids_all) + : getString(R.string.sync_only_wifi_ssids_values, ssids)); } else if (preference.getKey().equals(DEVICE_NAME_KEY)) { RestApi.Device old = mSyncthingService.getApi().getLocalDevice(); RestApi.Device updated = new RestApi.Device(); @@ -297,6 +314,14 @@ public class SettingsFragment extends PreferenceFragment return true; } + private String formatWifiNameList(Set ssids) { + Set formatted = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (String ssid : ssids) { + formatted.add(ssid.replaceFirst("^\"", "").replaceFirst("\"$", "")); + } + return TextUtils.join(", ", formatted); + } + /** * Changes the owner of syncthing files so they can be accessed without root. */ diff --git a/src/main/java/com/nutomic/syncthingandroid/preferences/WifiSsidPreference.java b/src/main/java/com/nutomic/syncthingandroid/preferences/WifiSsidPreference.java new file mode 100644 index 00000000..7fe3407d --- /dev/null +++ b/src/main/java/com/nutomic/syncthingandroid/preferences/WifiSsidPreference.java @@ -0,0 +1,129 @@ +package com.nutomic.syncthingandroid.preferences; + +import android.content.Context; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import android.preference.MultiSelectListPreference; +import android.util.AttributeSet; +import android.widget.ListView; +import android.widget.Toast; + +import com.nutomic.syncthingandroid.R; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * MultiSelectListPreference which allows the user to select on which WiFi networks (based on SSID) + * syncing should be allowed. + * + * Setting can be "All networks" (none selected), or selecting individual networks. + * + * Due to restrictions in Android, it is possible/likely, that the list of saved WiFi networks + * cannot be retrieved if the WiFi is turned off. In this case, an explanation is shown. + * + * The preference is stored as Set<String> where an empty set represents + * "all networks allowed". + * + * SSIDs are formatted according to the naming convention of WifiManager, i.e. they have the + * surrounding double-quotes (") for UTF-8 names, or they are hex strings (if not quoted). + */ +public class WifiSsidPreference extends MultiSelectListPreference { + + public WifiSsidPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public WifiSsidPreference(Context context) { + super(context); + } + + /** + * Show the dialog if WiFi is available and configured networks can be loaded. + * Otherwise will display a Toast requesting to turn on WiFi. + * + * On opening of the dialog, will also remove any SSIDs from the set that have been removed + * by the user in the WiFi manager. This change will be persisted only if the user changes + * any other setting + */ + @Override + protected void showDialog(Bundle state) { + WifiConfiguration[] networks = loadConfiguredNetworksSorted(); + if (networks != null) { + Set selected = getSharedPreferences().getStringSet(getKey(), new HashSet()); + // from JavaDoc: Note that you must not modify the set instance returned by this call. + // therefore required to make a defensive copy of the elements + selected = new HashSet<>(selected); + CharSequence[] all = extractSsid(networks, false); + filterRemovedNetworks(selected, all); + setEntries(extractSsid(networks, true)); // display without surrounding quotes + setEntryValues(all); // the value of the entry is the SSID "as is" + setValues(selected); // the currently selected values (without meanwhile deleted networks) + super.showDialog(state); + } else { + Toast.makeText(getContext(), R.string.sync_only_wifi_ssids_wifi_turn_on_wifi, Toast.LENGTH_LONG).show(); + } + } + + /** + * Removes any network that is no longer saved on the device. Otherwise it will never be + * removed from the allowed set by MultiSelectListPreference. + */ + private void filterRemovedNetworks(Set selected, CharSequence[] all) { + HashSet availableNetworks = new HashSet<>(Arrays.asList(all)); + selected.retainAll(availableNetworks); + } + + /** + * Converts WiFi configuration to it's string representation, using the SSID. + * + * It can also remove surrounding quotes which indicate that the SSID is an UTF-8 + * string and not a Hex-String, if the strings are intended to be displayed to the + * user, who will not expect the quotes. + * + * @param configs the objects to convert + * @param stripQuotes if to remove surrounding quotes + * @return the formatted SSID of the wifi configurations + */ + private CharSequence[] extractSsid(WifiConfiguration[] configs, boolean stripQuotes) { + CharSequence[] result = new CharSequence[configs.length]; + for (int i = 0; i < configs.length; i++) { + // WiFi SSIDs can either be UTF-8 (encapsulated in '"') or hex-strings + if (stripQuotes) + result[i] = configs[i].SSID.replaceFirst("^\"", "").replaceFirst("\"$", ""); + else + result[i] = configs[i].SSID; + } + return result; + } + + /** + * Load the configured WiFi networks, sort them by SSID. + * + * @return a sorted array of WifiConfiguration, or null, if data cannot be retrieved + */ + private WifiConfiguration[] loadConfiguredNetworksSorted() { + WifiManager wifiManager = (WifiManager) getContext().getSystemService(Context.WIFI_SERVICE); + if (wifiManager != null) { + List configuredNetworks = wifiManager.getConfiguredNetworks(); + // if WiFi is turned off, getConfiguredNetworks returns null on many devices + if (configuredNetworks != null) { + WifiConfiguration[] result = configuredNetworks.toArray(new WifiConfiguration[configuredNetworks.size()]); + Arrays.sort(result, new Comparator() { + @Override + public int compare(WifiConfiguration lhs, WifiConfiguration rhs) { + return lhs.SSID.compareToIgnoreCase(rhs.SSID); + } + }); + return result; + } + } + // WiFi is turned off or device doesn't have WiFi + return null; + } + +} diff --git a/src/main/java/com/nutomic/syncthingandroid/syncthing/DeviceStateHolder.java b/src/main/java/com/nutomic/syncthingandroid/syncthing/DeviceStateHolder.java index 14803da9..9c028ae0 100644 --- a/src/main/java/com/nutomic/syncthingandroid/syncthing/DeviceStateHolder.java +++ b/src/main/java/com/nutomic/syncthingandroid/syncthing/DeviceStateHolder.java @@ -5,6 +5,8 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.net.ConnectivityManager; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; import android.os.BatteryManager; /** @@ -26,17 +28,25 @@ public class DeviceStateHolder extends BroadcastReceiver { */ public static final String EXTRA_IS_CHARGING = "is_charging"; + private Context mContext; + private boolean mIsWifiConnected = false; + private String mWifiSsid; + private boolean mIsCharging = false; @TargetApi(16) public DeviceStateHolder(Context context) { + mContext = context; ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); mIsWifiConnected = cm.getNetworkInfo(ConnectivityManager.TYPE_WIFI).isConnected(); if (android.os.Build.VERSION.SDK_INT >= 16 && cm.isActiveNetworkMetered()) mIsWifiConnected = false; + if (mIsWifiConnected) { + updateWifiSsid(); + } } /** @@ -61,5 +71,25 @@ public class DeviceStateHolder extends BroadcastReceiver { public void update(Intent intent) { mIsWifiConnected = intent.getBooleanExtra(EXTRA_HAS_WIFI, mIsWifiConnected); mIsCharging = intent.getBooleanExtra(EXTRA_IS_CHARGING, mIsCharging); + + if (mIsWifiConnected) { + updateWifiSsid(); + } else { + mWifiSsid = null; + } + } + + public void updateWifiSsid() { + mWifiSsid = null; + WifiManager wifiManager = (WifiManager) mContext.getSystemService(Context.WIFI_SERVICE); + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + // may be null, if WiFi has been turned off in meantime + if (wifiInfo != null) { + mWifiSsid = wifiInfo.getSSID(); + } + } + + public String getWifiSsid() { + return mWifiSsid; } } diff --git a/src/main/java/com/nutomic/syncthingandroid/syncthing/SyncthingService.java b/src/main/java/com/nutomic/syncthingandroid/syncthing/SyncthingService.java index 0ab48604..f431da82 100644 --- a/src/main/java/com/nutomic/syncthingandroid/syncthing/SyncthingService.java +++ b/src/main/java/com/nutomic/syncthingandroid/syncthing/SyncthingService.java @@ -39,6 +39,7 @@ import java.security.SecureRandom; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; +import java.util.Set; /** * Holds the native syncthing instance and provides an API to access it. @@ -92,6 +93,7 @@ public class SyncthingService extends Service implements public static final String PREF_ALWAYS_RUN_IN_BACKGROUND = "always_run_in_background"; public static final String PREF_SYNC_ONLY_WIFI = "sync_only_wifi"; + public static final String PREF_SYNC_ONLY_WIFI_SSIDS = "sync_only_wifi_ssids_set"; public static final String PREF_SYNC_ONLY_CHARGING = "sync_only_charging"; public static final String PREF_USE_ROOT = "use_root"; private static final String PREF_NOTIFICATION_TYPE = "notification_type"; @@ -213,7 +215,7 @@ public class SyncthingService extends Service implements boolean prefStopNotCharging = prefs.getBoolean(PREF_SYNC_ONLY_CHARGING, false); shouldRun = (mDeviceStateHolder.isCharging() || !prefStopNotCharging) && - (mDeviceStateHolder.isWifiConnected() || !prefStopMobileData); + (!prefStopMobileData || isAllowedWifiConnected()); } // Start syncthing. @@ -261,6 +263,34 @@ public class SyncthingService extends Service implements onApiChange(); } + private boolean isAllowedWifiConnected() { + boolean wifiConnected = mDeviceStateHolder.isWifiConnected(); + if (wifiConnected) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); + Set ssids = sp.getStringSet(PREF_SYNC_ONLY_WIFI_SSIDS, new HashSet()); + if (ssids.isEmpty()) { + Log.d(TAG, "All SSIDs allowed for syncing"); + return true; + } else { + String ssid = mDeviceStateHolder.getWifiSsid(); + if (ssid != null) { + if (ssids.contains(ssid)) { + Log.d(TAG, "SSID " + ssid + " found in whitelist"); + return true; + } + Log.i(TAG, "SSID " + ssid + " not whitelisted"); + return false; + } else { + // Don't know the SSID (yet) (should not happen?!), so not allowing + Log.w(TAG, "SSID unknown (yet), cannot check SSID whitelist. Disallowing sync."); + return false; + } + } + } + Log.d(TAG, "Wifi not connected"); + return false; + } + /** * Shows or hides the persistent notification based on running state and * {@link #PREF_NOTIFICATION_TYPE}. @@ -290,7 +320,8 @@ public class SyncthingService extends Service implements public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (key.equals(PREF_NOTIFICATION_TYPE)) updateNotification(); - else if (key.equals(PREF_SYNC_ONLY_CHARGING) || key.equals(PREF_SYNC_ONLY_WIFI)) + else if (key.equals(PREF_SYNC_ONLY_CHARGING) || key.equals(PREF_SYNC_ONLY_WIFI) + || key.equals(PREF_SYNC_ONLY_WIFI_SSIDS)) updateState(); } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index bba5f32e..1dff15d4 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -238,6 +238,14 @@ Please report any problems you encounter via Github. Sync only on wifi + Restrict to certain wifi networks + + Sync on all wifi networks + + Sync only while connected to: %1$s + + Please turn on WiFi to select networks. + Use advanced Folder Picker Select any folder on the device for syncing diff --git a/src/main/res/xml/app_settings.xml b/src/main/res/xml/app_settings.xml index 5ddec148..dbc01dbe 100644 --- a/src/main/res/xml/app_settings.xml +++ b/src/main/res/xml/app_settings.xml @@ -19,6 +19,10 @@ android:title="@string/sync_only_wifi" android:defaultValue="false" /> + +