From 190826e660feadcc7d365fe7b962ab87cf3833d0 Mon Sep 17 00:00:00 2001 From: Catfriend1 Date: Sat, 22 Dec 2018 01:58:44 +0100 Subject: [PATCH] Refactor ConfigXml (#135) Changelog: - "Use default folder path given in config.xml" (#101) - "IllegalStateException: Fragment already added" (#108) - "Enhancement request for per-folder(device) sync conditions" (#110) - "NPE crash after key and config regeneration" (#141) - "Adjust the folder icon to show if it's send/receive or both" (#143) - "CPU percentage is not shown on the status tab" (#144) - "Always make individual sync conditions UI available" (#145) - "IntroducedBy deviceID lost on config change through wrapper UI" (#146) - "Wrapper doesn't use the same syntax as syncthing core's web UI for device addresses" (#147) - "Syncthing wrapper "emergency" shutdown on native binary crash doesn't work" (#148) Commits: * WIP * WIP * Get folder list and paused setting when syncthing is not running Preparation to solve #110 * Fix NPE in DeviceListFragment#DEVICES_COMPARATOR * Remove blank line * Add ConfigXml#getDevices and comparator Make ConfigXml#saveChanges public * SyncthingService evaluates per folder/device sync conditions when syncthing is not running via ConfigXml * Fix typos and add stubs * Fix build errors * DEBUG - Always run syncthing binary * Fix NPE at RunConditionMonitor pointer * Add setFolderPause, setDevicePause to ConfigXml * Improve logging * Remove test mode * Better log levels * Make ConfigXml#updateIfNeeded private * Remove SyncthingService#mStartupTask AsyncTask no longer needed * Update model/Options (fixes #101) * Fix NPE after config regeneration (fixes #140) * Refactor key and config generation Refactor ConfigXml public functions to allow checking if a valid config exists and trigger key and config (re)genration if something is corrupted. * Fix crash on export/import (fixes #142) * ApiRequest - Disable verbose log in release builds * ConfigXml#updateIfNeeded - Disable "startBrowser" because it applies to desktop environments and cannot start a mobile browser app * MainActivity - Always show all tabs * Show folder/device tab contents from config.xml if syncthing is not running * Update ConfigXml#getDevices return model - compression - introducer * Device tab - Hide in/out rate if syncthing is not running or if the device is paused * Update device item layout * MainActivity/Devices - Prevent showing outdated status after syncthing core transitioned from "active" to "disabled" * MainActivity/Folders - Prevent showing outdated status after syncthing core transitioned from "active" to "disabled" * Add ConfigRouter class Provides a transparent access to the config if ... a) Syncthing is running and REST API is available. b) Syncthing is NOT running and config.xml is accessed. * Add pref - Cache local device ID * Allow excluding self in ConfigRouter#getDevices * Allow excluding self in ConfigRouter#getDevices (2) * Update Folder model default values * Update Folder model defaults (2) - copiers - hashers * WIP - ConfigXml - FolderActivity Remove unused pref inject code Cache local device ID in pref Reduce verbose logging in release builds Extend ConfigXml#getFolders Extend ConfigXml#getDevices Fix ConfigXml#setDevicePause ToDo ConfigXml#getFolderIgnoreList needs to be implemented * Implemented ConfigXml#getFolderIgnoreList * Extend ConfigXml#getDevices - device.addresses * WIP - DeviceActivity Make it available when syncthing is not running * Fix unsuccessful API bumps while syncthing is starting * Fix space * Adjust the folder icon to show if it's send/receive or both (fixes #143) * Fix lint - item_device_list * Preserve active tab when syncthing core transitions between running and not running * Add xmlns:android to item_folder_list * Remove unused reference from item_folder_list * Add device icon to device tab * Fix CPU percentage not showing (fixes #144) * SyncthingService - Polish iterator code * Fix MainActivity#updateViewPager (fixes #108) * Add ConfigXml#updateFolder, updateDevice (1) * Add ConfigRouter#updateFolder, updateDevice * Add missing "final" to ConfigXml#updateDevice * WIP - FolderActivity - Update updateFolder via ConfigRouter ToDo: Implement ConfigRouter here. * ConfigRouter - Fix missing return * DeviceActivity - Update device via ConfigRouter * Always make individual sync conditions UI available (fixes #145) regardless if syncthing core is running or not. Remove SyncthingService dependency from SyncConditionsActivity * Fix incorrect folder type icon shown when syncthing core is not running * Add "introducedBy" to folder and device model (fixes #146) * Add Folder#getDevices to model * ConfigXml#updateFolder - Writeback devices sharing the folder Support preserving the "introducedBy" model field of Folder.java (fixes #146) * Add ConfigXml#updateFolder - Versioning * Remove SyncthingService dependency from FolderPickerActivity because it is no longer required. * Update ToDo remarks * Add ConfigXml#updateDevice - Addresses * Fix DeviceActivity#persistableAddresses to be more graceful (fixes #147) and accept the same address syntax as syncthing core web UI does. * Add ConfigXml#removeFolder, removeDevice * Add ConfigXml#addDevice, addFolder - Add ConfigXml#isDeviceIdValid - Do not allow adding empty folder labels or empty device names. - Update model Folder.java so ConfigXml can handle the ignorePerms XML attribute * Fix Syncthing wrapper "emergency" shutdown on native binary crash (fixes #148) * Update translation de * Add ConfigXml#postFolderIgnoreList * Update APK version to 0.14.54.3 / 4182 * Revert DEBUG - Always run syncthing binary * Update whatsnew --- app/build.gradle | 4 +- .../activities/DeviceActivity.java | 76 ++- .../activities/FirstStartActivity.java | 21 +- .../activities/FolderActivity.java | 52 +- .../activities/FolderPickerActivity.java | 29 +- .../activities/MainActivity.java | 78 +-- .../activities/SyncConditionsActivity.java | 25 +- .../activities/WebGuiActivity.java | 5 +- .../fragments/DeviceListFragment.java | 33 +- .../fragments/FolderListFragment.java | 22 +- .../fragments/StatusFragment.java | 2 +- .../syncthingandroid/http/ApiRequest.java | 6 +- .../syncthingandroid/model/Device.java | 16 +- .../syncthingandroid/model/Folder.java | 23 +- .../syncthingandroid/model/Options.java | 1 + .../syncthingandroid/service/Constants.java | 1 + .../syncthingandroid/service/RestApi.java | 7 +- .../service/RunConditionMonitor.java | 4 +- .../service/SyncthingRunnable.java | 8 +- .../service/SyncthingService.java | 212 ++++--- .../syncthingandroid/util/ConfigRouter.java | 166 +++++ .../syncthingandroid/util/ConfigXml.java | 593 ++++++++++++++++-- .../views/DevicesAdapter.java | 32 +- .../views/FoldersAdapter.java | 22 +- app/src/main/play/en-GB/whatsnew | 2 +- .../drawable-hdpi/ic_folder_receive_only.png | Bin 0 -> 1237 bytes .../res/drawable-hdpi/ic_folder_send_only.png | Bin 0 -> 1150 bytes .../drawable-ldpi/ic_folder_receive_only.png | Bin 0 -> 682 bytes .../res/drawable-ldpi/ic_folder_send_only.png | Bin 0 -> 653 bytes .../drawable-mdpi/ic_folder_receive_only.png | Bin 0 -> 711 bytes .../res/drawable-mdpi/ic_folder_send_only.png | Bin 0 -> 719 bytes .../drawable-xhdpi/ic_folder_receive_only.png | Bin 0 -> 1152 bytes .../drawable-xhdpi/ic_folder_send_only.png | Bin 0 -> 1169 bytes .../ic_folder_receive_only.png | Bin 0 -> 2313 bytes .../drawable-xxhdpi/ic_folder_send_only.png | Bin 0 -> 2198 bytes .../ic_folder_receive_only.png | Bin 0 -> 2366 bytes .../drawable-xxxhdpi/ic_folder_send_only.png | Bin 0 -> 2367 bytes app/src/main/res/layout/item_device_list.xml | 153 +++-- app/src/main/res/layout/item_folder_list.xml | 3 +- app/src/main/res/values-de/strings.xml | 11 +- app/src/main/res/values/strings.xml | 9 + 41 files changed, 1237 insertions(+), 379 deletions(-) create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java create mode 100644 app/src/main/res/drawable-hdpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-hdpi/ic_folder_send_only.png create mode 100644 app/src/main/res/drawable-ldpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-ldpi/ic_folder_send_only.png create mode 100644 app/src/main/res/drawable-mdpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-mdpi/ic_folder_send_only.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_folder_send_only.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_folder_receive_only.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png diff --git a/app/build.gradle b/app/build.gradle index 71c0a2d3..f4a1e1f5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,8 +37,8 @@ android { applicationId "com.github.catfriend1.syncthingandroid" minSdkVersion 16 targetSdkVersion 26 - versionCode 4181 - versionName "0.14.54.2" + versionCode 4182 + versionName "0.14.54.3" testApplicationId 'com.github.catfriend1.syncthingandroid.test' testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner' playAccountConfig = playAccountConfigs.defaultAccountConfig diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java index ba497076..decfbab5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java @@ -34,6 +34,7 @@ import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.util.Compression; +import com.nutomic.syncthingandroid.util.ConfigRouter; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; @@ -68,13 +69,15 @@ public class DeviceActivity extends SyncthingActivity public static final String EXTRA_IS_CREATE = "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; - private static final String TAG = "DeviceSettingsFragment"; + private static final String TAG = "DeviceActivity"; private static final String IS_SHOWING_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; private static final List DYNAMIC_ADDRESS = Collections.singletonList("dynamic"); + private ConfigRouter mConfig; + private Device mDevice; private View mIdContainer; @@ -186,6 +189,8 @@ public class DeviceActivity extends SyncthingActivity @Override public void onCreate(Bundle savedInstanceState) { + mConfig = new ConfigRouter(DeviceActivity.this); + super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); setContentView(R.layout.fragment_device); @@ -338,14 +343,9 @@ public class DeviceActivity extends SyncthingActivity @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - if (!mIsCreateMode) { - RestApi restApi = getApi(); // restApi != null because of State.ACTIVE - List devices = restApi.getDevices(false); + RestApi restApi = getApi(); + List devices = mConfig.getDevices(restApi, false); String passedId = getIntent().getStringExtra(EXTRA_DEVICE_ID); mDevice = null; for (Device currentDevice : devices) { @@ -427,8 +427,16 @@ public class DeviceActivity extends SyncthingActivity .show(); return true; } - getApi().addDevice(mDevice, error -> - Toast.makeText(this, error, Toast.LENGTH_LONG).show()); + if (isEmpty(mDevice.name)) { + Toast.makeText(this, R.string.device_name_required, Toast.LENGTH_LONG) + .show(); + return true; + } + mConfig.addDevice( + getApi(), + mDevice, + error -> Toast.makeText(this, error, Toast.LENGTH_LONG).show() + ); finish(); return true; case R.id.share_device_id: @@ -455,7 +463,8 @@ public class DeviceActivity extends SyncthingActivity return new android.app.AlertDialog.Builder(this) .setMessage(R.string.remove_device_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - getApi().removeDevice(mDevice.deviceID); + mConfig.removeDevice(getApi(), mDevice.deviceID); + mDeviceNeedsToUpdate = false; finish(); }) .setNegativeButton(android.R.string.no, null) @@ -474,6 +483,9 @@ public class DeviceActivity extends SyncthingActivity } } + /** + * Used in mIsCreateMode. + */ private void initDevice() { mDevice = new Device(); mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME); @@ -482,6 +494,7 @@ public class DeviceActivity extends SyncthingActivity mDevice.compression = METADATA.getValue(this); mDevice.introducer = false; mDevice.paused = false; + mDevice.introducedBy = ""; } private void prepareEditMode() { @@ -501,13 +514,14 @@ public class DeviceActivity extends SyncthingActivity */ private void updateDevice() { if (mIsCreateMode) { - // If we are about to create this folder, we cannot update via restApi. + // If we are about to create this device, we cannot update via restApi. return; } if (mDevice == null) { Log.e(TAG, "updateDevice: mDevice == null"); return; } + // Log.v(TAG, "deviceID=" + mDevice.deviceID + ", introducedBy=" + mDevice.introducedBy); // Save device specific preferences. Log.v(TAG, "updateDevice: mDevice.deviceID = \'" + mDevice.deviceID + "\'"); @@ -518,26 +532,42 @@ public class DeviceActivity extends SyncthingActivity ); editor.apply(); - // Update device via restApi and send the config to REST endpoint. - RestApi restApi = getApi(); - if (restApi == null) { - Log.e(TAG, "updateDevice: restApi == null"); - return; - } - restApi.updateDevice(mDevice); + // Update device using RestApi or ConfigXml. + mConfig.updateDevice(getApi(), mDevice); } private List persistableAddresses(CharSequence userInput) { - return isEmpty(userInput) - ? DYNAMIC_ADDRESS - : Arrays.asList(userInput.toString().split(" ")); + if (isEmpty(userInput)) { + return DYNAMIC_ADDRESS; + } + + /** + * Be fault-tolerant here. + * The user can write like this: + * tcp4://192.168.1.67:2222, dynamic + * tcp4://192.168.1.67:2222; dynamic + * tcp4://192.168.1.67:2222,dynamic + * tcp4://192.168.1.67:2222;dynamic + * tcp4://192.168.1.67:2222 dynamic + */ + String input = userInput.toString(); + input = input.replace(",", " "); + input = input.replace(";", " "); + input = input.replaceAll("\\s+", ", "); + // Log.v(TAG, "persistableAddresses: Cleaned user input=" + input); + + // Split and return the addresses as String[]. + return Arrays.asList(input.split(", ")); } private String displayableAddresses() { + if (mDevice.addresses == null) { + return ""; + } List list = DYNAMIC_ADDRESS.equals(mDevice.addresses) ? DYNAMIC_ADDRESS : mDevice.addresses; - return TextUtils.join(" ", list); + return TextUtils.join(", ", list); } @Override diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java index c0315577..bd2de43d 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java @@ -93,6 +93,21 @@ public class FirstStartActivity extends Activity { super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); + /** + * Check if a valid config exists that can be read and parsed. + */ + Boolean configParseable = false; + Boolean configExists = Constants.getConfigFile(this).exists(); + if (configExists) { + ConfigXml configParseTest = new ConfigXml(this); + try { + configParseTest.loadConfig(); + configParseable = true; + } catch (ConfigXml.OpenConfigException e) { + Log.d(TAG, "Failed to parse existing config. Will show key generation slide ..."); + } + } + /** * Check if prerequisites to run the app are still in place. * If anything mandatory is missing, the according welcome slide(s) will be shown. @@ -100,7 +115,7 @@ public class FirstStartActivity extends Activity { Boolean showSlideStoragePermission = !haveStoragePermission(); Boolean showSlideIgnoreDozePermission = !haveIgnoreDozePermission(); Boolean showSlideLocationPermission = !haveLocationPermission(); - Boolean showSlideKeyGeneration = !Constants.getConfigFile(this).exists(); + Boolean showSlideKeyGeneration = !configExists || !configParseable; /** * If we don't have to show slides for mandatory prerequisites, @@ -480,8 +495,10 @@ public class FirstStartActivity extends Activity { cancel(true); return null; } + configXml = new ConfigXml(firstStartActivity); try { - configXml = new ConfigXml(firstStartActivity); + // Create new secure keys and config. + configXml.generateConfig(); } catch (ExecutableNotFoundException e) { publishProgress(firstStartActivity.getString(R.string.executable_not_found, e.getMessage())); cancel(true); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java index 8946b72e..11c7970e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -36,6 +36,7 @@ import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.SyncthingApp; +import com.nutomic.syncthingandroid.util.ConfigRouter; import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; @@ -85,6 +86,7 @@ public class FolderActivity extends SyncthingActivity private static final String FOLDER_MARKER_NAME = ".stfolder"; // private static final String IGNORE_FILE_NAME = ".stignore"; + private ConfigRouter mConfig; private Folder mFolder; // Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21) private Uri mFolderUri = null; @@ -154,7 +156,7 @@ public class FolderActivity extends SyncthingActivity case R.id.device_toggle: Device device = (Device) view.getTag(); if (isChecked) { - mFolder.addDevice(device.deviceID); + mFolder.addDevice(device); } else { mFolder.removeDevice(device.deviceID); } @@ -166,6 +168,8 @@ public class FolderActivity extends SyncthingActivity @Override public void onCreate(Bundle savedInstanceState) { + mConfig = new ConfigRouter(FolderActivity.this); + super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); setContentView(R.layout.fragment_folder); @@ -384,14 +388,9 @@ public class FolderActivity extends SyncthingActivity @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - if (!mIsCreateMode) { - RestApi restApi = getApi(); // restApi != null because of State.ACTIVE - List folders = restApi.getFolders(); + RestApi restApi = getApi(); + List folders = mConfig.getFolders(restApi); String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID); mFolder = null; for (Folder currentFolder : folders) { @@ -405,11 +404,15 @@ public class FolderActivity extends SyncthingActivity finish(); return; } - restApi.getFolderIgnoreList(mFolder.id, this::onReceiveFolderIgnoreList); + mConfig.getFolderIgnoreList(restApi, mFolder, this::onReceiveFolderIgnoreList); checkWriteAndUpdateUI(); } + + // If the extra is set, we should automatically share the current folder with the given device. if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { - mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID)); + Device device = new Device(); + device.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); + mFolder.addDevice(device); mFolderNeedsToUpdate = true; } @@ -471,13 +474,14 @@ public class FolderActivity extends SyncthingActivity mCustomSyncConditionsDialog.setEnabled(mCustomSyncConditionsSwitch.isChecked()); // Populate devicesList. - List devicesList = getApi().getDevices(false); + RestApi restApi = getApi(); + List devicesList = mConfig.getDevices(restApi, false); mDevicesContainer.removeAllViews(); if (devicesList.isEmpty()) { addEmptyDeviceListView(); } else { - for (Device n : devicesList) { - addDeviceViewAndSetListener(n, getLayoutInflater()); + for (Device device : devicesList) { + addDeviceViewAndSetListener(device, getLayoutInflater()); } } @@ -511,6 +515,11 @@ public class FolderActivity extends SyncthingActivity .show(); return true; } + if (TextUtils.isEmpty(mFolder.label)) { + Toast.makeText(this, R.string.folder_label_required, Toast.LENGTH_LONG) + .show(); + return true; + } if (TextUtils.isEmpty(mFolder.path)) { Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) .show(); @@ -531,7 +540,7 @@ public class FolderActivity extends SyncthingActivity dfFolder.createDirectory(FOLDER_MARKER_NAME); } } - getApi().createFolder(mFolder); + mConfig.addFolder(getApi(), mFolder); finish(); return true; case R.id.remove: @@ -554,10 +563,7 @@ public class FolderActivity extends SyncthingActivity return new AlertDialog.Builder(this) .setMessage(R.string.remove_folder_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - RestApi restApi = getApi(); - if (restApi != null) { - restApi.removeFolder(mFolder.id); - } + mConfig.removeFolder(getApi(), mFolder.id); mFolderNeedsToUpdate = false; finish(); }) @@ -731,17 +737,13 @@ public class FolderActivity extends SyncthingActivity // Update folder via restApi and send the config to REST endpoint. RestApi restApi = getApi(); - if (restApi == null) { - Log.e(TAG, "updateFolder: restApi == null"); - return; - } // Update ignore list. String[] ignore = mEditIgnoreListContent.getText().toString().split("\n"); - restApi.postFolderIgnoreList(mFolder.id, ignore); + mConfig.postFolderIgnoreList(restApi, mFolder, ignore); - // Update model and send the config to REST endpoint. - restApi.updateFolder(mFolder); + // Update folder using RestApi or ConfigXml. + mConfig.updateFolder(restApi, mFolder); } @Override diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java index a832391c..b2e092d8 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java @@ -9,7 +9,6 @@ import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.Environment; -import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -31,8 +30,6 @@ import com.google.common.collect.Sets; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -45,7 +42,7 @@ import java.util.Iterator; * Activity that allows selecting a directory in the local file system. */ public class FolderPickerActivity extends SyncthingActivity - implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener { + implements AdapterView.OnItemClickListener { private static final String EXTRA_INITIAL_DIRECTORY = "com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY"; @@ -151,22 +148,6 @@ public class FolderPickerActivity extends SyncthingActivity mRootsAdapter.addAll(Sets.newTreeSet(roots)); } - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; - syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); - } - } - @Override public boolean onCreateOptionsMenu(Menu menu) { if (mListView.getAdapter() == mRootsAdapter) @@ -324,14 +305,6 @@ public class FolderPickerActivity extends SyncthingActivity } } - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - /** * Displays a list of all available roots, or if there is only one root, the * contents of that folder. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java index ffc05634..add7604c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java @@ -97,6 +97,9 @@ public class MainActivity extends SyncthingActivity private ActionBarDrawerToggle mDrawerToggle; private DrawerLayout mDrawerLayout; + + private Boolean oneTimeShot = true; + @Inject SharedPreferences mPreferences; /** @@ -104,9 +107,10 @@ public class MainActivity extends SyncthingActivity */ @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != mSyncthingServiceState) { - mSyncthingServiceState = currentState; + mSyncthingServiceState = currentState; + if (oneTimeShot) { updateViewPager(); + oneTimeShot = false; } switch (currentState) { @@ -178,15 +182,12 @@ public class MainActivity extends SyncthingActivity } if (savedInstanceState != null) { - mViewPager.setCurrentItem(savedInstanceState.getInt("currentTab")); if (savedInstanceState.getBoolean(IS_SHOWING_RESTART_DIALOG)){ showRestartDialog(); } - if(savedInstanceState.getBoolean(IS_QRCODE_DIALOG_DISPLAYED)) { + if (savedInstanceState.getBoolean(IS_QRCODE_DIALOG_DISPLAYED)) { showQrCodeDialog(savedInstanceState.getString(DEVICEID_KEY), savedInstanceState.getParcelable(QRCODE_BITMAP_KEY)); } - } else { - updateViewPager(); } fm.beginTransaction().replace(R.id.drawer, mDrawerFragment).commit(); @@ -211,31 +212,21 @@ public class MainActivity extends SyncthingActivity * Updates the ViewPager to show tabs depending on the service state. */ private void updateViewPager() { - Boolean isServiceActive = mSyncthingServiceState == SyncthingService.State.ACTIVE; - final int numPages = (isServiceActive ? 3 : 1); + final int numPages = 3; FragmentStatePagerAdapter mSectionsPagerAdapter = new FragmentStatePagerAdapter(getSupportFragmentManager()) { @Override public Fragment getItem(int position) { - if (isServiceActive) { - switch (position) { - case 0: - return mFolderListFragment; - case 1: - return mDeviceListFragment; - case 2: - return mStatusFragment; - default: - return null; - } - } else { - switch (position) { - case 0: - return mStatusFragment; - default: - return null; - } + switch (position) { + case 0: + return mFolderListFragment; + case 1: + return mDeviceListFragment; + case 2: + return mStatusFragment; + default: + return null; } } @@ -251,24 +242,15 @@ public class MainActivity extends SyncthingActivity @Override public CharSequence getPageTitle(int position) { - if (isServiceActive) { - switch (position) { - case 0: - return getResources().getString(R.string.folders_fragment_title); - case 1: - return getResources().getString(R.string.devices_fragment_title); - case 2: - return getResources().getString(R.string.status_fragment_title); - default: - return String.valueOf(position); - } - } else { - switch (position) { - case 0: - return getResources().getString(R.string.status_fragment_title); - default: - return String.valueOf(position); - } + switch (position) { + case 0: + return getResources().getString(R.string.folders_fragment_title); + case 1: + return getResources().getString(R.string.devices_fragment_title); + case 2: + return getResources().getString(R.string.status_fragment_title); + default: + return String.valueOf(position); } } }; @@ -316,9 +298,9 @@ public class MainActivity extends SyncthingActivity SyncthingService mSyncthingService = getService(); if (mSyncthingService != null) { mSyncthingService.unregisterOnServiceStateChangeListener(this); + mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mFolderListFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mDeviceListFragment); - mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mStatusFragment); } } @@ -329,9 +311,9 @@ public class MainActivity extends SyncthingActivity SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; SyncthingService syncthingService = syncthingServiceBinder.getService(); syncthingService.registerOnServiceStateChangeListener(this); + syncthingService.registerOnServiceStateChangeListener(mDrawerFragment); syncthingService.registerOnServiceStateChangeListener(mFolderListFragment); syncthingService.registerOnServiceStateChangeListener(mDeviceListFragment); - syncthingService.registerOnServiceStateChangeListener(mDrawerFragment); syncthingService.registerOnServiceStateChangeListener(mStatusFragment); } @@ -351,11 +333,9 @@ public class MainActivity extends SyncthingActivity putFragment.accept(mFolderListFragment); putFragment.accept(mDeviceListFragment); putFragment.accept(mStatusFragment); - putFragment.accept(mDrawerFragment); - outState.putInt("currentTab", mViewPager.getCurrentItem()); outState.putBoolean(IS_SHOWING_RESTART_DIALOG, mRestartDialog != null && mRestartDialog.isShowing()); - if(mQrCodeDialog != null && mQrCodeDialog.isShowing()) { + if (mQrCodeDialog != null && mQrCodeDialog.isShowing()) { outState.putBoolean(IS_QRCODE_DIALOG_DISPLAYED, true); ImageView qrCode = mQrCodeDialog.findViewById(R.id.qrcode_image_view); TextView deviceID = mQrCodeDialog.findViewById(R.id.device_id); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java index 3bb09b58..b4974fae 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java @@ -6,7 +6,6 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; -import android.os.IBinder; import android.support.v7.widget.SwitchCompat; import android.util.Log; import android.util.TypedValue; @@ -23,8 +22,6 @@ import com.google.common.collect.Sets; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; import com.nutomic.syncthingandroid.util.Util; import java.util.HashSet; @@ -41,8 +38,7 @@ import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; /** * Activity that allows selecting a directory in the local file system. */ -public class SyncConditionsActivity extends SyncthingActivity - implements SyncthingService.OnServiceStateChangeListener { +public class SyncConditionsActivity extends SyncthingActivity { private static final String TAG = "SyncConditionsActivity"; @@ -215,20 +211,9 @@ public class SyncConditionsActivity extends SyncthingActivity } }; - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; - syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this); - } - @Override protected void onDestroy() { super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); - } } @Override @@ -284,12 +269,4 @@ public class SyncConditionsActivity extends SyncthingActivity finish(); } - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java index 891946a8..2f3b4c60 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java @@ -133,9 +133,10 @@ public class WebGuiActivity extends SyncthingActivity setContentView(R.layout.activity_web_gui); mLoadingView = findViewById(R.id.loading); + mConfig = new ConfigXml(this); try { - mConfig = new ConfigXml(this); - } catch (Exception e) { + mConfig.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { throw new RuntimeException(e.getMessage()); } loadCaCert(); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java index c96c4824..9a298b95 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java @@ -20,6 +20,7 @@ import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; +import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.views.DevicesAdapter; import java.util.Collections; @@ -34,7 +35,14 @@ public class DeviceListFragment extends ListFragment implements SyncthingService private final static String TAG = "DeviceListFragment"; - private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> lhs.name.compareTo(rhs.name); + /** + * Compares devices by name, uses the device ID as fallback if the name is empty + */ + private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> { + String lhsName = lhs.name != null && !lhs.name.isEmpty() ? lhs.name : lhs.deviceID; + String rhsName = rhs.name != null && !rhs.name.isEmpty() ? rhs.name : rhs.deviceID; + return lhsName.compareTo(rhsName); + }; private Runnable mUpdateListRunnable = new Runnable() { @Override @@ -107,9 +115,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService * while the user is looking at the current tab. */ private void onTimerEvent() { - if (mServiceState != SyncthingService.State.ACTIVE) { - return; - } MainActivity mainActivity = (MainActivity) getActivity(); if (mainActivity == null) { return; @@ -117,10 +122,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService if (mainActivity.isFinishing()) { return; } - RestApi restApi = mainActivity.getApi(); - if (restApi == null) { - return; - } Log.v(TAG, "Invoking updateList on UI thread"); mainActivity.runOnUiThread(DeviceListFragment.this::updateList); } @@ -135,11 +136,19 @@ public class DeviceListFragment extends ListFragment implements SyncthingService if (activity == null || getView() == null || activity.isFinishing()) { return; } + List devices; RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; + if (restApi == null || + !restApi.isConfigLoaded() || + mServiceState != SyncthingService.State.ACTIVE) { + // Syncthing is not running or REST API is not available yet. + ConfigXml configXml = new ConfigXml(activity); + configXml.loadConfig(); + devices = configXml.getDevices(false); + } else { + // Syncthing is running and REST API is available. + devices = restApi.getDevices(false); } - List devices = restApi.getDevices(false); if (devices == null) { return; } @@ -153,7 +162,7 @@ public class DeviceListFragment extends ListFragment implements SyncthingService mAdapter.clear(); Collections.sort(devices, DEVICES_COMPARATOR); mAdapter.addAll(devices); - mAdapter.updateConnections(restApi); + mAdapter.updateDeviceStatus(restApi); mAdapter.notifyDataSetChanged(); setListShown(true); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java index d7382f10..8d1c7709 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java @@ -19,6 +19,7 @@ import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; +import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.views.FoldersAdapter; import java.util.List; @@ -102,9 +103,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService * while the user is looking at the current tab. */ private void onTimerEvent() { - if (mServiceState != SyncthingService.State.ACTIVE) { - return; - } MainActivity mainActivity = (MainActivity) getActivity(); if (mainActivity == null) { return; @@ -112,10 +110,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService if (mainActivity.isFinishing()) { return; } - RestApi restApi = mainActivity.getApi(); - if (restApi == null) { - return; - } Log.v(TAG, "Invoking updateList on UI thread"); mainActivity.runOnUiThread(FolderListFragment.this::updateList); } @@ -130,11 +124,19 @@ public class FolderListFragment extends ListFragment implements SyncthingService if (activity == null || getView() == null || activity.isFinishing()) { return; } + List folders; RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; + if (restApi == null || + !restApi.isConfigLoaded() || + mServiceState != SyncthingService.State.ACTIVE) { + // Syncthing is not running or REST API is not available yet. + ConfigXml configXml = new ConfigXml(activity); + configXml.loadConfig(); + folders = configXml.getFolders(); + } else { + // Syncthing is running and REST API is available. + folders = restApi.getFolders(); } - List folders = restApi.getFolders(); if (folders == null) { return; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java index 3d20709f..1174d6bc 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java @@ -260,7 +260,7 @@ public class StatusFragment extends ListFragment implements SyncthingService.OnS int announceConnected = announceTotal - Optional.fromNullable(systemStatus.discoveryErrors).transform(Map::size).or(0); synchronized (mStatusHolderLock) { - mCpuUsage = (systemStatus.cpuPercent / 100 < 1) ? "" : percentFormat.format(systemStatus.cpuPercent / 100); + mCpuUsage = (systemStatus.cpuPercent < 5) ? "" : percentFormat.format(systemStatus.cpuPercent / 100); mRamUsage = Util.readableFileSize(mActivity, systemStatus.sys); mAnnounceServer = (announceTotal == 0) ? "" : diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java index 23b67b54..f8a5e938 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java @@ -38,6 +38,8 @@ public abstract class ApiRequest { private static final String TAG = "ApiRequest"; + private static final Boolean ENABLE_VERBOSE_LOG = false; + /** * The name of the HTTP header used for the syncthing API key. */ @@ -92,7 +94,9 @@ public abstract class ApiRequest { */ void connect(int requestMethod, Uri uri, @Nullable String requestBody, @Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) { - Log.v(TAG, "Performing request to " + uri.toString()); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Performing request to " + uri.toString()); + } StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> { if (listener != null) { listener.onSuccess(reply); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java index 29a307fd..67fd543c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java @@ -10,17 +10,29 @@ public class Device { public List addresses; public String compression; public String certName; - public boolean introducer; + public String introducedBy = ""; + public boolean introducer = false; public boolean paused; public List pendingFolders; public List ignoredFolders; + /** + * Relevant fields for Folder.List "shared-with-device" model, + * handled by {@link ConfigRouter#updateFolder and ConfigXml#updateFolder} + * deviceID + * introducedBy + * Example Tag + * + * + * + */ + /** * Returns the device name, or the first characters of the ID if the name is empty. */ public String getDisplayName() { return (TextUtils.isEmpty(name)) - ? deviceID.substring(0, 7) + ? (TextUtils.isEmpty(deviceID) ? "" : deviceID.substring(0, 7)) : name; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java index a711e445..bd920003 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java @@ -21,15 +21,15 @@ public class Folder { public boolean fsWatcherEnabled = true; public int fsWatcherDelayS = 10; private List devices = new ArrayList<>(); - public int rescanIntervalS; - public final boolean ignorePerms = true; + public int rescanIntervalS = 3600; + public boolean ignorePerms = true; public boolean autoNormalize = true; public MinDiskFree minDiskFree; public Versioning versioning; - public int copiers; + public int copiers = 0; public int pullerMaxPendingKiB; - public int hashers; - public String order; + public int hashers = 0; + public String order = "random"; public boolean ignoreDelete; public int scanProgressIntervalS; public int pullerPauseS; @@ -52,12 +52,17 @@ public class Folder { public String unit; } - public void addDevice(String deviceId) { + public void addDevice(final Device device) { Device d = new Device(); - d.deviceID = deviceId; + d.deviceID = device.deviceID; + d.introducedBy = device.introducedBy; devices.add(d); } + public List getDevices() { + return devices; + } + public Device getDevice(String deviceId) { for (Device d : devices) { if (d.deviceID.equals(deviceId)) { @@ -78,6 +83,8 @@ public class Folder { @Override public String toString() { - return !TextUtils.isEmpty(label) ? label : id; + return (TextUtils.isEmpty(label)) + ? (TextUtils.isEmpty(id) ? "" : id) + : label; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java index c4790029..4e033c16 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java @@ -34,6 +34,7 @@ public class Options { public String[] alwaysLocalNets; public boolean overwriteRemoteDeviceNamesOnConnect; public int tempIndexMinBlocks; + public String defaultFolderPath; public static final int USAGE_REPORTING_UNDECIDED = 0; public static final int USAGE_REPORTING_DENIED = -1; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java index 7235d69d..16461764 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java @@ -68,6 +68,7 @@ public class Constants { * Cached information which is not available on SettingsActivity. */ public static final String PREF_LAST_BINARY_VERSION = "lastBinaryVersion"; + public static final String PREF_LOCAL_DEVICE_ID = "localDeviceID"; /** * {@link EventProcessor} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index ec69ac21..2b43b224 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -71,7 +71,6 @@ public class RestApi { private final static Comparator FOLDERS_COMPARATOR = (lhs, rhs) -> { String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; - return lhsLabel.compareTo(rhsLabel); }; @@ -453,7 +452,7 @@ public class RestApi { /** * This is only used for new folder creation, see {@link FolderActivity}. */ - public void createFolder(Folder folder) { + public void addFolder(Folder folder) { synchronized (mConfigLock) { // Add the new folder to the model. mConfig.folders.add(folder); @@ -500,7 +499,7 @@ public class RestApi { * * @param includeLocal True if the local device should be included in the result. */ - public List getDevices(boolean includeLocal) { + public List getDevices(Boolean includeLocal) { List devices; synchronized (mConfigLock) { devices = deepCopy(mConfig.devices, new TypeToken>(){}.getType()); @@ -850,6 +849,7 @@ public class RestApi { Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0")); if (folder.paused != !syncConditionsMet) { folder.paused = !syncConditionsMet; + Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0")); configChanged = true; } } @@ -872,6 +872,7 @@ public class RestApi { Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0")); if (device.paused != !syncConditionsMet) { device.paused = !syncConditionsMet; + Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0")); configChanged = true; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java index 58509851..cf9883df 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java @@ -58,7 +58,7 @@ public class RunConditionMonitor { } public interface OnSyncPreconditionChangedListener { - void onSyncPreconditionChanged(); + void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor); } private class SyncConditionResult { @@ -189,7 +189,7 @@ public class RunConditionMonitor { // Notify about changed preconditions. if (mOnSyncPreconditionChangedListener != null) { - mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(); + mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(this); } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java index e1456be7..6a9e094e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -40,6 +40,8 @@ import javax.inject.Inject; import eu.chainfire.libsuperuser.Shell; +import static com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE; + /** * Runs the syncthing binary from command line, and prints its output to logcat. * @@ -258,8 +260,10 @@ public class SyncthingRunnable implements Runnable { // Notify {@link SyncthingService} that service state State.ACTIVE is no longer valid. if (!returnStdOut && sendStopToService) { - mContext.startService(new Intent(mContext, SyncthingService.class) - .setAction(SyncthingService.ACTION_STOP)); + Intent intent = new Intent(mContext, SyncthingService.class); + intent.setAction(SyncthingService.ACTION_STOP); + intent.putExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, true); + mContext.startService(intent); } // Return captured command line output. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index 225874c5..21ab0ef2 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -8,6 +8,7 @@ import android.Manifest; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; @@ -20,6 +21,7 @@ import com.google.common.io.Files; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask; +import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.util.FileUtils; @@ -37,6 +39,7 @@ import java.nio.file.Paths; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.Set; @@ -112,6 +115,13 @@ public class SyncthingService extends Service { public static final String EXTRA_FOLDER_ID = "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID"; + /** + * Extra used together with ACTION_STOP. + */ + public static final String EXTRA_STOP_AFTER_CRASHED_NATIVE = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE"; + + public interface OnSyncthingKilled { void onKilled(); } @@ -155,7 +165,6 @@ public class SyncthingService extends Service { */ private State mCurrentState = State.DISABLED; private ConfigXml mConfig; - private StartupTask mStartupTask = null; private Thread mSyncthingRunnableThread = null; private Handler mHandler; @@ -276,9 +285,23 @@ public class SyncthingService extends Service { if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { shutdown(State.INIT, () -> launchStartupTask(SyncthingRunnable.Command.main)); - } else if (ACTION_STOP.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - shutdown(State.DISABLED, () -> { - }); + } else if (ACTION_STOP.equals(intent.getAction())) { + if (intent.getBooleanExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, false)) { + /** + * We were requested to stop the service because the syncthing native binary crashed. + * Changing mCurrentState prevents the "defer until syncthing is started" routine we normally + * use for clean shutdown to take place. Instead, we will immediately shutdown the crashed + * instance forcefully. + */ + mCurrentState = State.ERROR; + shutdown(State.DISABLED, () -> {}); + } else { + // Graceful shutdown. + if (mCurrentState == State.STARTING || + mCurrentState == State.ACTIVE) { + shutdown(State.DISABLED, () -> {}); + } + } } else if (ACTION_RESET_DATABASE.equals(intent.getAction())) { Log.i(TAG, "Invoking reset of database"); shutdown(State.INIT, () -> { @@ -348,10 +371,84 @@ public class SyncthingService extends Service { * After sync preconditions changed, we need to inform {@link RestApi} to pause or * unpause devices and folders as defined in per-object sync preferences. */ - private void onSyncPreconditionChanged() { - if (mRestApi != null) { - // Forward event. - mRestApi.onSyncPreconditionChanged(mRunConditionMonitor); + private void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor) { + synchronized (mStateLock) { + if (mRestApi != null && mCurrentState == State.ACTIVE) { + // Forward event because syncthing is running. + mRestApi.onSyncPreconditionChanged(runConditionMonitor); + return; + } + } + + Log.v(TAG, "onSyncPreconditionChanged: Event fired while syncthing is not running."); + Boolean configChanged = false; + ConfigXml configXml; + + // Read and parse the config from disk. + configXml = new ConfigXml(this); + try { + configXml.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { + mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "onSyncPreconditionChanged:ConfigXml.OpenConfigException"); + synchronized (mStateLock) { + onServiceStateChange(State.ERROR); + } + return; + } + + // Check if the folders are available from config. + List folders = configXml.getFolders(); + if (folders != null) { + for (Folder folder : folders) { + // Log.v(TAG, "onSyncPreconditionChanged: Processing config of folder.id=" + folder.id); + Boolean folderCustomSyncConditionsEnabled = mPreferences.getBoolean( + Constants.DYN_PREF_OBJECT_CUSTOM_SYNC_CONDITIONS(Constants.PREF_OBJECT_PREFIX_FOLDER + folder.id), false + ); + if (folderCustomSyncConditionsEnabled) { + Boolean syncConditionsMet = runConditionMonitor.checkObjectSyncConditions( + Constants.PREF_OBJECT_PREFIX_FOLDER + folder.id + ); + Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0")); + if (folder.paused != !syncConditionsMet) { + configXml.setFolderPause(folder.id, !syncConditionsMet); + Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0")); + configChanged = true; + } + } + } + } else { + Log.d(TAG, "onSyncPreconditionChanged: folders == null"); + return; + } + + // Check if the devices are available from config. + List devices = configXml.getDevices(false); + if (devices != null) { + for (Device device : devices) { + // Log.v(TAG, "onSyncPreconditionChanged: Processing config of device.id=" + device.deviceID); + Boolean deviceCustomSyncConditionsEnabled = mPreferences.getBoolean( + Constants.DYN_PREF_OBJECT_CUSTOM_SYNC_CONDITIONS(Constants.PREF_OBJECT_PREFIX_DEVICE + device.deviceID), false + ); + if (deviceCustomSyncConditionsEnabled) { + Boolean syncConditionsMet = runConditionMonitor.checkObjectSyncConditions( + Constants.PREF_OBJECT_PREFIX_DEVICE + device.deviceID + ); + Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0")); + if (device.paused != !syncConditionsMet) { + configXml.setDevicePause(device.deviceID, !syncConditionsMet); + Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0")); + configChanged = true; + } + } + } + } else { + Log.d(TAG, "onSyncPreconditionChanged: devices == null"); + return; + } + + if (configChanged) { + Log.v(TAG, "onSyncPreconditionChanged: Saving changed config to disk ..."); + configXml.saveChanges(); } } @@ -359,77 +456,25 @@ public class SyncthingService extends Service { * Prepares to launch the syncthing binary. */ private void launchStartupTask(SyncthingRunnable.Command srCommand) { - Log.v(TAG, "Starting syncthing"); synchronized (mStateLock) { if (mCurrentState != State.DISABLED && mCurrentState != State.INIT) { Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling."); return; } } - - // Safety check: Log warning if a previously launched startup task did not finish properly. - if (mStartupTask != null && (mStartupTask.getStatus() == AsyncTask.Status.RUNNING)) { - Log.w(TAG, "launchStartupTask: StartupTask is still running. Skipped starting it twice."); + Log.v(TAG, "Starting syncthing"); + onServiceStateChange(State.STARTING); + mConfig = new ConfigXml(this); + try { + mConfig.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { + mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException"); + synchronized (mStateLock) { + onServiceStateChange(State.ERROR); + } return; } - onServiceStateChange(State.STARTING); - mStartupTask = new StartupTask(this, srCommand); - mStartupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - /** - * Sets up the initial configuration, and updates the config when coming from an old - * version. - */ - private static class StartupTask extends AsyncTask { - private WeakReference refSyncthingService; - private SyncthingRunnable.Command srCommand; - - StartupTask(SyncthingService context, SyncthingRunnable.Command srCommand) { - refSyncthingService = new WeakReference<>(context); - this.srCommand = srCommand; - } - - @Override - protected Void doInBackground(Void... voids) { - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService == null) { - cancel(true); - return null; - } - try { - syncthingService.mConfig = new ConfigXml(syncthingService); - syncthingService.mConfig.updateIfNeeded(); - } catch (SyncthingRunnable.ExecutableNotFoundException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "SycnthingRunnable.ExecutableNotFoundException"); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - cancel(true); - } catch (ConfigXml.OpenConfigException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException"); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - cancel(true); - } - return null; - } - - @Override - protected void onPostExecute(Void aVoid) { - // Get a reference to the service if it is still there. - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService != null) { - syncthingService.onStartupTaskCompleteListener(srCommand); - } - } - } - - /** - * Callback on {@link StartupTask#onPostExecute}. - */ - private void onStartupTaskCompleteListener(SyncthingRunnable.Command srCommand) { if (mRestApi == null) { mRestApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); @@ -652,13 +697,13 @@ public class SyncthingService extends Service { mCurrentState = newState; mHandler.post(() -> { mNotificationHandler.updatePersistentNotification(this); - for (Iterator i = mOnServiceStateChangeListeners.iterator(); - i.hasNext(); ) { - OnServiceStateChangeListener listener = i.next(); + Iterator it = mOnServiceStateChangeListeners.iterator(); + while (it.hasNext()) { + OnServiceStateChangeListener listener = it.next(); if (listener != null) { listener.onServiceStateChange(mCurrentState); } else { - i.remove(); + it.remove(); } } }); @@ -773,7 +818,14 @@ public class SyncthingService extends Service { // Start syncthing after export if run conditions apply. if (mLastDeterminedShouldRun) { - launchStartupTask(SyncthingRunnable.Command.main); + Handler mainLooper = new Handler(Looper.getMainLooper()); + Runnable launchStartupTaskRunnable = new Runnable() { + @Override + public void run() { + launchStartupTask(SyncthingRunnable.Command.main); + } + }; + mainLooper.post(launchStartupTaskRunnable); } return failSuccess; } @@ -844,6 +896,7 @@ public class SyncthingService extends Service { case Constants.PREF_DEBUG_FACILITIES_AVAILABLE: case Constants.PREF_EVENT_PROCESSOR_LAST_SYNC_ID: case Constants.PREF_LAST_BINARY_VERSION: + case Constants.PREF_LOCAL_DEVICE_ID: Log.v(TAG, "importConfig: Ignoring cache pref \"" + prefKey + "\"."); break; default: @@ -929,7 +982,14 @@ public class SyncthingService extends Service { // Start syncthing after import if run conditions apply. if (mLastDeterminedShouldRun) { - launchStartupTask(SyncthingRunnable.Command.main); + Handler mainLooper = new Handler(Looper.getMainLooper()); + Runnable launchStartupTaskRunnable = new Runnable() { + @Override + public void run() { + launchStartupTask(SyncthingRunnable.Command.main); + } + }; + mainLooper.post(launchStartupTaskRunnable); } return failSuccess; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java new file mode 100644 index 00000000..5aaecca9 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java @@ -0,0 +1,166 @@ +package com.nutomic.syncthingandroid.util; + +import android.content.Context; +// import android.util.Log; + +import com.nutomic.syncthingandroid.model.Device; +import com.nutomic.syncthingandroid.model.Folder; +import com.nutomic.syncthingandroid.model.FolderIgnoreList; +import com.nutomic.syncthingandroid.service.RestApi; +import com.nutomic.syncthingandroid.util.ConfigXml; + +import java.util.List; + +/** + * Provides a transparent access to the config if ... + * a) Syncthing is running and REST API is available. + * b) Syncthing is NOT running and config.xml is accessed. + */ +public class ConfigRouter { + + private static final String TAG = "ConfigRouter"; + + public interface OnResultListener1 { + void onResult(T t); + } + + private final Context mContext; + + private ConfigXml configXml; + + public ConfigRouter(Context context) { + mContext = context; + configXml = new ConfigXml(mContext); + } + + public List getFolders(RestApi restApi) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + return configXml.getFolders(); + } + + // Syncthing is running and REST API is available. + return restApi.getFolders(); + } + + public void addFolder(RestApi restApi, Folder folder) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.addFolder(folder); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.addFolder(folder); // This will send the config afterwards. + } + + public void updateFolder(RestApi restApi, final Folder folder) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.updateFolder(folder); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.updateFolder(folder); // This will send the config afterwards. + } + + public void removeFolder(RestApi restApi, final String folderId) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.removeFolder(folderId); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.removeFolder(folderId); // This will send the config afterwards. + } + + /** + * Gets ignore list for given folder. + */ + public void getFolderIgnoreList(RestApi restApi, Folder folder, OnResultListener1 listener) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.getFolderIgnoreList(folder, folderIgnoreList -> listener.onResult(folderIgnoreList)); + return; + } + + // Syncthing is running and REST API is available. + restApi.getFolderIgnoreList(folder.id, folderIgnoreList -> listener.onResult(folderIgnoreList)); + } + + /** + * Stores ignore list for given folder. + */ + public void postFolderIgnoreList(RestApi restApi, Folder folder, String[] ignore) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.postFolderIgnoreList(folder, ignore); + return; + } + + // Syncthing is running and REST API is available. + restApi.postFolderIgnoreList(folder.id, ignore); + } + + public List getDevices(RestApi restApi, Boolean includeLocal) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + return configXml.getDevices(includeLocal); + } + + // Syncthing is running and REST API is available. + return restApi.getDevices(includeLocal); + } + + public void addDevice(RestApi restApi, Device device, OnResultListener1 errorListener) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.addDevice(device, error -> errorListener.onResult(error)); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.addDevice(device, error -> errorListener.onResult(error)); // This will send the config afterwards. + } + + public void updateDevice(RestApi restApi, final Device device) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.updateDevice(device); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.updateDevice(device); // This will send the config afterwards. + } + + public void removeDevice(RestApi restApi, final String deviceID) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.removeDevice(deviceID); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.removeDevice(deviceID); // This will send the config afterwards. + } + +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java index 12e53124..f1737655 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java @@ -1,13 +1,15 @@ package com.nutomic.syncthingandroid.util; import android.content.Context; -import android.content.SharedPreferences; import android.os.Build; import android.os.Environment; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; +import com.nutomic.syncthingandroid.model.Device; +import com.nutomic.syncthingandroid.model.Folder; +import com.nutomic.syncthingandroid.model.FolderIgnoreList; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.SyncthingRunnable; @@ -23,12 +25,16 @@ import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Random; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.inject.Inject; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -53,53 +59,117 @@ import org.xml.sax.InputSource; */ public class ConfigXml { + private static final String TAG = "ConfigXml"; + + private static final Boolean ENABLE_VERBOSE_LOG = false; + public class OpenConfigException extends RuntimeException { } - private static final String TAG = "ConfigXml"; + /** + * Compares devices by name, uses the device ID as fallback if the name is empty + */ + private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> { + String lhsName = lhs.name != null && !lhs.name.isEmpty() ? lhs.name : lhs.deviceID; + String rhsName = rhs.name != null && !rhs.name.isEmpty() ? rhs.name : rhs.deviceID; + return lhsName.compareTo(rhsName); + }; + + /** + * Compares folders by labels, uses the folder ID as fallback if the label is empty + */ + private final static Comparator FOLDERS_COMPARATOR = (lhs, rhs) -> { + String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; + String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; + return lhsLabel.compareTo(rhsLabel); + }; + + public interface OnResultListener1 { + void onResult(T t); + } + private static final int FOLDER_ID_APPENDIX_LENGTH = 4; private final Context mContext; - @Inject - SharedPreferences mPreferences; - private final File mConfigFile; private Document mConfig; - public ConfigXml(Context context) throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException { + public ConfigXml(Context context) { mContext = context; mConfigFile = Constants.getConfigFile(mContext); - boolean isFirstStart = !mConfigFile.exists(); - if (isFirstStart) { - Log.i(TAG, "App started for the first time. Generating keys and config."); - new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run(true); + } + + public void loadConfig() throws OpenConfigException { + parseConfig(); + updateIfNeeded(); + } + + /** + * This should run within an AsyncTask as it can cause a full CPU load + * for more than 30 seconds on older phone hardware. + */ + public void generateConfig() throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException { + // Create new secret keys and config. + Log.i(TAG, "(Re)Generating keys and config."); + new SyncthingRunnable(mContext, SyncthingRunnable.Command.generate).run(true); + parseConfig(); + Boolean changed = false; + + // Set local device name. + Log.i(TAG, "Starting syncthing to retrieve local device id."); + String localDeviceID = getLocalDeviceIDandStoreToPref(); + if (!TextUtils.isEmpty(localDeviceID)) { + changed = changeLocalDeviceName(localDeviceID) || changed; } - readConfig(); + // Set default folder to the "camera" folder: path and name + changed = changeDefaultFolder() || changed; - if (isFirstStart) { - boolean changed = false; - - Log.i(TAG, "Starting syncthing to retrieve local device id."); - String logOutput = new SyncthingRunnable(context, SyncthingRunnable.Command.deviceid).run(true); - String localDeviceID = logOutput.replace("\n", ""); - // Verify local device ID is correctly formatted. - if (localDeviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$")) { - changed = changeLocalDeviceName(localDeviceID) || changed; - } - changed = changeDefaultFolder() || changed; - - // Save changes if we made any. - if (changed) { - saveChanges(); - } + // Save changes if we made any. + if (changed) { + saveChanges(); } } - private void readConfig() { + private String getLocalDeviceIDfromPref() { + String localDeviceID = PreferenceManager.getDefaultSharedPreferences(mContext).getString(Constants.PREF_LOCAL_DEVICE_ID, ""); + if (TextUtils.isEmpty(localDeviceID)) { + Log.d(TAG, "getLocalDeviceIDfromPref: Local device ID unavailable, trying to retrieve it from syncthing ..."); + try { + localDeviceID = getLocalDeviceIDandStoreToPref(); + } catch (SyncthingRunnable.ExecutableNotFoundException e) { + Log.e(TAG, "getLocalDeviceIDfromPref: Failed to execute syncthing core"); + } + if (TextUtils.isEmpty(localDeviceID)) { + Log.e(TAG, "getLocalDeviceIDfromPref: Local device ID unavailable"); + } + } + return localDeviceID; + } + + private String getLocalDeviceIDandStoreToPref() throws SyncthingRunnable.ExecutableNotFoundException { + String logOutput = new SyncthingRunnable(mContext, SyncthingRunnable.Command.deviceid).run(true); + String localDeviceID = logOutput.replace("\n", ""); + + // Verify that local device ID is correctly formatted. + if (!isDeviceIdValid(localDeviceID)) { + Log.w(TAG, "getLocalDeviceIDandStoreToPref: Syncthing core returned a bad formatted device ID \"" + localDeviceID + "\""); + return ""; + } + + // Store local device ID to pref. This saves us expensive calls to the syncthing binary if we need it later. + PreferenceManager.getDefaultSharedPreferences(mContext).edit() + .putString(Constants.PREF_LOCAL_DEVICE_ID, localDeviceID) + .apply(); + Log.v(TAG, "getLocalDeviceIDandStoreToPref: Cached local device ID \"" + localDeviceID + "\""); + return localDeviceID; + } + + private void parseConfig() { if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) { + Log.w(TAG, "Failed to open config file '" + mConfigFile + "'"); throw new OpenConfigException(); } try { @@ -107,11 +177,16 @@ public class ConfigXml { InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8"); InputSource inputSource = new InputSource(inputStreamReader); inputSource.setEncoding("UTF-8"); - DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); - Log.d(TAG, "Parsing config file '" + mConfigFile + "'"); + DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder db = dbfactory.newDocumentBuilder(); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Parsing config file '" + mConfigFile + "'"); + } mConfig = db.parse(inputSource); inputStream.close(); - Log.i(TAG, "Successfully parsed config file"); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Successfully parsed config file"); + } } catch (SAXException | ParserConfigurationException | IOException e) { Log.w(TAG, "Failed to parse config file '" + mConfigFile + "'", e); throw new OpenConfigException(); @@ -141,7 +216,7 @@ public class ConfigXml { * username/password, and disables weak hash checking. */ @SuppressWarnings("SdCardPath") - public void updateIfNeeded() { + private void updateIfNeeded() { boolean changed = false; /* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ @@ -173,7 +248,7 @@ public class ConfigXml { Boolean forceHttps = Constants.osSupportsTLS12(); if (!gui.hasAttribute("tls") || Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) { - gui.setAttribute("tls", forceHttps ? "true" : "false"); + gui.setAttribute("tls", Boolean.toString(forceHttps)); changed = true; } @@ -222,6 +297,9 @@ public class ConfigXml { } } + // Disable "startBrowser" because it applies to desktop environments and cannot start a mobile browser app. + changed = setConfigElement(options, "startBrowser", "false") || changed; + // Save changes if we made any. if (changed) { saveChanges(); @@ -238,7 +316,9 @@ public class ConfigXml { /* Read existing config version */ int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version")); int iOldConfigVersion = iConfigVersion; - Log.i(TAG, "Found existing config version " + Integer.toString(iConfigVersion)); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Found existing config version " + Integer.toString(iConfigVersion)); + } /* Check if we have to do manual migration from version X to Y */ if (iConfigVersion == 27) { @@ -273,6 +353,438 @@ public class ConfigXml { } } + private Boolean getAttributeOrDefault(final Element element, String attribute, Boolean defaultValue) { + return element.hasAttribute(attribute) ? Boolean.parseBoolean(element.getAttribute(attribute)) : defaultValue; + } + + private Integer getAttributeOrDefault(final Element element, String attribute, Integer defaultValue) { + return element.hasAttribute(attribute) ? Integer.parseInt(element.getAttribute(attribute)) : defaultValue; + } + + private String getAttributeOrDefault(final Element element, String attribute, String defaultValue) { + return element.hasAttribute(attribute) ? element.getAttribute(attribute) : defaultValue; + } + + private Boolean getContentOrDefault(final Node node, Boolean defaultValue) { + return (node == null) ? defaultValue : Boolean.parseBoolean(node.getTextContent()); + } + + private Integer getContentOrDefault(final Node node, Integer defaultValue) { + return (node == null) ? defaultValue : Integer.parseInt(node.getTextContent()); + } + + private String getContentOrDefault(final Node node, String defaultValue) { + return (node == null) ? defaultValue : node.getTextContent(); + } + + public List getFolders() { + String localDeviceID = getLocalDeviceIDfromPref(); + List folders = new ArrayList<>(); + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + Folder folder = new Folder(); + folder.id = getAttributeOrDefault(r, "id", ""); + folder.label = getAttributeOrDefault(r, "label", ""); + folder.path = getAttributeOrDefault(r, "path", ""); + folder.type = getAttributeOrDefault(r, "type", Constants.FOLDER_TYPE_SEND_RECEIVE); + folder.autoNormalize = getAttributeOrDefault(r, "autoNormalize", true); + folder.fsWatcherDelayS =getAttributeOrDefault(r, "fsWatcherDelayS", 10); + folder.fsWatcherEnabled = getAttributeOrDefault(r, "fsWatcherEnabled", true); + folder.ignorePerms = getAttributeOrDefault(r, "ignorePerms", true); + folder.rescanIntervalS = getAttributeOrDefault(r, "rescanIntervalS", 3600); + + folder.copiers = getContentOrDefault(r.getElementsByTagName("copiers").item(0), 0); + folder.hashers = getContentOrDefault(r.getElementsByTagName("hashers").item(0), 0); + folder.order = getContentOrDefault(r.getElementsByTagName("order").item(0), "random"); + folder.paused = getContentOrDefault(r.getElementsByTagName("paused").item(0), false); + + // Devices + /* + + */ + NodeList nodeDevices = r.getElementsByTagName("device"); + for (int j = 0; j < nodeDevices.getLength(); j++) { + Element elementDevice = (Element) nodeDevices.item(j); + Device device = new Device(); + device.deviceID = getAttributeOrDefault(elementDevice, "id", ""); + + // Exclude self. + if (!TextUtils.isEmpty(device.deviceID) && !device.deviceID.equals(localDeviceID)) { + device.introducedBy = getAttributeOrDefault(elementDevice, "introducedBy", ""); + // Log.v(TAG, "getFolders: deviceID=" + device.deviceID + ", introducedBy=" + device.introducedBy); + folder.addDevice(device); + } + } + + // Versioning + /* + + + + + */ + folder.versioning = new Folder.Versioning(); + Element elementVersioning = (Element) r.getElementsByTagName("versioning").item(0); + folder.versioning.type = getAttributeOrDefault(elementVersioning, "type", ""); + NodeList nodeVersioningParam = elementVersioning.getElementsByTagName("param"); + for (int j = 0; j < nodeVersioningParam.getLength(); j++) { + Element elementVersioningParam = (Element) nodeVersioningParam.item(j); + folder.versioning.params.put( + getAttributeOrDefault(elementVersioningParam, "key", ""), + getAttributeOrDefault(elementVersioningParam, "val", "") + ); + /* + Log.v(TAG, "folder.versioning.type=" + folder.versioning.type + + ", key=" + getAttributeOrDefault(elementVersioningParam, "key", "") + + ", val=" + getAttributeOrDefault(elementVersioningParam, "val", "") + ); + */ + } + + // For testing purposes only. + // Log.v(TAG, "folder.label=" + folder.label + "/" +"folder.type=" + folder.type + "/" + "folder.paused=" + folder.paused); + folders.add(folder); + } + Collections.sort(folders, FOLDERS_COMPARATOR); + return folders; + } + + public void addFolder(final Folder folder) { + Log.v(TAG, "addFolder: folder.id=" + folder.id); + Node nodeConfig = mConfig.getDocumentElement(); + Node nodeFolder = mConfig.createElement("folder"); + nodeConfig.appendChild(nodeFolder); + Element elementFolder = (Element) nodeFolder; + elementFolder.setAttribute("id", folder.id); + updateFolder(folder); + } + + public void updateFolder(final Folder folder) { + String localDeviceID = getLocalDeviceIDfromPref(); + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + if (folder.id.equals(getAttributeOrDefault(r, "id", ""))) { + // Found folder node to update. + r.setAttribute("label", folder.label); + r.setAttribute("path", folder.path); + r.setAttribute("type", folder.type); + r.setAttribute("autoNormalize", Boolean.toString(folder.autoNormalize)); + r.setAttribute("fsWatcherDelayS", Integer.toString(folder.fsWatcherDelayS)); + r.setAttribute("fsWatcherEnabled", Boolean.toString(folder.fsWatcherEnabled)); + r.setAttribute("ignorePerms", Boolean.toString(folder.ignorePerms)); + r.setAttribute("rescanIntervalS", Integer.toString(folder.rescanIntervalS)); + + setConfigElement(r, "copiers", Integer.toString(folder.copiers)); + setConfigElement(r, "hashers", Integer.toString(folder.hashers)); + setConfigElement(r, "order", folder.order); + setConfigElement(r, "paused", Boolean.toString(folder.paused)); + + // Update devices that share this folder. + // Pass 1: Remove all devices below that folder in XML except the local device. + NodeList nodeDevices = r.getElementsByTagName("device"); + for (int j = nodeDevices.getLength() - 1; j >= 0; j--) { + Element elementDevice = (Element) nodeDevices.item(j); + if (!getAttributeOrDefault(elementDevice, "id", "").equals(localDeviceID)) { + Log.v(TAG, "updateFolder: nodeDevices: Removing deviceID=" + getAttributeOrDefault(elementDevice, "id", "")); + removeChildElementFromTextNode(r, elementDevice); + } + } + + // Pass 2: Add devices below that folder from the POJO model. + final List devices = folder.getDevices(); + for (Device device : devices) { + Log.v(TAG, "updateFolder: nodeDevices: Adding deviceID=" + device.deviceID); + Node nodeDevice = mConfig.createElement("device"); + r.appendChild(nodeDevice); + Element elementDevice = (Element) nodeDevice; + elementDevice.setAttribute("id", device.deviceID); + elementDevice.setAttribute("introducedBy", device.introducedBy); + } + + // Versioning + // Pass 1: Remove all versioning nodes in XML (usually one) + /* + NodeList nlVersioning = r.getElementsByTagName("versioning"); + for (int j = nlVersioning.getLength() - 1; j >= 0; j--) { + Log.v(TAG, "updateFolder: nodeVersioning: Removing versioning node"); + removeChildElementFromTextNode(r, (Element) nlVersioning.item(j)); + } + */ + Element elementVersioning = (Element) r.getElementsByTagName("versioning").item(0); + if (elementVersioning != null) { + Log.v(TAG, "updateFolder: nodeVersioning: Removing versioning node"); + removeChildElementFromTextNode(r, elementVersioning); + } + + // Pass 2: Add versioning node from the POJO model. + Node nodeVersioning = mConfig.createElement("versioning"); + r.appendChild(nodeVersioning); + elementVersioning = (Element) nodeVersioning; + if (!TextUtils.isEmpty(folder.versioning.type)) { + elementVersioning.setAttribute("type", folder.versioning.type); + for (Map.Entry param : folder.versioning.params.entrySet()) { + Log.v(TAG, "updateFolder: nodeVersioning: Adding param key=" + param.getKey() + ", val=" + param.getValue()); + Node nodeParam = mConfig.createElement("param"); + elementVersioning.appendChild(nodeParam); + Element elementParam = (Element) nodeParam; + elementParam.setAttribute("key", param.getKey()); + elementParam.setAttribute("val", param.getValue()); + } + } + + break; + } + } + } + + public void removeFolder(String folderId) { + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = nodeFolders.getLength() - 1; i >= 0; i--) { + Element r = (Element) nodeFolders.item(i); + if (folderId.equals(getAttributeOrDefault(r, "id", ""))) { + // Found folder node to remove. + Log.v(TAG, "removeFolder: Removing folder node, folderId=" + folderId); + removeChildElementFromTextNode((Element) r.getParentNode(), r); + break; + } + } + } + + public void setFolderPause(String folderId, Boolean paused) { + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + if (getAttributeOrDefault(r, "id", "").equals(folderId)) + { + setConfigElement(r, "paused", Boolean.toString(paused)); + break; + } + } + } + + /** + * Gets ignore list for given folder. + */ + public void getFolderIgnoreList(Folder folder, OnResultListener1 listener) { + FolderIgnoreList folderIgnoreList = new FolderIgnoreList(); + File file; + FileInputStream fileInputStream = null; + try { + file = new File(folder.path, ".stignore"); + if (file.exists()) { + fileInputStream = new FileInputStream(file); + InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, "UTF-8"); + byte[] data = new byte[(int) file.length()]; + fileInputStream.read(data); + folderIgnoreList.ignore = new String(data, "UTF-8").split("\n"); + } else { + // File not found. + Log.w(TAG, "getFolderIgnoreList: File missing " + file); + /** + * Don't fail as the file might be expectedly missing when users didn't + * set ignores in the past storyline of that folder. + */ + } + } catch (IOException e) { + Log.e(TAG, "getFolderIgnoreList: Failed to read '" + folder.path + "/.stignore' #1", e); + } finally { + try { + if (fileInputStream != null) { + fileInputStream.close(); + } + } catch (IOException e) { + Log.e(TAG, "getFolderIgnoreList: Failed to read '" + folder.path + "/.stignore' #2", e); + } + } + listener.onResult(folderIgnoreList); + } + + /** + * Stores ignore list for given folder. + */ + public void postFolderIgnoreList(Folder folder, String[] ignore) { + File file; + FileOutputStream fileOutputStream = null; + try { + file = new File(folder.path, ".stignore"); + if (!file.exists()) { + file.createNewFile(); + } + fileOutputStream = new FileOutputStream(file); + // Log.v(TAG, "postFolderIgnoreList: Writing .stignore content=" + TextUtils.join("\n", ignore)); + fileOutputStream.write(TextUtils.join("\n", ignore).getBytes("UTF-8")); + fileOutputStream.flush(); + } catch (IOException e) { + /** + * This will happen on external storage folders which exist outside the + * "/Android/data/[package_name]/files" folder on Android 5+. + */ + Log.w(TAG, "postFolderIgnoreList: Failed to write '" + folder.path + "/.stignore' #1", e); + } finally { + try { + if (fileOutputStream != null) { + fileOutputStream.close(); + } + } catch (IOException e) { + Log.e(TAG, "postFolderIgnoreList: Failed to write '" + folder.path + "/.stignore' #2", e); + } + } + } + + public List getDevices(Boolean includeLocal) { + String localDeviceID = getLocalDeviceIDfromPref(); + List devices = new ArrayList<>(); + + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + Device device = new Device(); + device.compression = getAttributeOrDefault(r, "compression", "metadata"); + device.deviceID = getAttributeOrDefault(r, "id", ""); + device.introducedBy = getAttributeOrDefault(r, "introducedBy", ""); + device.introducer = getAttributeOrDefault(r, "introducer", false); + device.name = getAttributeOrDefault(r, "name", ""); + device.paused = getContentOrDefault(r.getElementsByTagName("paused").item(0), false); + + // Addresses + /* + +
dynamic
+
tcp4://192.168.1.67:2222
+
+ */ + device.addresses = new ArrayList<>(); + NodeList nodeAddresses = r.getElementsByTagName("address"); + for (int j = 0; j < nodeAddresses.getLength(); j++) { + String address = getContentOrDefault(nodeAddresses.item(j), ""); + device.addresses.add(address); + // Log.v(TAG, "getDevices: address=" + address); + } + + // For testing purposes only. + // Log.v(TAG, "getDevices: device.name=" + device.name + "/" +"device.id=" + device.deviceID + "/" + "device.paused=" + device.paused); + + // Exclude self if requested. + Boolean isLocalDevice = !TextUtils.isEmpty(device.deviceID) && device.deviceID.equals(localDeviceID); + if (includeLocal || !isLocalDevice) { + devices.add(device); + } + } + } + Collections.sort(devices, DEVICES_COMPARATOR); + return devices; + } + + public void addDevice(final Device device, OnResultListener1 errorListener) { + if (!isDeviceIdValid(device.deviceID)) { + errorListener.onResult(mContext.getString(R.string.device_id_invalid)); + return; + } + + Log.v(TAG, "addDevice: deviceID=" + device.deviceID); + Node nodeConfig = mConfig.getDocumentElement(); + Node nodeDevice = mConfig.createElement("device"); + nodeConfig.appendChild(nodeDevice); + Element elementDevice = (Element) nodeDevice; + elementDevice.setAttribute("id", device.deviceID); + updateDevice(device); + } + + public void updateDevice(final Device device) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (device.deviceID.equals(getAttributeOrDefault(r, "id", ""))) { + // Found device to update. + r.setAttribute("compression", device.compression); + r.setAttribute("introducedBy", device.introducedBy); + r.setAttribute("introducer", Boolean.toString(device.introducer)); + r.setAttribute("name", device.name); + + setConfigElement(r, "paused", Boolean.toString(device.paused)); + + // Addresses + // Pass 1: Remove all addresses in XML. + NodeList nodeAddresses = r.getElementsByTagName("address"); + for (int j = nodeAddresses.getLength() - 1; j >= 0; j--) { + Element elementAddress = (Element) nodeAddresses.item(j); + Log.v(TAG, "updateDevice: nodeAddresses: Removing address=" + getContentOrDefault(elementAddress, "")); + removeChildElementFromTextNode(r, elementAddress); + } + + // Pass 2: Add addresses from the POJO model. + if (device.addresses != null) { + for (String address : device.addresses) { + Log.v(TAG, "updateDevice: nodeAddresses: Adding address=" + address); + Node nodeAddress = mConfig.createElement("address"); + r.appendChild(nodeAddress); + Element elementAddress = (Element) nodeAddress; + elementAddress.setTextContent(address); + } + } + + break; + } + } + } + } + + public void removeDevice(String deviceID) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (deviceID.equals(getAttributeOrDefault(r, "id", ""))) { + // Found device to remove. + Log.v(TAG, "removeDevice: Removing device node, deviceID=" + deviceID); + removeChildElementFromTextNode((Element) r.getParentNode(), r); + break; + } + } + } + } + + public void setDevicePause(String deviceId, Boolean paused) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (getAttributeOrDefault(r, "id", "").equals(deviceId)) + { + setConfigElement(r, "paused", Boolean.toString(paused)); + break; + } + } + } + } + + /** + * If an indented child element is removed, whitespace and line break will be left by + * Element.removeChild(). + * See https://stackoverflow.com/questions/14255064/removechild-how-to-remove-indent-too + */ + private void removeChildElementFromTextNode(Element parentElement, Element childElement) { + Node prev = childElement.getPreviousSibling(); + if (prev != null && + prev.getNodeType() == Node.TEXT_NODE && + prev.getNodeValue().trim().length() == 0) { + parentElement.removeChild(prev); + } + parentElement.removeChild(childElement); + } + private boolean setConfigElement(Element parent, String tagName, String textContent) { Node element = parent.getElementsByTagName(tagName).item(0); if (element == null) { @@ -347,16 +859,23 @@ public class ConfigXml { return sb.toString(); } + /** + * Returns if a syncthing device ID is correctly formatted. + */ + private Boolean isDeviceIdValid(final String deviceID) { + return deviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$"); + } + /** * Writes updated mConfig back to file. */ - private void saveChanges() { + public void saveChanges() { if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) { Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file."); return; } - Log.i(TAG, "Writing updated config file"); + Log.i(TAG, "Saving config file"); File mConfigTempFile = Constants.getConfigTempFile(mContext); try { // Write XML header. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java b/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java index c531efeb..33601992 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.res.Resources; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,11 +17,16 @@ import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.util.Util; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + /** * Generates item views for device items. */ public class DevicesAdapter extends ArrayAdapter { + private static final String TAG = "DevicesAdapter"; + private Connections mConnections; public DevicesAdapter(Context context) { @@ -36,6 +42,7 @@ public class DevicesAdapter extends ArrayAdapter { convertView = inflater.inflate(R.layout.item_device_list, parent, false); } + View rateInOutView = convertView.findViewById(R.id.rateInOutContainer); TextView name = convertView.findViewById(R.id.name); TextView status = convertView.findViewById(R.id.status); TextView download = convertView.findViewById(R.id.download); @@ -52,16 +59,17 @@ public class DevicesAdapter extends ArrayAdapter { } if (conn == null) { - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + // Syncthing is not running. + rateInOutView.setVisibility(GONE); + status.setVisibility(GONE); status.setText(r.getString(R.string.device_state_unknown)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); return convertView; } if (conn.paused) { - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + rateInOutView.setVisibility(GONE); + status.setVisibility(VISIBLE); status.setText(r.getString(R.string.device_paused)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black)); return convertView; @@ -70,6 +78,8 @@ public class DevicesAdapter extends ArrayAdapter { if (conn.connected) { download.setText(Util.readableTransferRate(getContext(), conn.inBits)); upload.setText(Util.readableTransferRate(getContext(), conn.outBits)); + rateInOutView.setVisibility(VISIBLE); + status.setVisibility(VISIBLE); if (conn.completion == 100) { status.setText(r.getString(R.string.device_up_to_date)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green)); @@ -81,8 +91,8 @@ public class DevicesAdapter extends ArrayAdapter { } // !conn.connected - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + rateInOutView.setVisibility(GONE); + status.setVisibility(VISIBLE); status.setText(r.getString(R.string.device_disconnected)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); return convertView; @@ -91,14 +101,20 @@ public class DevicesAdapter extends ArrayAdapter { /** * Requests new connection info for all devices visible in listView. */ - public void updateConnections(RestApi api) { + public void updateDeviceStatus(RestApi restApi) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running. Clear last state. + mConnections = null; + return; + } for (int i = 0; i < getCount(); i++) { - api.getConnections(this::onReceiveConnections); + restApi.getConnections(this::onReceiveConnections); } } private void onReceiveConnections(Connections connections) { mConnections = connections; + // This will invoke "getView" for all elements. notifyDataSetChanged(); } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java b/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java index 8e5d9839..32ab4f96 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java @@ -62,6 +62,20 @@ public class FoldersAdapter extends ArrayAdapter { mContext.startService(intent); }); binding.openFolder.setOnClickListener(view -> { FileUtils.openFolder(mContext, folder.path); } ); + + // Update folder icon. + int drawableId = R.drawable.ic_folder_black_24dp_active; + switch (folder.type) { + case Constants.FOLDER_TYPE_RECEIVE_ONLY: + drawableId = R.drawable.ic_folder_receive_only; + break; + case Constants.FOLDER_TYPE_SEND_ONLY: + drawableId = R.drawable.ic_folder_send_only; + break; + default: + } + binding.openFolder.setImageResource(drawableId); + updateFolderStatusView(binding, folder); return binding.getRoot(); } @@ -72,6 +86,7 @@ public class FoldersAdapter extends ArrayAdapter { binding.items.setVisibility(GONE); binding.override.setVisibility(GONE); binding.size.setVisibility(GONE); + binding.state.setVisibility(GONE); setTextOrHide(binding.invalid, folder.invalid); return; } @@ -80,6 +95,7 @@ public class FoldersAdapter extends ArrayAdapter { boolean outOfSync = folderStatus.state.equals("idle") && neededItems > 0; boolean overrideButtonVisible = folder.type.equals(Constants.FOLDER_TYPE_SEND_ONLY) && outOfSync; binding.override.setVisibility(overrideButtonVisible ? VISIBLE : GONE); + binding.state.setVisibility(VISIBLE); if (outOfSync) { binding.state.setText(mContext.getString(R.string.status_outofsync)); binding.state.setTextColor(ContextCompat.getColor(mContext, R.color.text_red)); @@ -143,8 +159,9 @@ public class FoldersAdapter extends ArrayAdapter { * Requests updated folder status from the api for all visible items. */ public void updateFolderStatus(RestApi restApi) { - if (restApi == null) { - Log.e(TAG, "updateFolderStatus: restApi == null"); + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running. Clear last state. + mLocalFolderStatuses.clear(); return; } @@ -158,6 +175,7 @@ public class FoldersAdapter extends ArrayAdapter { private void onReceiveFolderStatus(String folderId, FolderStatus folderStatus) { mLocalFolderStatuses.put(folderId, folderStatus); + // This will invoke "getView" for all elements. notifyDataSetChanged(); } diff --git a/app/src/main/play/en-GB/whatsnew b/app/src/main/play/en-GB/whatsnew index 6d5ae720..4ed7219d 100644 --- a/app/src/main/play/en-GB/whatsnew +++ b/app/src/main/play/en-GB/whatsnew @@ -2,10 +2,10 @@ Maintenance * Updated syncthing core to v0.14.54 * Fallback to http if https tls 1.2 is unavailable on Android 4.x Enhancements +* UI can be used to configure even when syncthing is not running [NEW] * Added "Recent changes" UI, click files to open [NEW] * Specify sync conditions differently for each folder, device [NEW] * Added offline 'tips & tricks' content [NEW] -* UI explains why syncthing is running (or not) Fixes * Fixed the "battery eater" * Android 8 and 9 support diff --git a/app/src/main/res/drawable-hdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-hdpi/ic_folder_receive_only.png new file mode 100644 index 0000000000000000000000000000000000000000..d8def2518f3128af09c42ddd61234ea2e4d4b73a GIT binary patch literal 1237 zcmV;`1SPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1Zqh{K~z{r?U;RR z6J;F7fA`w18++@zF}F3z!@)3_7~&dYusd*e7{%z3<&~ z_r2fuo_n4Y9yBnt)y$LW%NxcrlCnv(e;e5c)5dyc^5+i@Aw?4j89KJn)No)p?-N!g zbV!Oo0W3LpDCgCl=5Ok;RwTv^j<%W*DLNY`=Q9%m1OM2cP93hKAwd%f8A_;AoZQQ6 zC&ZGWg;+AQ5KD#@;(;?1bL#lA^+eh5Eq}EH5{;xFUZ94KuQ`3?mengF>27eGd7`%I zMiK7*5h-C5F@;GfTHXKV%f_onibB=Uw$#*$k-VHkBN?VbiXoHOcO;hnRbmCuKEfo2HtG|)9THY=Jj=#J8SU?bRdej)!?4AL{;J%_8IcO8`eIONPIIf;l{G2P-T!9lej7rLVJAi#}rB2hGvN` zK5a2YqWMd`aD3WhibQjTLa4`=SWJXpum69?Af&bjf; zjj3-}sb%NMDa!~e&2)`{L}tNFurlD-$6$TiOD)F-#g6`Qy;VjhsH1>iPGcl0Z%0*g zSD*EmCH)4U+wt|F&+73cF+^5*Q|XTVXEt9fUp6qB^xolqzM7;6BkHUUnG7suZdvzH z*B%^FQY_0KO0p^#2H1ivI7bnzh2Vz|{?UD8&4z;xrX+2AC%zcSBs zmoIC5t)lniAN(&;@l<9Wh)vI#Ee?;g>G~*A;vx7{6e)t+ajjgK99a2qK}lhLuF*Qi zO54vnvF9;D`&bd!sdJMa-kv~(X2F1@eFePpN~OFMNgA~9(XQ097^+Ra7 z5hVq((=l+3OJLAbXsRH9=gF4J&(80`Tq=)*ectqBL=00000NkvXXu0mjf_N+aO literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/ic_folder_send_only.png b/app/src/main/res/drawable-hdpi/ic_folder_send_only.png new file mode 100644 index 0000000000000000000000000000000000000000..56de3e43dd6afdb6643c7cbbec36dae9d0584eb8 GIT binary patch literal 1150 zcmV-^1cCdBP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1QSU_K~z{r?U;RN z8)Y2FfA@Y%x+YDV)VXFxD^%BRZNUgy#6&4$6aQHMQGB5*s7TS4fes;mtW%2C^$%Yr z{-Lgdn_9(fuA>tyB&!y+ow%{hlF3S{jo6xWGr2U)r zdGzT+QY9KT1%Zwwqna6;rN@s>@>U$f0LMj|z%vieo&JU2vUg(g^p|@$bZSXp=vWeJ zOzO%UIhS@)MDACJSq75x)U)^X2Y)>9IDQt%hABC+<(Q6#7gv~3A*c+jt<1b-HolS~ z%a2KqYl+WrWQAhV!%TdJA}bb?9%kYz8ky0U^e8pHf-{(jNe`p(=|&dns`q|xA|^eI z#-}?|OXYgs8n-KWk+`O2o`(#4`8_bhZ*I6a_QE+N=Adh+odFM5x4E0DZ`wiAlpVHPFO)0xa2xQxIrOl*99XtXvyK7b?*LaaztHQt|mvCS4o(h5v%j)PCG z@A+`*mfZ;*L)nFYLU-;uKYXYI--{%}T+a=1U05|#j8#L$ST$6P|C}LGeXC<^9rccW);GCOcG#`+|5 z!qt#P0XR`sJMS)u<3ds*-6>GCb49C{MSz-CcS3@3trl%TS2bDqmOxs;hQ<1~ek}K5 z!9a`eM07DYbOsITAxpqHo`{D>UanHhdZxTK=CZN0Hh2X*2Xpr4QkigHL@l>%YLI7X zMjI+b;;+b_-=gszZ$qJKevYmcWM*r(O$=R$|`ORj`9uDytzW7l4Fx zSrS21ToWX*w2zeNvx215B;Z%9+_E9lm%X~lg{<;YCeL{(+Oio*WPqvM6l#5-A+*85 zrzD&P-mx*#_Slw?Bh8)CF7Z5WU>}kolWv9|Mn3WP?>pe{yjT93%Rq$kc6`tic++1q z$L*|8)6g~ayY?5&*w9Cp*v$U@Ztp9v-PPi6 zIcQ-Sr#60*NpW=K*s-~eOy;Z5<$WFqd{&DwGKg(I8^fiEg0W;Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0zXMaK~y+TV`RVs z)TQLCey}pKf!KdIRewCavF8L>0t;Y*(imWQjFgp-w8&u*5t02MEXMP3o!{Jl=1^%2 zfYYEq{~6gB85t4szrO!uWn^H6@-P5SgGdE~&mfdoTQy}B&)x6;jDP{e#PMvE!K#yw z`hWd}$o>2G_k~%^l#<~Bs00|G7?iv4;(-WV7FS6=rVmgd4DjbS_c2MkY|kTe;%`BPzyNE? zUR!8wG574x-7mlX=Ya}=!Jj|eXF-PSm>m8ZDg*~uV=cJ<=m}57|Gr{e3_qYkU_*FK zlcTU10s@Tb_UaBt->}60hYG*|6Oa1AB8!g?)?WOy{?R+mYt}A)5uYwCj=S*uj|x-? z1TeEP{1kAU#Mqr|GJnN|{|QiGIQaXI={K9OL6N33>zZG0-~SP2yxDp5$^Td;CMH&> z5;*wq;(;U11j)?wkM+;n^F252-P!i!?cLocpED+b4Eg`>A6Onn!vNlt&B*kh{m-|z zhTneuHTVbg``ryuGEF#FT1SSy?!s>PZ5>elvgBu07*qoM6N<$g1-h#fdBvi literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-ldpi/ic_folder_send_only.png b/app/src/main/res/drawable-ldpi/ic_folder_send_only.png new file mode 100644 index 0000000000000000000000000000000000000000..506b68ae1a5391dfb8a694abb43df7ffd633d552 GIT binary patch literal 653 zcmV;80&@L{P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0wPI7K~y+TV;}^w zytE9|Y;zQ$T%3RjN@E4Ru|`q1K5?Jo{CIYkWmvZkR2VD3Y0#7uvykognHRkO{*RS~ z^_R-`>uXn7hE2d}7$cNM0U$%xUHiNG<4>pn6#V_m`IJY+-|fhn(rZv5IKXfb$U7Tu z{9lG{2naAUf0zDzYu!d{-b677**j1n41mj<%upH({(ct-ViJ>b;Nj(C;$&xG`2PI| zR0bFZOiT4>#!d&j(5c zm!j7uF&%@5paQ(uZetYZi_46w*{`u>h!_Pnb0;+1-iL!i*_r*-T+J%4q zJOWkC!1U`6!xt!Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0$fQ%K~zXfW1ux) zgwl9HjD_m{3tu@L;EMnMV_;Sd-Ed)jLm*THCt!locmcm4y8}BL8v`2*vvV+T{eLR} zmBtH*vVamWY5_qoI4oeqmY9i3;8>JZ%ZoeQHusv(&cUt#;xjV+WO#V}0>hU-sBU55 z6=KkkS7!M4AHx6q>Fb5l`dNif8!Zn&L?8gef@XiihLsN)>)Dx!bRrb|{q>LGr&4*~ z^^Fl5ph94P5k$2c&o`tPu^;&L7wR-3z~5gC4D7OgiRgxd08VF4NV42N_uNkhHUd6@ z819pJa$Dm9s1ORktDBG|gbW7(f&q=o5@HMo0fH8Q0J;8zyc`X z-#-RcS(gh}_D$aavxNCCBi!(8Z{31jcYX z4gC4T$iS`Pmw0St-GV}Y{e~USjW1k3HE%;_oXPy97yl-(vS81ve|~;raF~2ekco+j z1#dX9u>5EEdSlak_vD!k{TnYffDCVovs#bOa4=wGVwmX2_yLa!1OQp`>g=?7Qw#h5 zW~Qe9S6%)Q05kmmzrPIsp;_kNUmzR8gae{100Aau7KVSnzcBm-#tSnO#Lxf!a56~i z*f-jUe?9P%U5Y`^(Y;aV`}YILmJn?LHt_ea0E1Fc!2~l&_J#l1`5!Fo?`%;0`S;*I zs5}TTUg~iR2%GzSJv$pYcKv7g&&zI2qig9Tx=q twY__>12C03fb~!b{P`ttptRKY002@N=+Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0%S=kj1PrhS z(ez2H5`O&usQ_X#F@0h|1< z85v}I>ij?PiXF;>0Cric1IIQ@c7$?q0wyR;GcalaK|pg0SU`lnykf%-4mPj=GGG;w zzi{RFt_`3(fex71Il)DCVW4KiwriO3KREOl9$sACz;eFbxFL20Lp=}JDpc@KKm(X7 zcWt+U z2dE$;Bg5Bs&l&zP1B;OVe}Q~vkPyQ^V9~+I3}ge9z$_pb$)IwGmEkADmrt)4zJ7+{ zPcMP72h{qPlR;X?zR^bf>w%x_QVe>I?u|ma*2m}BC002ovPDHLkV1mbg BJ*NNw literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png new file mode 100644 index 0000000000000000000000000000000000000000..50834a803ea1485d42204194f5a7d64d690302c3 GIT binary patch literal 1152 zcmV-`1b_R9P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1Qkg{K~!i%?U;RN z8)Y2FzsX%M-I_LWWeXKjDCpENYWIgux49+^UuG#f=>*3Zf^NP{nFv)FO4mQ8sGw|P zh)ylyO8skPj5*mq>4pPQ=ZXkhr*pL(E^9@TG~4Aa$vxh4dy*|_UM{&OJA`~7FV8Pr z?)$m??(#f`IyU2AuUuHu)=t7L3m~xZJTw_m--`%;x0Bm`xqZ>$XZ@uA=Ot)vV1*(i z=Voxnu0O{PZE^-#$r4WXYMGSiDt_c|MsR`1juUKT8EygU^jIEyQvG< zOH>DdO+Yd=le>;nWN$cNdOAIN;OMRk@qY&dA%7DG#D*zMj2^_vSr;1091<<5 zz!%(%-lgqO(}Yzs5NN4pNNuZvuRgPQj1@~_O5m-I+sUxYi-q&TXK+A}%7@QDCFY0E zpi5kfAiiZ3(}OF^<})SmVEbJ}UhFnE-{*lA$E<4yroMlmcU|Ivxy@CFCJb0EVHgWcq*ga5$SBC!fj zGU3O9!yC0A>a1)@Emf#f2Jhmra zVl8>%i)l!zA8YpNbDO;I8$OQ(Zqs*IO$#W|FuM0$GWKU||H}s}N#B<#G*=@)DI!q_ ztKlh1Jc3^*E*P8hSyDP(@HSn>w>usQZ9hf=tU3=K;q&1mK%o-uFph5vgkJeN5@OXX zhj%_o`iA(1r|@Z^9MVv)#xW`~BKy&^^#%0! zV~8YJS&ksNv5S?^k4x! z*V8g0X)jjzW&Ctrh1b)VGsRKnX0UGmRB+G3?4S7J&`@CQ+q*+8o?C8UK+<}HI0bs% z-+&O~3M4phF6s~k(ZqUO`eUE*LC&Y|efSlTL>s%REE-ZuEBgB%&$Ig90{jPK;>ybG S8B8Al0000=~T^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xhdpi/ic_folder_send_only.png new file mode 100644 index 0000000000000000000000000000000000000000..bf02698c9423a3784c621485e4f1002de70c4f50 GIT binary patch literal 1169 zcmV;C1aA9@P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1SUyDK~!i%?U{XO z++-ZbzjsN~?smOv)vgqJos(U~%}qyfzD#;OijLXskBv?64fcoIM3ITm3Ik~mtTF{f z%Kp$6irCUWhK^Bm|A^M%P=~rAf_8J8tHV`?>!mN3OYV}#Bz+Rvqu0D#ZpY+4kY4gj zF8BRBdGhk4DD!{iY@(GktX)UeHnfswj*~W8zBM?Th>^zVCeqTS)C|tsofH&a{3X^aw;B9yV_Y#f-iWAI0zKK?9rH8(+$39T9sDA{Uo4UfcMeyYy9bHEOxht`v>Z^H;w z&_W(EDIU=y7ZRU5=j)-Rg0R(v>>$Z>#s*53&Uk=l0;QrXP-@BorKT)UYRUqorYulu zN+aOl(c231Y4p*CCNdRTWqZ&f&hgkja4r79j)hUv$^8-1`O_LyTRn}={)6>BSH?bl z+8@`Jzwg~h_Vi~FtkhRqqX^*Fyo#waujtwJKj(VyBYQ_!)Y#pLiY(0Fj;`^sFZQq2 zgJ@Vbg7)L}FJS7MBWA-L-yJ30mqS?PV7MppQ;79ljUQ7RPv6nc?>tEMew0PE0|6*$ zOxGU9r7sVfVcgJu*GBShZIz?psyLU#FB2E_-*tZu_^S}Q_6A}{f6pCO+2+mGCh}L< zS@>KEI*FM;9c~P?A=Y)$vBYb!MlzVJL!iLRi2E+I2w6&Dem;dj^W(TPAVbNcK%xI&|NdIX%2L2Bk9{CeR- zQ)%@1b)@S|6@+bX{tFv?JgQ1+sDvonK&cY(qj2(s-bKbApz1 zwkcWUd+AX^0Tvv43NMZRY5xCsWbn7{U}!ad>>uf-C7sV%&TRc$^usXz))w;Qu>U4^ zAq^RV+VR8p-wRBPgJ;7FcVt;WQm(<4h=3o?OQ^}YSBUE4Nc*wL`1_C1JMrbg!M4`d zd%p^WZ@PgFk<|{OUI2V%uU8=i+M7qlzqqJEMDXbyxO%BiU$JEE(RV*499zO(Wr>2U jcsn{fpUAPgWdZ&J*mn3fl5jj~00000NkvXXu0mjfa^p2F literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png new file mode 100644 index 0000000000000000000000000000000000000000..7e534dd4e0a4e5acdb773ebea19cf78b68eb1f9e GIT binary patch literal 2313 zcmV+k3HJ7hP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2%bqqK~#8N?VNpV z6XzYrzrOQZ{E|4t4#BY#$^v_#9fN6NY1cGM)iIbB1e1@IRsCaBOu#hCm?(6ARoea- z+6t_Kv>IbwTRO?1Z;@!#Ze?r}4Y01GUAL9ejV8p{jvYuG+h?E8cYDscWDYMmzg~iR zAM4_~=XZXdyYKV+KKJ5tprN5rO?2|RYP1<0ey^+6_mPo?UM`y%5y-@lZ2b|ghQkc^ z(Scq$pbDxA^z6Di|L-n&+Q$U5hb1i++_;bdZ**fp*Mt4p+wLFw{bn5_N2(4bzpH|! z*5x9fG)E|gIS(YiL&nS9($_qkAvgv(o|M(N%xy?hE5!W z(JJ7d$DYA|(iX(H;u{u)Eb2eI5`2vl>z3XDmY?bFzd+V^K;YAi{Zkzqx8LtvCkKkA zB!M2Ju*2(ZZ0`}RxP8&W_FB8mGjl~*PE+XYwJ=la3Nseb&PFIpbB?+{! z<|-E261Y6dAzjjdq6Qq;SQ4XwQFQG5Zeab-cdd{EMNwAPpMC5easO!!?*>vZ8_GO> zR}3k0NXI2^Lt-S#+;!+w;5i@l)l}}YH zmQQ8vrjD)hsn!57Mdj?Ko~`n!x2%Z>xN&oi{gXD3*k8(<+$eym)`wq{)2 zh>X@j9DIE0$1>9d>hdlYQ%)OLdAV#zqr7Pu;#WeP0A8%!|irH)DG#vI93UC(@Qmo(;FYpV|)?>g1p6OKBphHg%}|16i6g~$W$^$3mC zBY5iJsVZ|bZ|?lOztzk6I-j(n(OO~O32CIVPK*RcvHrQgFV_ih99*qCDMxdheXK*= z|5gg;hQ=UfmLPF8%yb+&eVwhO%yGF~&3FGIc;^DA8G0gUWw$kjSn$8N@3+aR?aX;e zZ?-{iA`_XfXoC`z5oQyI#wH5KH+MYxJkyrm_L^{5j^+r4!b~U{VIm_}m}oT2L?VIf zj#iR5FP=BIclQiOC2vHEudRo2A ziG>GoVJI@y-0XBq4n2&PI#?WyXmXgK<3mWr{|z=7fz572Lz5E@ivb1|#kmo)D3FYF zq92i<+`ZTTtI9v?9I4$RTzES6IX>t3upESU7`vzP} zyKuE{^R#vDX>9d-o9Fr7B^TvJxona-G)R|&B#@U_Y1zbL9&0w-jpd!KjVzl2e`Y^( z?}o2M_7Ec^c> z<8?~xJ@6a9%}o1l(ZQ)i1aeN<1dbhN9^UDj_AK>v0&U-Yw7sr|_Q;E$5VFV;fikId j|K7c8hU7vGWq|(y;l&|=xMEU900000NkvXXu0mjf_McDG literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png new file mode 100644 index 0000000000000000000000000000000000000000..4df6e5d2c86ab97e70db32c64a6602aa3935ecfd GIT binary patch literal 2198 zcma);`9BkmAIC+TYn1!u<0`QvhMX}pa^_}|#DrX%jr5T#XHsL%Ig&F)h8!`{#)d@r zI$Ex&4`MAWSB{U*fAIa`^?tpc@5kf))9aUax`Q2zk4KV+jg5`Z%F^8N#4Z057w}}d zFq&Z}b_(SPgR(Vzl3qQ5(*dTorfh74r@RNZ0Vm8IZs~zyW8)|Nr&E-$T0b^6E;TE2 zQ|D-JMp5iNsQ?*DPxls_-lmk*49N;XD_OU_$j!1Pjb9%(ExcQvreN^Q4t|63c(A(R zB{{>yjCMLP(e;{0h-DS61#OwN(%RUQVKU33og^aeEX3G

wHqsWL*kr<7q(ic zW51L|VXzJjiWTXN-|pQQb&!BSW{8N?@qnB4dQ(BY^U1?&&{@ru-S_9)&1wRF(Wi&? z<@N0oz#zH+rz4@ZXD3rL!DwO-xcCrZ1XDMbp+K#Y#H z3RZMuoXnqpKUwY1*a|$pIPdB^F%sGo;NagXXm-DI;GDznli<-gfFS$`QBG;X=9RAYFJy5r;RZ@UA|{F2m>0Nz7E=3cYSLzNV^WYe-ip|ZMou`)Ae;r zFW=OYYwKrK1@G>zl@itShyIw)hA8)IW28#NXQNpMIg&-wArJO2uc*EZ^I{jW>v3IY zJ-K}{v;>9Gaz(UdWYtsX{OztYf8Tg4>nV@916|L<_q))9(o&#DLNe1R~+>;7T-kYO6a=-74Gc|Y{`JRE<1@fMoZjp4zCVnj8Bo43}*0d49HaG-|CD2xb9QNUeO_L)IsNe z+^ko+v2FL??QA7Krv#I7>9E zxlj2YfP6y$_=BJCmOekEG@b?H>rSiYyUB$|Q2J@%^4DzK8caICO@y?#VJB?w9fOxT z@?ZmPqI>QOQSB0^XdegX=ecRB&ra8w2-dP&J?BwM`2RsUDy5T^#}=%sXzfx1Pq_W8 zI7{~H2C#E&uYQ6A6*oKcuKm5c)F=BKEvH4J)zTWpq&G&aleF~5m9tcDV1Xzw7NFfZ ztVz{tm6vQz=-kY_m6pd7S@sG))KY{Mom5jE@q1Q~vD?;VxJT(7p1_^$SEGe|9mR2q zAPG_2y!MQZreoE1eIV~fbHVH)&Lx`G6}0G?~_JXAie0E`{2JPr+p?AD{fjvfjLoni!hluvfp8Q1~Va#}9y(>~fM z-ABwyQ4^kHz9{Le7M&0ij?V0@`bEfQ#e}}PxPXb;-7X$2-&l$jAJEq@IvCCl(P_QU zt{(81X@o?>kuv7BD84$8E)#X%Dm3cVRAW^b$K+1Tm$6mQCiCFKabfp}MRGMDW(%Z) z)yD}<=KCh|keS=c;*m_>9jW}OhXK(O24b8kx2`^}xa4j8b@VwWqbrVCIL{A1Ih#{f zTs2UF<)H{&Pyk)#*(uvTY}C7v8($_qz*$<0!l6x)t$}C=>tbk_VUY%xEiYLcLa%)K z-3u&6#Lyqi#Zz)OejjB+*D`8N4G*r66)qRXmp60Z>bbA{vG;p2yg((7k!v{d04iB? z(0w6j@Ohy$ZQht#(ed&WkhrZcLC%(plz@Ae1|b6NqE^P8TMA4&y#Dbiwb~1$4`&Bq z%t&Q%G9R}^A>rW6bPsX7jD`dtB3dQeP@mVc9vI#NXx8J^r{?xpnM>d8#7@oedP~2E zyS$YV<7JHereQn&bbgb1G=PCA*48l%>&=nhm^lUF9Am3zVEq=?rK7i#<7X`YxZaJZ zJDd0M006Zf-?TDl}`dh^xv->ZJ W7*kcKW)2Ln(|fV z+JDg3LU9xN1%_isV7iXA6A18@=hvuc_W5c8Q>aNTd%fUL$FDRazT?3u)=1<=Xa@I7 zNH;G>Zbrt*|K3$(;s*paEyUaW(cXJXdsMBQobD{X&2qmN!&Tw<8-Uz8{-1EP1Qk$X zE`c$Ax9yH7Zuzy86rbV>-t{NvjTXm`T&-^XYxsS1QvqRZWJdUT`L4KIt*_~LV^prc z%E0uGVT_TPD))4uO>&fF;q~Y0^~hhpT!fwqlk8~41s}-uZRCUEI)w_Oa7>(Z$OS#%Tr#CWC{-Ia^lW3>T(*_ zh}l4pvI=LpVW_P7aGItx`EZMA%_T#f8&Txn**DY8OKPKioLZwm`KBS0UurbDA99m1 zQB)lNza#Y_2YX=s$?)`%3tT1J*i##aiE^{t;Bx}Iq@m2%OFwK_FZ8)SN**VQN5H`^$dpK zOGzoQ*yBwgZ=f?P#!vLcHxe741wo^ou15oiZE$ z!r5qa`yYM=jZ~QoZRH@ok!N*nHH8XpuJ=k~oHO4@!N+{gAk3jl7ASNKSSHt%MCm=? zG_|vB7}h1i$DM%gQOlEgQDm6euli~>TbsQM>DM_^y966dT!whD?&}#s8z%5^_0T>4 z6vuxEZ`J1OoL)4deril%J;<{)Pry!?zv{RIQ#s3&jeR|Q@vi(R^Ue(HpZN`3hQ!pt zvv!74I4xlr^M`0y#Psob<45akGSo~aO_Tk3Y6*E4BXrz9y+og;!4LCg%L>VG$Q`kt{=9F8PWAD~mxPpR31pa_1cAD~AlencORUvb7 zS^rl6ZVJIl%{vgP6YSWgT0p2N=rPyBt#K|ABfeMn;X!0h|3ep>1ykO+k~zCd=O37#wVTFXH$uICXN5ZV{s1k z9~`3w)TNk}cyRS?;PJ`_S!v41ilaBv91y|py*lxM))plN7kcS|%g{;`JICtWiIOXn zsBdpIuxHUqk?K2MO1@RQ<~!@D4iv>|FvNR~r?=p^u5V1`^-ZBv-G4-I!ac2)9CMR{}C*H2Xn3eBt@~fQQZMlXwSc0Nf|P z$nXk2IVxLqiGTP>8l~15>61{Zo1&gkpwmpzw3D34$?yzRW<{%4sz)+75vMgLGby7W+RvlvmIXJW=yGt=hW z8P?eNXe1x0pQ1qD0>!@U67lo|O-3m-BZt2+TGbkK0NW#r;jB+D%5QacYh2I*qTBt9 zTfE$!`uYpUueJNeP@ne&QU{9x$846G8m7^Ak#SZnN)o1sb@2;jRhG&Pt`%Ba%5{F- zaS|U2BxxRc^~~V!uY%lEk4`WwN=I44Q#rlL<81Tb9=aS|*CV znc3DXZgro})`Up)v7~;y5&zP*B==nU-z0s=r{<%NFYMPg$sfPN8+iR%?n{*FeI^1Q z)c)imXnM!1U?+aayORCr1%ef0b#gTL^`v?>8MYY~P7hw?2tM}X=X)N7!ebwu^|Tla z15XoAwl9z;4C(`Jh_6R`9`)|;u~g-7MJEb=LG~3BX66C}nZoI;<&#W|>2l1WF>y5G z)~fMJAzh328&8;!sN3p5cr)eE2`$Y^wEl21%sWy*Zm4VkwKU5VGm$6izfBfT;evyv zJS!`-Ni<_)Ym@voJIh`Cbtd}on-MYJhWk1E==+Z#*~ZEX>UX9^Z|fA)QtxK=`ZJzo zWLykZ@=*u-xLaAy9GIa{iAAhQJ-wU)2VD(e2w8CXbScVj>CVnE(ezmlPsyz4j8R{i z8Id84U*UbUj)0J+C#ibPBwLBNV<2)=bp1LBOBt#$m-f-=KS;-HVpe?`Q|V9UvOdS$u-II)>XlJcjja z@c}URiG+6FhR}bT034H00OU*S5+G}_?x%hz28CIL^9?TVq*4#yOt;!MWfy)O7L|if xg)es2Bvf*I%`$=01wd!90UpY2D=!9Z%4(&!I(Vlfrfhc}z}dmwo@R$8{SP?alcoRw literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png new file mode 100644 index 0000000000000000000000000000000000000000..f21d8aa27e9589257fc00e028c4d3d729d183d60 GIT binary patch literal 2367 zcmbW3`9BkmAICQ>vAKuI6@4fySI$MQ_%Lb7@j+?ku1tI)#4vKEh(c&c$DFxxHJNj3 z$;L85m>hE@$@w*W_5BmR@5kfyem!3A_w(`o`JHNyvK9nOg8=}5AkxOt;m|RE#K(Ks zzds8%JTzd4gS7?VEnRluFo68cFPQ@XwHXku=h4H=A8g|m0sshg{SlBFRO$l&@C_m@ z&7C69KM2uS8Gkt{8Rca}Pa_Q8tA|oKSb+xT8!DnbUmfdy|qdnM1!> zP(OZ(PADSHm@qGgw~@Y`-;0i&Bys2*V$J_b3sSuzSl3jzh$*e~7i^0h8tm^-6ZOGe z-wLoR%umhwJg949&abGNA{h(i5$}sr;Hqb}t~7bnj?5f~dgk+z-y3wl!oV9h(@O>d ziNQWYuT+@LXGc>iUhYXDlLNo;`-yhx3bARzNTBl?0yiNP?hRl)s#+7%L3FGN_Id2w z`n&+@GE#`0tDrJ! zxp-qwOHqKM&TZ1(M&=P0)+=y&l+gI#WAiRMw;nElJOx$IhX-}rZDO65Os zJ%4D!7xO|Qf1s&^61jfE?CQTOp%3N&(@t&DwW$6*GfEXs&+BBz%tJGZ(G&6|!q zBd)Q;hxy+l+KscT-!!B8an!j(sOR7KIU%i}*$UNksP5R*$T8(pNON4i8kx3EBWKDy z*J37Twxm?;>LNUe`d&BY@P1|4Ug}P5`Ld{fl^xl>u+S(g$dmj4ZCjeh#RakuLh*bm ze1qYoCDyTgAa9U!M6G;TJV0jy`~X@Gmo2?>bL;1z9UkQZCfru2ee71l>-kIE-DuQv z@?N+fZE;{(e7!-e->RA4^b(1E%8lR)XZ$OA))-?Cw z{p-hpyAxwl#f~EBlSJf-F4U|~(<35N-2>D%4tnmaXE+%pRVZzMLNigILatGmT>Idl z_~!U*&w8B|dyM6&p?5zB7t*BB!o(3Zw+j6lSQ3U?Cdz|tIH2vyLQ@S zm4}ZaW~P+`h_3~SJpBJwQy_L%(2_o=37EV|kGx!WRO6Ih#HvSB*==%Y>LUTKNcqO( z7{*u4w&wNgeYs(*dXZRzbpiWo7U_leUPM#u=dc5Q3hIYjWNW?fBxN+7|J#mk|Et#H z)!M(!&Ls^}3wvCZ1O()--{GDCp2IcO7L$2lH_!&aq^@S&^(#ZQ2y)vn?Ug+sYGVB( zPvv{T^^77pnf9US`fV+T_>kQkaPUfMRpljH%#xy5`xCcPy#-ZF?`19VRLpiz#+)Sk zk+nguV9Tz*-s+VL>$WDR5u!a5j>h+{wmaOASZ9>tIc`(Ef}W3aq8W{vL)0FGQC2;K zXN>6sQPRj&N#iwr_FX^8MPRCkOgfwSwe`o8sn+K2AGV6_BwjQ z`8QG){ptN%V&OG~zX4I|tMn%O6XO|yrtiB)c9|D=$F z*(dw&4QL{7&oFR0;ecGu|X2KBigqSIHBG=oiQ*^TowxW4VIX z2Z^EJ>;iJS&)GzyQuUDDJ?AoT!Fb&E?g74Qp}mKuGCwCTv$!;Km&KD+Q~~pm0^asf zBy=aK|EPc!KLl;Fe=6aju)k@6N_ZQ)zW~W;?MT=&t`*kT_Jm{T8Y1bvaI=JR7*Q5g zKEVQA=3aV2HnVWFqk7pY<;m{2+Sv7qrm1GW;+eXlk|a^AvN}T=)9%wR;n1l4(W}Yx z6HhHJli)-?<6kl+llPg7(CM-x;}Od=d92Tu8{sgG&U2FZ1FT8C@k}kN zr(S^Re=%H&#}LEew4#Yd##6D^e2yPubqwQdLV36edG?L&qDdck=OK*cBX`zzHHuvC zpwKg)3wci=f#1TjGIA|wvFys{+}_h+GvD?vJ`#I!wRKykFBhIxr^V7Ui{0Kf*Y?#v zHjt{+vyq>E*)p^E?d&(K%w{)LQP--;|{I9!S zYn|n@Kl8mDQrxO>umv05YCJo1Q!}N@y4!b9C$AHyc69i36DB5(`D=n~zZiTH%}Q`k zV+05z)j~8?fP0RA0&9|iW;IfS3Yv7FZhz(AHdpnX2A8{LMPcYtD zk#}Foz$oCb$g9=S^G5q8AtuekyiszH$AHZ>=G~L{iN_cvv(CnAk_zXyB5wl+Wp`PS z%F&y6>y$T1BxQUz3%R@-=0bPpNMH`)a$WUW+P(CGfmeNi>0HCDzXTv@8v?7bPwc4s URRiX$hffaxX@# - + - + android:descendantFocusability="blocksDescendants"> - + - + - + - + - + - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_folder_list.xml b/app/src/main/res/layout/item_folder_list.xml index 0856e370..cd098c0d 100644 --- a/app/src/main/res/layout/item_folder_list.xml +++ b/app/src/main/res/layout/item_folder_list.xml @@ -32,7 +32,6 @@ android:id="@+id/state" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_alignBottom="@id/id" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" android:textAppearance="?textAppearanceListItemSmall" /> @@ -41,7 +40,7 @@ android:id="@+id/directory" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_below="@id/state" + android:layout_below="@id/label" android:ellipsize="end" android:textAppearance="?textAppearanceListItemSecondary" /> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 46396773..dde9bd39 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -220,6 +220,9 @@ Bitte melden Sie auftretende Probleme via GitHub. Die Verzeichnis ID darf nicht leer sein + + Die Verzeichnisbezeichnung darf nicht leer sein + Der Verzeichnispfad darf nicht leer sein @@ -283,6 +286,12 @@ Bitte melden Sie auftretende Probleme via GitHub. Die Geräte-ID darf nicht leer sein + + Die Geräte-ID ist nicht im richtigen Format. Bitte versuche es erneut und gebe die richtige ID ein. + + + Der Gerätename darf nicht leer sein + QR Code scannen @@ -416,7 +425,7 @@ Bitte melden Sie auftretende Probleme via GitHub. Keine Papierkorb Einfach - Stufenweis + Gestaffelt Extern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9397b71..b85f25c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,9 @@ Please report any problems you encounter via Github. The folder ID must not be empty + + The folder label must not be empty + The folder path must not be empty @@ -283,6 +286,12 @@ Please report any problems you encounter via Github. The device ID must not be empty + + The device ID is not formatted correctly. Please retry and enter the correct ID. + + + The device name must not be empty + Scan QR Code