1
0
Fork 0
mirror of https://github.com/syncthing/syncthing-android.git synced 2024-12-02 01:01:17 +00:00

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
This commit is contained in:
Catfriend1 2018-12-22 01:58:44 +01:00 committed by GitHub
parent 7791787bbc
commit 190826e660
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1237 additions and 379 deletions

View file

@ -37,8 +37,8 @@ android {
applicationId "com.github.catfriend1.syncthingandroid" applicationId "com.github.catfriend1.syncthingandroid"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 26 targetSdkVersion 26
versionCode 4181 versionCode 4182
versionName "0.14.54.2" versionName "0.14.54.3"
testApplicationId 'com.github.catfriend1.syncthingandroid.test' testApplicationId 'com.github.catfriend1.syncthingandroid.test'
testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
playAccountConfig = playAccountConfigs.defaultAccountConfig playAccountConfig = playAccountConfigs.defaultAccountConfig

View file

@ -34,6 +34,7 @@ import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.util.Compression; import com.nutomic.syncthingandroid.util.Compression;
import com.nutomic.syncthingandroid.util.ConfigRouter;
import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.TextWatcherAdapter;
import com.nutomic.syncthingandroid.util.Util; import com.nutomic.syncthingandroid.util.Util;
@ -68,13 +69,15 @@ public class DeviceActivity extends SyncthingActivity
public static final String EXTRA_IS_CREATE = public static final String EXTRA_IS_CREATE =
"com.nutomic.syncthingandroid.activities.DeviceActivity.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_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE";
private static final String IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_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 String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE";
private static final List<String> DYNAMIC_ADDRESS = Collections.singletonList("dynamic"); private static final List<String> DYNAMIC_ADDRESS = Collections.singletonList("dynamic");
private ConfigRouter mConfig;
private Device mDevice; private Device mDevice;
private View mIdContainer; private View mIdContainer;
@ -186,6 +189,8 @@ public class DeviceActivity extends SyncthingActivity
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
mConfig = new ConfigRouter(DeviceActivity.this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
((SyncthingApp) getApplication()).component().inject(this); ((SyncthingApp) getApplication()).component().inject(this);
setContentView(R.layout.fragment_device); setContentView(R.layout.fragment_device);
@ -338,14 +343,9 @@ public class DeviceActivity extends SyncthingActivity
@Override @Override
public void onServiceStateChange(SyncthingService.State currentState) { public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != ACTIVE) {
finish();
return;
}
if (!mIsCreateMode) { if (!mIsCreateMode) {
RestApi restApi = getApi(); // restApi != null because of State.ACTIVE RestApi restApi = getApi();
List<Device> devices = restApi.getDevices(false); List<Device> devices = mConfig.getDevices(restApi, false);
String passedId = getIntent().getStringExtra(EXTRA_DEVICE_ID); String passedId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
mDevice = null; mDevice = null;
for (Device currentDevice : devices) { for (Device currentDevice : devices) {
@ -427,8 +427,16 @@ public class DeviceActivity extends SyncthingActivity
.show(); .show();
return true; return true;
} }
getApi().addDevice(mDevice, error -> if (isEmpty(mDevice.name)) {
Toast.makeText(this, error, Toast.LENGTH_LONG).show()); 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(); finish();
return true; return true;
case R.id.share_device_id: case R.id.share_device_id:
@ -455,7 +463,8 @@ public class DeviceActivity extends SyncthingActivity
return new android.app.AlertDialog.Builder(this) return new android.app.AlertDialog.Builder(this)
.setMessage(R.string.remove_device_confirm) .setMessage(R.string.remove_device_confirm)
.setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> {
getApi().removeDevice(mDevice.deviceID); mConfig.removeDevice(getApi(), mDevice.deviceID);
mDeviceNeedsToUpdate = false;
finish(); finish();
}) })
.setNegativeButton(android.R.string.no, null) .setNegativeButton(android.R.string.no, null)
@ -474,6 +483,9 @@ public class DeviceActivity extends SyncthingActivity
} }
} }
/**
* Used in mIsCreateMode.
*/
private void initDevice() { private void initDevice() {
mDevice = new Device(); mDevice = new Device();
mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME); mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
@ -482,6 +494,7 @@ public class DeviceActivity extends SyncthingActivity
mDevice.compression = METADATA.getValue(this); mDevice.compression = METADATA.getValue(this);
mDevice.introducer = false; mDevice.introducer = false;
mDevice.paused = false; mDevice.paused = false;
mDevice.introducedBy = "";
} }
private void prepareEditMode() { private void prepareEditMode() {
@ -501,13 +514,14 @@ public class DeviceActivity extends SyncthingActivity
*/ */
private void updateDevice() { private void updateDevice() {
if (mIsCreateMode) { 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; return;
} }
if (mDevice == null) { if (mDevice == null) {
Log.e(TAG, "updateDevice: mDevice == null"); Log.e(TAG, "updateDevice: mDevice == null");
return; return;
} }
// Log.v(TAG, "deviceID=" + mDevice.deviceID + ", introducedBy=" + mDevice.introducedBy);
// Save device specific preferences. // Save device specific preferences.
Log.v(TAG, "updateDevice: mDevice.deviceID = \'" + mDevice.deviceID + "\'"); Log.v(TAG, "updateDevice: mDevice.deviceID = \'" + mDevice.deviceID + "\'");
@ -518,26 +532,42 @@ public class DeviceActivity extends SyncthingActivity
); );
editor.apply(); editor.apply();
// Update device via restApi and send the config to REST endpoint. // Update device using RestApi or ConfigXml.
RestApi restApi = getApi(); mConfig.updateDevice(getApi(), mDevice);
if (restApi == null) {
Log.e(TAG, "updateDevice: restApi == null");
return;
}
restApi.updateDevice(mDevice);
} }
private List<String> persistableAddresses(CharSequence userInput) { private List<String> persistableAddresses(CharSequence userInput) {
return isEmpty(userInput) if (isEmpty(userInput)) {
? DYNAMIC_ADDRESS return DYNAMIC_ADDRESS;
: Arrays.asList(userInput.toString().split(" ")); }
/**
* 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() { private String displayableAddresses() {
if (mDevice.addresses == null) {
return "";
}
List<String> list = DYNAMIC_ADDRESS.equals(mDevice.addresses) List<String> list = DYNAMIC_ADDRESS.equals(mDevice.addresses)
? DYNAMIC_ADDRESS ? DYNAMIC_ADDRESS
: mDevice.addresses; : mDevice.addresses;
return TextUtils.join(" ", list); return TextUtils.join(", ", list);
} }
@Override @Override

View file

@ -93,6 +93,21 @@ public class FirstStartActivity extends Activity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
((SyncthingApp) getApplication()).component().inject(this); ((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. * Check if prerequisites to run the app are still in place.
* If anything mandatory is missing, the according welcome slide(s) will be shown. * 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 showSlideStoragePermission = !haveStoragePermission();
Boolean showSlideIgnoreDozePermission = !haveIgnoreDozePermission(); Boolean showSlideIgnoreDozePermission = !haveIgnoreDozePermission();
Boolean showSlideLocationPermission = !haveLocationPermission(); Boolean showSlideLocationPermission = !haveLocationPermission();
Boolean showSlideKeyGeneration = !Constants.getConfigFile(this).exists(); Boolean showSlideKeyGeneration = !configExists || !configParseable;
/** /**
* If we don't have to show slides for mandatory prerequisites, * If we don't have to show slides for mandatory prerequisites,
@ -480,8 +495,10 @@ public class FirstStartActivity extends Activity {
cancel(true); cancel(true);
return null; return null;
} }
configXml = new ConfigXml(firstStartActivity);
try { try {
configXml = new ConfigXml(firstStartActivity); // Create new secure keys and config.
configXml.generateConfig();
} catch (ExecutableNotFoundException e) { } catch (ExecutableNotFoundException e) {
publishProgress(firstStartActivity.getString(R.string.executable_not_found, e.getMessage())); publishProgress(firstStartActivity.getString(R.string.executable_not_found, e.getMessage()));
cancel(true); cancel(true);

View file

@ -36,6 +36,7 @@ import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.util.ConfigRouter;
import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.FileUtils;
import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.TextWatcherAdapter;
import com.nutomic.syncthingandroid.util.Util; 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 FOLDER_MARKER_NAME = ".stfolder";
// private static final String IGNORE_FILE_NAME = ".stignore"; // private static final String IGNORE_FILE_NAME = ".stignore";
private ConfigRouter mConfig;
private Folder mFolder; private Folder mFolder;
// Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21) // Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21)
private Uri mFolderUri = null; private Uri mFolderUri = null;
@ -154,7 +156,7 @@ public class FolderActivity extends SyncthingActivity
case R.id.device_toggle: case R.id.device_toggle:
Device device = (Device) view.getTag(); Device device = (Device) view.getTag();
if (isChecked) { if (isChecked) {
mFolder.addDevice(device.deviceID); mFolder.addDevice(device);
} else { } else {
mFolder.removeDevice(device.deviceID); mFolder.removeDevice(device.deviceID);
} }
@ -166,6 +168,8 @@ public class FolderActivity extends SyncthingActivity
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
mConfig = new ConfigRouter(FolderActivity.this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
((SyncthingApp) getApplication()).component().inject(this); ((SyncthingApp) getApplication()).component().inject(this);
setContentView(R.layout.fragment_folder); setContentView(R.layout.fragment_folder);
@ -384,14 +388,9 @@ public class FolderActivity extends SyncthingActivity
@Override @Override
public void onServiceStateChange(SyncthingService.State currentState) { public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != ACTIVE) {
finish();
return;
}
if (!mIsCreateMode) { if (!mIsCreateMode) {
RestApi restApi = getApi(); // restApi != null because of State.ACTIVE RestApi restApi = getApi();
List<Folder> folders = restApi.getFolders(); List<Folder> folders = mConfig.getFolders(restApi);
String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID); String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID);
mFolder = null; mFolder = null;
for (Folder currentFolder : folders) { for (Folder currentFolder : folders) {
@ -405,11 +404,15 @@ public class FolderActivity extends SyncthingActivity
finish(); finish();
return; return;
} }
restApi.getFolderIgnoreList(mFolder.id, this::onReceiveFolderIgnoreList); mConfig.getFolderIgnoreList(restApi, mFolder, this::onReceiveFolderIgnoreList);
checkWriteAndUpdateUI(); checkWriteAndUpdateUI();
} }
// If the extra is set, we should automatically share the current folder with the given device.
if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { 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; mFolderNeedsToUpdate = true;
} }
@ -471,13 +474,14 @@ public class FolderActivity extends SyncthingActivity
mCustomSyncConditionsDialog.setEnabled(mCustomSyncConditionsSwitch.isChecked()); mCustomSyncConditionsDialog.setEnabled(mCustomSyncConditionsSwitch.isChecked());
// Populate devicesList. // Populate devicesList.
List<Device> devicesList = getApi().getDevices(false); RestApi restApi = getApi();
List<Device> devicesList = mConfig.getDevices(restApi, false);
mDevicesContainer.removeAllViews(); mDevicesContainer.removeAllViews();
if (devicesList.isEmpty()) { if (devicesList.isEmpty()) {
addEmptyDeviceListView(); addEmptyDeviceListView();
} else { } else {
for (Device n : devicesList) { for (Device device : devicesList) {
addDeviceViewAndSetListener(n, getLayoutInflater()); addDeviceViewAndSetListener(device, getLayoutInflater());
} }
} }
@ -511,6 +515,11 @@ public class FolderActivity extends SyncthingActivity
.show(); .show();
return true; 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)) { if (TextUtils.isEmpty(mFolder.path)) {
Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG)
.show(); .show();
@ -531,7 +540,7 @@ public class FolderActivity extends SyncthingActivity
dfFolder.createDirectory(FOLDER_MARKER_NAME); dfFolder.createDirectory(FOLDER_MARKER_NAME);
} }
} }
getApi().createFolder(mFolder); mConfig.addFolder(getApi(), mFolder);
finish(); finish();
return true; return true;
case R.id.remove: case R.id.remove:
@ -554,10 +563,7 @@ public class FolderActivity extends SyncthingActivity
return new AlertDialog.Builder(this) return new AlertDialog.Builder(this)
.setMessage(R.string.remove_folder_confirm) .setMessage(R.string.remove_folder_confirm)
.setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> {
RestApi restApi = getApi(); mConfig.removeFolder(getApi(), mFolder.id);
if (restApi != null) {
restApi.removeFolder(mFolder.id);
}
mFolderNeedsToUpdate = false; mFolderNeedsToUpdate = false;
finish(); finish();
}) })
@ -731,17 +737,13 @@ public class FolderActivity extends SyncthingActivity
// Update folder via restApi and send the config to REST endpoint. // Update folder via restApi and send the config to REST endpoint.
RestApi restApi = getApi(); RestApi restApi = getApi();
if (restApi == null) {
Log.e(TAG, "updateFolder: restApi == null");
return;
}
// Update ignore list. // Update ignore list.
String[] ignore = mEditIgnoreListContent.getText().toString().split("\n"); 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. // Update folder using RestApi or ConfigXml.
restApi.updateFolder(mFolder); mConfig.updateFolder(restApi, mFolder);
} }
@Override @Override

View file

@ -9,7 +9,6 @@ import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.os.IBinder;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
@ -31,8 +30,6 @@ import com.google.common.collect.Sets;
import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.service.Constants; 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 com.nutomic.syncthingandroid.util.Util;
import java.io.File; import java.io.File;
@ -45,7 +42,7 @@ import java.util.Iterator;
* Activity that allows selecting a directory in the local file system. * Activity that allows selecting a directory in the local file system.
*/ */
public class FolderPickerActivity extends SyncthingActivity public class FolderPickerActivity extends SyncthingActivity
implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener { implements AdapterView.OnItemClickListener {
private static final String EXTRA_INITIAL_DIRECTORY = private static final String EXTRA_INITIAL_DIRECTORY =
"com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY"; "com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY";
@ -151,22 +148,6 @@ public class FolderPickerActivity extends SyncthingActivity
mRootsAdapter.addAll(Sets.newTreeSet(roots)); 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 @Override
public boolean onCreateOptionsMenu(Menu menu) { public boolean onCreateOptionsMenu(Menu menu) {
if (mListView.getAdapter() == mRootsAdapter) 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 * Displays a list of all available roots, or if there is only one root, the
* contents of that folder. * contents of that folder.

View file

@ -97,6 +97,9 @@ public class MainActivity extends SyncthingActivity
private ActionBarDrawerToggle mDrawerToggle; private ActionBarDrawerToggle mDrawerToggle;
private DrawerLayout mDrawerLayout; private DrawerLayout mDrawerLayout;
private Boolean oneTimeShot = true;
@Inject SharedPreferences mPreferences; @Inject SharedPreferences mPreferences;
/** /**
@ -104,9 +107,10 @@ public class MainActivity extends SyncthingActivity
*/ */
@Override @Override
public void onServiceStateChange(SyncthingService.State currentState) { public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != mSyncthingServiceState) { mSyncthingServiceState = currentState;
mSyncthingServiceState = currentState; if (oneTimeShot) {
updateViewPager(); updateViewPager();
oneTimeShot = false;
} }
switch (currentState) { switch (currentState) {
@ -178,15 +182,12 @@ public class MainActivity extends SyncthingActivity
} }
if (savedInstanceState != null) { if (savedInstanceState != null) {
mViewPager.setCurrentItem(savedInstanceState.getInt("currentTab"));
if (savedInstanceState.getBoolean(IS_SHOWING_RESTART_DIALOG)){ if (savedInstanceState.getBoolean(IS_SHOWING_RESTART_DIALOG)){
showRestartDialog(); 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)); showQrCodeDialog(savedInstanceState.getString(DEVICEID_KEY), savedInstanceState.getParcelable(QRCODE_BITMAP_KEY));
} }
} else {
updateViewPager();
} }
fm.beginTransaction().replace(R.id.drawer, mDrawerFragment).commit(); 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. * Updates the ViewPager to show tabs depending on the service state.
*/ */
private void updateViewPager() { private void updateViewPager() {
Boolean isServiceActive = mSyncthingServiceState == SyncthingService.State.ACTIVE; final int numPages = 3;
final int numPages = (isServiceActive ? 3 : 1);
FragmentStatePagerAdapter mSectionsPagerAdapter = FragmentStatePagerAdapter mSectionsPagerAdapter =
new FragmentStatePagerAdapter(getSupportFragmentManager()) { new FragmentStatePagerAdapter(getSupportFragmentManager()) {
@Override @Override
public Fragment getItem(int position) { public Fragment getItem(int position) {
if (isServiceActive) { switch (position) {
switch (position) { case 0:
case 0: return mFolderListFragment;
return mFolderListFragment; case 1:
case 1: return mDeviceListFragment;
return mDeviceListFragment; case 2:
case 2: return mStatusFragment;
return mStatusFragment; default:
default: return null;
return null;
}
} else {
switch (position) {
case 0:
return mStatusFragment;
default:
return null;
}
} }
} }
@ -251,24 +242,15 @@ public class MainActivity extends SyncthingActivity
@Override @Override
public CharSequence getPageTitle(int position) { public CharSequence getPageTitle(int position) {
if (isServiceActive) { switch (position) {
switch (position) { case 0:
case 0: return getResources().getString(R.string.folders_fragment_title);
return getResources().getString(R.string.folders_fragment_title); case 1:
case 1: return getResources().getString(R.string.devices_fragment_title);
return getResources().getString(R.string.devices_fragment_title); case 2:
case 2: return getResources().getString(R.string.status_fragment_title);
return getResources().getString(R.string.status_fragment_title); default:
default: return String.valueOf(position);
return String.valueOf(position);
}
} else {
switch (position) {
case 0:
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(); SyncthingService mSyncthingService = getService();
if (mSyncthingService != null) { if (mSyncthingService != null) {
mSyncthingService.unregisterOnServiceStateChangeListener(this); mSyncthingService.unregisterOnServiceStateChangeListener(this);
mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment);
mSyncthingService.unregisterOnServiceStateChangeListener(mFolderListFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mFolderListFragment);
mSyncthingService.unregisterOnServiceStateChangeListener(mDeviceListFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mDeviceListFragment);
mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment);
mSyncthingService.unregisterOnServiceStateChangeListener(mStatusFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mStatusFragment);
} }
} }
@ -329,9 +311,9 @@ public class MainActivity extends SyncthingActivity
SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder;
SyncthingService syncthingService = syncthingServiceBinder.getService(); SyncthingService syncthingService = syncthingServiceBinder.getService();
syncthingService.registerOnServiceStateChangeListener(this); syncthingService.registerOnServiceStateChangeListener(this);
syncthingService.registerOnServiceStateChangeListener(mDrawerFragment);
syncthingService.registerOnServiceStateChangeListener(mFolderListFragment); syncthingService.registerOnServiceStateChangeListener(mFolderListFragment);
syncthingService.registerOnServiceStateChangeListener(mDeviceListFragment); syncthingService.registerOnServiceStateChangeListener(mDeviceListFragment);
syncthingService.registerOnServiceStateChangeListener(mDrawerFragment);
syncthingService.registerOnServiceStateChangeListener(mStatusFragment); syncthingService.registerOnServiceStateChangeListener(mStatusFragment);
} }
@ -351,11 +333,9 @@ public class MainActivity extends SyncthingActivity
putFragment.accept(mFolderListFragment); putFragment.accept(mFolderListFragment);
putFragment.accept(mDeviceListFragment); putFragment.accept(mDeviceListFragment);
putFragment.accept(mStatusFragment); putFragment.accept(mStatusFragment);
putFragment.accept(mDrawerFragment);
outState.putInt("currentTab", mViewPager.getCurrentItem());
outState.putBoolean(IS_SHOWING_RESTART_DIALOG, mRestartDialog != null && mRestartDialog.isShowing()); 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); outState.putBoolean(IS_QRCODE_DIALOG_DISPLAYED, true);
ImageView qrCode = mQrCodeDialog.findViewById(R.id.qrcode_image_view); ImageView qrCode = mQrCodeDialog.findViewById(R.id.qrcode_image_view);
TextView deviceID = mQrCodeDialog.findViewById(R.id.device_id); TextView deviceID = mQrCodeDialog.findViewById(R.id.device_id);

View file

@ -6,7 +6,6 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder;
import android.support.v7.widget.SwitchCompat; import android.support.v7.widget.SwitchCompat;
import android.util.Log; import android.util.Log;
import android.util.TypedValue; import android.util.TypedValue;
@ -23,8 +22,6 @@ import com.google.common.collect.Sets;
import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.service.Constants; 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 com.nutomic.syncthingandroid.util.Util;
import java.util.HashSet; 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. * Activity that allows selecting a directory in the local file system.
*/ */
public class SyncConditionsActivity extends SyncthingActivity public class SyncConditionsActivity extends SyncthingActivity {
implements SyncthingService.OnServiceStateChangeListener {
private static final String TAG = "SyncConditionsActivity"; 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 @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
SyncthingService syncthingService = getService();
if (syncthingService != null) {
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
} }
@Override @Override
@ -284,12 +269,4 @@ public class SyncConditionsActivity extends SyncthingActivity
finish(); finish();
} }
@Override
public void onServiceStateChange(SyncthingService.State currentState) {
if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) {
setResult(Activity.RESULT_CANCELED);
finish();
}
}
} }

View file

@ -133,9 +133,10 @@ public class WebGuiActivity extends SyncthingActivity
setContentView(R.layout.activity_web_gui); setContentView(R.layout.activity_web_gui);
mLoadingView = findViewById(R.id.loading); mLoadingView = findViewById(R.id.loading);
mConfig = new ConfigXml(this);
try { try {
mConfig = new ConfigXml(this); mConfig.loadConfig();
} catch (Exception e) { } catch (ConfigXml.OpenConfigException e) {
throw new RuntimeException(e.getMessage()); throw new RuntimeException(e.getMessage());
} }
loadCaCert(); loadCaCert();

View file

@ -20,6 +20,7 @@ import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.util.ConfigXml;
import com.nutomic.syncthingandroid.views.DevicesAdapter; import com.nutomic.syncthingandroid.views.DevicesAdapter;
import java.util.Collections; import java.util.Collections;
@ -34,7 +35,14 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
private final static String TAG = "DeviceListFragment"; private final static String TAG = "DeviceListFragment";
private final static Comparator<Device> 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<Device> 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() { private Runnable mUpdateListRunnable = new Runnable() {
@Override @Override
@ -107,9 +115,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
* while the user is looking at the current tab. * while the user is looking at the current tab.
*/ */
private void onTimerEvent() { private void onTimerEvent() {
if (mServiceState != SyncthingService.State.ACTIVE) {
return;
}
MainActivity mainActivity = (MainActivity) getActivity(); MainActivity mainActivity = (MainActivity) getActivity();
if (mainActivity == null) { if (mainActivity == null) {
return; return;
@ -117,10 +122,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
if (mainActivity.isFinishing()) { if (mainActivity.isFinishing()) {
return; return;
} }
RestApi restApi = mainActivity.getApi();
if (restApi == null) {
return;
}
Log.v(TAG, "Invoking updateList on UI thread"); Log.v(TAG, "Invoking updateList on UI thread");
mainActivity.runOnUiThread(DeviceListFragment.this::updateList); mainActivity.runOnUiThread(DeviceListFragment.this::updateList);
} }
@ -135,11 +136,19 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
if (activity == null || getView() == null || activity.isFinishing()) { if (activity == null || getView() == null || activity.isFinishing()) {
return; return;
} }
List<Device> devices;
RestApi restApi = activity.getApi(); RestApi restApi = activity.getApi();
if (restApi == null || !restApi.isConfigLoaded()) { if (restApi == null ||
return; !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<Device> devices = restApi.getDevices(false);
if (devices == null) { if (devices == null) {
return; return;
} }
@ -153,7 +162,7 @@ public class DeviceListFragment extends ListFragment implements SyncthingService
mAdapter.clear(); mAdapter.clear();
Collections.sort(devices, DEVICES_COMPARATOR); Collections.sort(devices, DEVICES_COMPARATOR);
mAdapter.addAll(devices); mAdapter.addAll(devices);
mAdapter.updateConnections(restApi); mAdapter.updateDeviceStatus(restApi);
mAdapter.notifyDataSetChanged(); mAdapter.notifyDataSetChanged();
setListShown(true); setListShown(true);
} }

View file

@ -19,6 +19,7 @@ import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.util.ConfigXml;
import com.nutomic.syncthingandroid.views.FoldersAdapter; import com.nutomic.syncthingandroid.views.FoldersAdapter;
import java.util.List; import java.util.List;
@ -102,9 +103,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService
* while the user is looking at the current tab. * while the user is looking at the current tab.
*/ */
private void onTimerEvent() { private void onTimerEvent() {
if (mServiceState != SyncthingService.State.ACTIVE) {
return;
}
MainActivity mainActivity = (MainActivity) getActivity(); MainActivity mainActivity = (MainActivity) getActivity();
if (mainActivity == null) { if (mainActivity == null) {
return; return;
@ -112,10 +110,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService
if (mainActivity.isFinishing()) { if (mainActivity.isFinishing()) {
return; return;
} }
RestApi restApi = mainActivity.getApi();
if (restApi == null) {
return;
}
Log.v(TAG, "Invoking updateList on UI thread"); Log.v(TAG, "Invoking updateList on UI thread");
mainActivity.runOnUiThread(FolderListFragment.this::updateList); mainActivity.runOnUiThread(FolderListFragment.this::updateList);
} }
@ -130,11 +124,19 @@ public class FolderListFragment extends ListFragment implements SyncthingService
if (activity == null || getView() == null || activity.isFinishing()) { if (activity == null || getView() == null || activity.isFinishing()) {
return; return;
} }
List<Folder> folders;
RestApi restApi = activity.getApi(); RestApi restApi = activity.getApi();
if (restApi == null || !restApi.isConfigLoaded()) { if (restApi == null ||
return; !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<Folder> folders = restApi.getFolders();
if (folders == null) { if (folders == null) {
return; return;
} }

View file

@ -260,7 +260,7 @@ public class StatusFragment extends ListFragment implements SyncthingService.OnS
int announceConnected = int announceConnected =
announceTotal - Optional.fromNullable(systemStatus.discoveryErrors).transform(Map::size).or(0); announceTotal - Optional.fromNullable(systemStatus.discoveryErrors).transform(Map::size).or(0);
synchronized (mStatusHolderLock) { 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); mRamUsage = Util.readableFileSize(mActivity, systemStatus.sys);
mAnnounceServer = (announceTotal == 0) ? mAnnounceServer = (announceTotal == 0) ?
"" : "" :

View file

@ -38,6 +38,8 @@ public abstract class ApiRequest {
private static final String TAG = "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. * 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, void connect(int requestMethod, Uri uri, @Nullable String requestBody,
@Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) { @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 -> { StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> {
if (listener != null) { if (listener != null) {
listener.onSuccess(reply); listener.onSuccess(reply);

View file

@ -10,17 +10,29 @@ public class Device {
public List<String> addresses; public List<String> addresses;
public String compression; public String compression;
public String certName; public String certName;
public boolean introducer; public String introducedBy = "";
public boolean introducer = false;
public boolean paused; public boolean paused;
public List<PendingFolder> pendingFolders; public List<PendingFolder> pendingFolders;
public List<IgnoredFolder> ignoredFolders; public List<IgnoredFolder> ignoredFolders;
/**
* Relevant fields for Folder.List<Device> "shared-with-device" model,
* handled by {@link ConfigRouter#updateFolder and ConfigXml#updateFolder}
* deviceID
* introducedBy
* Example Tag
* <folder ...>
* <device id="[DEVICE_SHARING_THAT_FOLDER_WITH_US]" introducedBy="[INTRODUCER_DEVICE_THAT_TOLD_US_ABOUT_THE_FOLDER_OR_EMPTY_STRING]"></device>
* </folder>
*/
/** /**
* Returns the device name, or the first characters of the ID if the name is empty. * Returns the device name, or the first characters of the ID if the name is empty.
*/ */
public String getDisplayName() { public String getDisplayName() {
return (TextUtils.isEmpty(name)) return (TextUtils.isEmpty(name))
? deviceID.substring(0, 7) ? (TextUtils.isEmpty(deviceID) ? "" : deviceID.substring(0, 7))
: name; : name;
} }
} }

View file

@ -21,15 +21,15 @@ public class Folder {
public boolean fsWatcherEnabled = true; public boolean fsWatcherEnabled = true;
public int fsWatcherDelayS = 10; public int fsWatcherDelayS = 10;
private List<Device> devices = new ArrayList<>(); private List<Device> devices = new ArrayList<>();
public int rescanIntervalS; public int rescanIntervalS = 3600;
public final boolean ignorePerms = true; public boolean ignorePerms = true;
public boolean autoNormalize = true; public boolean autoNormalize = true;
public MinDiskFree minDiskFree; public MinDiskFree minDiskFree;
public Versioning versioning; public Versioning versioning;
public int copiers; public int copiers = 0;
public int pullerMaxPendingKiB; public int pullerMaxPendingKiB;
public int hashers; public int hashers = 0;
public String order; public String order = "random";
public boolean ignoreDelete; public boolean ignoreDelete;
public int scanProgressIntervalS; public int scanProgressIntervalS;
public int pullerPauseS; public int pullerPauseS;
@ -52,12 +52,17 @@ public class Folder {
public String unit; public String unit;
} }
public void addDevice(String deviceId) { public void addDevice(final Device device) {
Device d = new Device(); Device d = new Device();
d.deviceID = deviceId; d.deviceID = device.deviceID;
d.introducedBy = device.introducedBy;
devices.add(d); devices.add(d);
} }
public List<Device> getDevices() {
return devices;
}
public Device getDevice(String deviceId) { public Device getDevice(String deviceId) {
for (Device d : devices) { for (Device d : devices) {
if (d.deviceID.equals(deviceId)) { if (d.deviceID.equals(deviceId)) {
@ -78,6 +83,8 @@ public class Folder {
@Override @Override
public String toString() { public String toString() {
return !TextUtils.isEmpty(label) ? label : id; return (TextUtils.isEmpty(label))
? (TextUtils.isEmpty(id) ? "" : id)
: label;
} }
} }

View file

@ -34,6 +34,7 @@ public class Options {
public String[] alwaysLocalNets; public String[] alwaysLocalNets;
public boolean overwriteRemoteDeviceNamesOnConnect; public boolean overwriteRemoteDeviceNamesOnConnect;
public int tempIndexMinBlocks; public int tempIndexMinBlocks;
public String defaultFolderPath;
public static final int USAGE_REPORTING_UNDECIDED = 0; public static final int USAGE_REPORTING_UNDECIDED = 0;
public static final int USAGE_REPORTING_DENIED = -1; public static final int USAGE_REPORTING_DENIED = -1;

View file

@ -68,6 +68,7 @@ public class Constants {
* Cached information which is not available on SettingsActivity. * Cached information which is not available on SettingsActivity.
*/ */
public static final String PREF_LAST_BINARY_VERSION = "lastBinaryVersion"; public static final String PREF_LAST_BINARY_VERSION = "lastBinaryVersion";
public static final String PREF_LOCAL_DEVICE_ID = "localDeviceID";
/** /**
* {@link EventProcessor} * {@link EventProcessor}

View file

@ -71,7 +71,6 @@ public class RestApi {
private final static Comparator<Folder> FOLDERS_COMPARATOR = (lhs, rhs) -> { private final static Comparator<Folder> FOLDERS_COMPARATOR = (lhs, rhs) -> {
String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id;
String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id;
return lhsLabel.compareTo(rhsLabel); return lhsLabel.compareTo(rhsLabel);
}; };
@ -453,7 +452,7 @@ public class RestApi {
/** /**
* This is only used for new folder creation, see {@link FolderActivity}. * This is only used for new folder creation, see {@link FolderActivity}.
*/ */
public void createFolder(Folder folder) { public void addFolder(Folder folder) {
synchronized (mConfigLock) { synchronized (mConfigLock) {
// Add the new folder to the model. // Add the new folder to the model.
mConfig.folders.add(folder); mConfig.folders.add(folder);
@ -500,7 +499,7 @@ public class RestApi {
* *
* @param includeLocal True if the local device should be included in the result. * @param includeLocal True if the local device should be included in the result.
*/ */
public List<Device> getDevices(boolean includeLocal) { public List<Device> getDevices(Boolean includeLocal) {
List<Device> devices; List<Device> devices;
synchronized (mConfigLock) { synchronized (mConfigLock) {
devices = deepCopy(mConfig.devices, new TypeToken<List<Device>>(){}.getType()); devices = deepCopy(mConfig.devices, new TypeToken<List<Device>>(){}.getType());
@ -850,6 +849,7 @@ public class RestApi {
Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0")); Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0"));
if (folder.paused != !syncConditionsMet) { if (folder.paused != !syncConditionsMet) {
folder.paused = !syncConditionsMet; folder.paused = !syncConditionsMet;
Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0"));
configChanged = true; configChanged = true;
} }
} }
@ -872,6 +872,7 @@ public class RestApi {
Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0")); Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0"));
if (device.paused != !syncConditionsMet) { if (device.paused != !syncConditionsMet) {
device.paused = !syncConditionsMet; device.paused = !syncConditionsMet;
Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0"));
configChanged = true; configChanged = true;
} }
} }

View file

@ -58,7 +58,7 @@ public class RunConditionMonitor {
} }
public interface OnSyncPreconditionChangedListener { public interface OnSyncPreconditionChangedListener {
void onSyncPreconditionChanged(); void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor);
} }
private class SyncConditionResult { private class SyncConditionResult {
@ -189,7 +189,7 @@ public class RunConditionMonitor {
// Notify about changed preconditions. // Notify about changed preconditions.
if (mOnSyncPreconditionChangedListener != null) { if (mOnSyncPreconditionChangedListener != null) {
mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(); mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(this);
} }
} }

View file

@ -40,6 +40,8 @@ import javax.inject.Inject;
import eu.chainfire.libsuperuser.Shell; 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. * 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. // Notify {@link SyncthingService} that service state State.ACTIVE is no longer valid.
if (!returnStdOut && sendStopToService) { if (!returnStdOut && sendStopToService) {
mContext.startService(new Intent(mContext, SyncthingService.class) Intent intent = new Intent(mContext, SyncthingService.class);
.setAction(SyncthingService.ACTION_STOP)); intent.setAction(SyncthingService.ACTION_STOP);
intent.putExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, true);
mContext.startService(intent);
} }
// Return captured command line output. // Return captured command line output.

View file

@ -8,6 +8,7 @@ import android.Manifest;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock; import android.os.SystemClock;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat; 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.R;
import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask; import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask;
import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.util.ConfigXml;
import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.FileUtils;
@ -37,6 +39,7 @@ import java.nio.file.Paths;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -112,6 +115,13 @@ public class SyncthingService extends Service {
public static final String EXTRA_FOLDER_ID = public static final String EXTRA_FOLDER_ID =
"com.nutomic.syncthingandroid.service.SyncthingService.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 { public interface OnSyncthingKilled {
void onKilled(); void onKilled();
} }
@ -155,7 +165,6 @@ public class SyncthingService extends Service {
*/ */
private State mCurrentState = State.DISABLED; private State mCurrentState = State.DISABLED;
private ConfigXml mConfig; private ConfigXml mConfig;
private StartupTask mStartupTask = null;
private Thread mSyncthingRunnableThread = null; private Thread mSyncthingRunnableThread = null;
private Handler mHandler; private Handler mHandler;
@ -276,9 +285,23 @@ public class SyncthingService extends Service {
if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
shutdown(State.INIT, () -> launchStartupTask(SyncthingRunnable.Command.main)); shutdown(State.INIT, () -> launchStartupTask(SyncthingRunnable.Command.main));
} else if (ACTION_STOP.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { } else if (ACTION_STOP.equals(intent.getAction())) {
shutdown(State.DISABLED, () -> { 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())) { } else if (ACTION_RESET_DATABASE.equals(intent.getAction())) {
Log.i(TAG, "Invoking reset of database"); Log.i(TAG, "Invoking reset of database");
shutdown(State.INIT, () -> { 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 * After sync preconditions changed, we need to inform {@link RestApi} to pause or
* unpause devices and folders as defined in per-object sync preferences. * unpause devices and folders as defined in per-object sync preferences.
*/ */
private void onSyncPreconditionChanged() { private void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor) {
if (mRestApi != null) { synchronized (mStateLock) {
// Forward event. if (mRestApi != null && mCurrentState == State.ACTIVE) {
mRestApi.onSyncPreconditionChanged(mRunConditionMonitor); // 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<Folder> 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<Device> 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. * Prepares to launch the syncthing binary.
*/ */
private void launchStartupTask(SyncthingRunnable.Command srCommand) { private void launchStartupTask(SyncthingRunnable.Command srCommand) {
Log.v(TAG, "Starting syncthing");
synchronized (mStateLock) { synchronized (mStateLock) {
if (mCurrentState != State.DISABLED && mCurrentState != State.INIT) { if (mCurrentState != State.DISABLED && mCurrentState != State.INIT) {
Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling."); Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling.");
return; return;
} }
} }
Log.v(TAG, "Starting syncthing");
// Safety check: Log warning if a previously launched startup task did not finish properly. onServiceStateChange(State.STARTING);
if (mStartupTask != null && (mStartupTask.getStatus() == AsyncTask.Status.RUNNING)) { mConfig = new ConfigXml(this);
Log.w(TAG, "launchStartupTask: StartupTask is still running. Skipped starting it twice."); try {
mConfig.loadConfig();
} catch (ConfigXml.OpenConfigException e) {
mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException");
synchronized (mStateLock) {
onServiceStateChange(State.ERROR);
}
return; 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<Void, Void, Void> {
private WeakReference<SyncthingService> 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) { if (mRestApi == null) {
mRestApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), mRestApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(),
this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); this::onApiAvailable, () -> onServiceStateChange(mCurrentState));
@ -652,13 +697,13 @@ public class SyncthingService extends Service {
mCurrentState = newState; mCurrentState = newState;
mHandler.post(() -> { mHandler.post(() -> {
mNotificationHandler.updatePersistentNotification(this); mNotificationHandler.updatePersistentNotification(this);
for (Iterator<OnServiceStateChangeListener> i = mOnServiceStateChangeListeners.iterator(); Iterator<OnServiceStateChangeListener> it = mOnServiceStateChangeListeners.iterator();
i.hasNext(); ) { while (it.hasNext()) {
OnServiceStateChangeListener listener = i.next(); OnServiceStateChangeListener listener = it.next();
if (listener != null) { if (listener != null) {
listener.onServiceStateChange(mCurrentState); listener.onServiceStateChange(mCurrentState);
} else { } else {
i.remove(); it.remove();
} }
} }
}); });
@ -773,7 +818,14 @@ public class SyncthingService extends Service {
// Start syncthing after export if run conditions apply. // Start syncthing after export if run conditions apply.
if (mLastDeterminedShouldRun) { 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; return failSuccess;
} }
@ -844,6 +896,7 @@ public class SyncthingService extends Service {
case Constants.PREF_DEBUG_FACILITIES_AVAILABLE: case Constants.PREF_DEBUG_FACILITIES_AVAILABLE:
case Constants.PREF_EVENT_PROCESSOR_LAST_SYNC_ID: case Constants.PREF_EVENT_PROCESSOR_LAST_SYNC_ID:
case Constants.PREF_LAST_BINARY_VERSION: case Constants.PREF_LAST_BINARY_VERSION:
case Constants.PREF_LOCAL_DEVICE_ID:
Log.v(TAG, "importConfig: Ignoring cache pref \"" + prefKey + "\"."); Log.v(TAG, "importConfig: Ignoring cache pref \"" + prefKey + "\".");
break; break;
default: default:
@ -929,7 +982,14 @@ public class SyncthingService extends Service {
// Start syncthing after import if run conditions apply. // Start syncthing after import if run conditions apply.
if (mLastDeterminedShouldRun) { 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; return failSuccess;
} }

View file

@ -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<T> {
void onResult(T t);
}
private final Context mContext;
private ConfigXml configXml;
public ConfigRouter(Context context) {
mContext = context;
configXml = new ConfigXml(mContext);
}
public List<Folder> 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<FolderIgnoreList> 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<Device> 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<String> 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.
}
}

View file

@ -1,13 +1,15 @@
package com.nutomic.syncthingandroid.util; package com.nutomic.syncthingandroid.util;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build; import android.os.Build;
import android.os.Environment; import android.os.Environment;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; 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.R;
import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.SyncthingRunnable; import com.nutomic.syncthingandroid.service.SyncthingRunnable;
@ -23,12 +25,16 @@ import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; 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.Locale;
import java.util.Map;
import java.util.Random; import java.util.Random;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.ParserConfigurationException;
@ -53,53 +59,117 @@ import org.xml.sax.InputSource;
*/ */
public class ConfigXml { public class ConfigXml {
private static final String TAG = "ConfigXml";
private static final Boolean ENABLE_VERBOSE_LOG = false;
public class OpenConfigException extends RuntimeException { 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<Device> 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<Folder> 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<T> {
void onResult(T t);
}
private static final int FOLDER_ID_APPENDIX_LENGTH = 4; private static final int FOLDER_ID_APPENDIX_LENGTH = 4;
private final Context mContext; private final Context mContext;
@Inject
SharedPreferences mPreferences;
private final File mConfigFile; private final File mConfigFile;
private Document mConfig; private Document mConfig;
public ConfigXml(Context context) throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException { public ConfigXml(Context context) {
mContext = context; mContext = context;
mConfigFile = Constants.getConfigFile(mContext); mConfigFile = Constants.getConfigFile(mContext);
boolean isFirstStart = !mConfigFile.exists(); }
if (isFirstStart) {
Log.i(TAG, "App started for the first time. Generating keys and config."); public void loadConfig() throws OpenConfigException {
new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run(true); 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) { // Save changes if we made any.
boolean changed = false; if (changed) {
saveChanges();
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();
}
} }
} }
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)) { if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) {
Log.w(TAG, "Failed to open config file '" + mConfigFile + "'");
throw new OpenConfigException(); throw new OpenConfigException();
} }
try { try {
@ -107,11 +177,16 @@ public class ConfigXml {
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8"); InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8");
InputSource inputSource = new InputSource(inputStreamReader); InputSource inputSource = new InputSource(inputStreamReader);
inputSource.setEncoding("UTF-8"); inputSource.setEncoding("UTF-8");
DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance();
Log.d(TAG, "Parsing config file '" + mConfigFile + "'"); DocumentBuilder db = dbfactory.newDocumentBuilder();
if (ENABLE_VERBOSE_LOG) {
Log.v(TAG, "Parsing config file '" + mConfigFile + "'");
}
mConfig = db.parse(inputSource); mConfig = db.parse(inputSource);
inputStream.close(); 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) { } catch (SAXException | ParserConfigurationException | IOException e) {
Log.w(TAG, "Failed to parse config file '" + mConfigFile + "'", e); Log.w(TAG, "Failed to parse config file '" + mConfigFile + "'", e);
throw new OpenConfigException(); throw new OpenConfigException();
@ -141,7 +216,7 @@ public class ConfigXml {
* username/password, and disables weak hash checking. * username/password, and disables weak hash checking.
*/ */
@SuppressWarnings("SdCardPath") @SuppressWarnings("SdCardPath")
public void updateIfNeeded() { private void updateIfNeeded() {
boolean changed = false; boolean changed = false;
/* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ /* 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(); Boolean forceHttps = Constants.osSupportsTLS12();
if (!gui.hasAttribute("tls") || if (!gui.hasAttribute("tls") ||
Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) { Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) {
gui.setAttribute("tls", forceHttps ? "true" : "false"); gui.setAttribute("tls", Boolean.toString(forceHttps));
changed = true; 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. // Save changes if we made any.
if (changed) { if (changed) {
saveChanges(); saveChanges();
@ -238,7 +316,9 @@ public class ConfigXml {
/* Read existing config version */ /* Read existing config version */
int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version")); int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version"));
int iOldConfigVersion = iConfigVersion; 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 */ /* Check if we have to do manual migration from version X to Y */
if (iConfigVersion == 27) { 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<Folder> getFolders() {
String localDeviceID = getLocalDeviceIDfromPref();
List<Folder> 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
/*
<device id="[DEVICE_ID]" introducedBy=""/>
*/
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
/*
<versioning></versioning>
<versioning type="trashcan">
<param key="cleanoutDays" val="90"></param>
</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<Device> 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<String, String> 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<FolderIgnoreList> 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<Device> getDevices(Boolean includeLocal) {
String localDeviceID = getLocalDeviceIDfromPref();
List<Device> devices = new ArrayList<>();
// Prevent enumerating "<device>" tags below "<folder>" 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
/*
<device ...>
<address>dynamic</address>
<address>tcp4://192.168.1.67:2222</address>
</device>
*/
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<String> 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 "<device>" tags below "<folder>" 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 "<device>" tags below "<folder>" 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 "<device>" tags below "<folder>" 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) { private boolean setConfigElement(Element parent, String tagName, String textContent) {
Node element = parent.getElementsByTagName(tagName).item(0); Node element = parent.getElementsByTagName(tagName).item(0);
if (element == null) { if (element == null) {
@ -347,16 +859,23 @@ public class ConfigXml {
return sb.toString(); 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. * Writes updated mConfig back to file.
*/ */
private void saveChanges() { public void saveChanges() {
if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) { if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) {
Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file."); Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file.");
return; return;
} }
Log.i(TAG, "Writing updated config file"); Log.i(TAG, "Saving config file");
File mConfigTempFile = Constants.getConfigTempFile(mContext); File mConfigTempFile = Constants.getConfigTempFile(mContext);
try { try {
// Write XML header. // Write XML header.

View file

@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.Resources; import android.content.res.Resources;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.v4.content.ContextCompat; import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -16,11 +17,16 @@ import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.util.Util; import com.nutomic.syncthingandroid.util.Util;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
/** /**
* Generates item views for device items. * Generates item views for device items.
*/ */
public class DevicesAdapter extends ArrayAdapter<Device> { public class DevicesAdapter extends ArrayAdapter<Device> {
private static final String TAG = "DevicesAdapter";
private Connections mConnections; private Connections mConnections;
public DevicesAdapter(Context context) { public DevicesAdapter(Context context) {
@ -36,6 +42,7 @@ public class DevicesAdapter extends ArrayAdapter<Device> {
convertView = inflater.inflate(R.layout.item_device_list, parent, false); 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 name = convertView.findViewById(R.id.name);
TextView status = convertView.findViewById(R.id.status); TextView status = convertView.findViewById(R.id.status);
TextView download = convertView.findViewById(R.id.download); TextView download = convertView.findViewById(R.id.download);
@ -52,16 +59,17 @@ public class DevicesAdapter extends ArrayAdapter<Device> {
} }
if (conn == null) { if (conn == null) {
download.setText(Util.readableTransferRate(getContext(), 0)); // Syncthing is not running.
upload.setText(Util.readableTransferRate(getContext(), 0)); rateInOutView.setVisibility(GONE);
status.setVisibility(GONE);
status.setText(r.getString(R.string.device_state_unknown)); status.setText(r.getString(R.string.device_state_unknown));
status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
return convertView; return convertView;
} }
if (conn.paused) { if (conn.paused) {
download.setText(Util.readableTransferRate(getContext(), 0)); rateInOutView.setVisibility(GONE);
upload.setText(Util.readableTransferRate(getContext(), 0)); status.setVisibility(VISIBLE);
status.setText(r.getString(R.string.device_paused)); status.setText(r.getString(R.string.device_paused));
status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black));
return convertView; return convertView;
@ -70,6 +78,8 @@ public class DevicesAdapter extends ArrayAdapter<Device> {
if (conn.connected) { if (conn.connected) {
download.setText(Util.readableTransferRate(getContext(), conn.inBits)); download.setText(Util.readableTransferRate(getContext(), conn.inBits));
upload.setText(Util.readableTransferRate(getContext(), conn.outBits)); upload.setText(Util.readableTransferRate(getContext(), conn.outBits));
rateInOutView.setVisibility(VISIBLE);
status.setVisibility(VISIBLE);
if (conn.completion == 100) { if (conn.completion == 100) {
status.setText(r.getString(R.string.device_up_to_date)); status.setText(r.getString(R.string.device_up_to_date));
status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green));
@ -81,8 +91,8 @@ public class DevicesAdapter extends ArrayAdapter<Device> {
} }
// !conn.connected // !conn.connected
download.setText(Util.readableTransferRate(getContext(), 0)); rateInOutView.setVisibility(GONE);
upload.setText(Util.readableTransferRate(getContext(), 0)); status.setVisibility(VISIBLE);
status.setText(r.getString(R.string.device_disconnected)); status.setText(r.getString(R.string.device_disconnected));
status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red));
return convertView; return convertView;
@ -91,14 +101,20 @@ public class DevicesAdapter extends ArrayAdapter<Device> {
/** /**
* Requests new connection info for all devices visible in listView. * 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++) { for (int i = 0; i < getCount(); i++) {
api.getConnections(this::onReceiveConnections); restApi.getConnections(this::onReceiveConnections);
} }
} }
private void onReceiveConnections(Connections connections) { private void onReceiveConnections(Connections connections) {
mConnections = connections; mConnections = connections;
// This will invoke "getView" for all elements.
notifyDataSetChanged(); notifyDataSetChanged();
} }
} }

View file

@ -62,6 +62,20 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
mContext.startService(intent); mContext.startService(intent);
}); });
binding.openFolder.setOnClickListener(view -> { FileUtils.openFolder(mContext, folder.path); } ); 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); updateFolderStatusView(binding, folder);
return binding.getRoot(); return binding.getRoot();
} }
@ -72,6 +86,7 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
binding.items.setVisibility(GONE); binding.items.setVisibility(GONE);
binding.override.setVisibility(GONE); binding.override.setVisibility(GONE);
binding.size.setVisibility(GONE); binding.size.setVisibility(GONE);
binding.state.setVisibility(GONE);
setTextOrHide(binding.invalid, folder.invalid); setTextOrHide(binding.invalid, folder.invalid);
return; return;
} }
@ -80,6 +95,7 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
boolean outOfSync = folderStatus.state.equals("idle") && neededItems > 0; boolean outOfSync = folderStatus.state.equals("idle") && neededItems > 0;
boolean overrideButtonVisible = folder.type.equals(Constants.FOLDER_TYPE_SEND_ONLY) && outOfSync; boolean overrideButtonVisible = folder.type.equals(Constants.FOLDER_TYPE_SEND_ONLY) && outOfSync;
binding.override.setVisibility(overrideButtonVisible ? VISIBLE : GONE); binding.override.setVisibility(overrideButtonVisible ? VISIBLE : GONE);
binding.state.setVisibility(VISIBLE);
if (outOfSync) { if (outOfSync) {
binding.state.setText(mContext.getString(R.string.status_outofsync)); binding.state.setText(mContext.getString(R.string.status_outofsync));
binding.state.setTextColor(ContextCompat.getColor(mContext, R.color.text_red)); binding.state.setTextColor(ContextCompat.getColor(mContext, R.color.text_red));
@ -143,8 +159,9 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
* Requests updated folder status from the api for all visible items. * Requests updated folder status from the api for all visible items.
*/ */
public void updateFolderStatus(RestApi restApi) { public void updateFolderStatus(RestApi restApi) {
if (restApi == null) { if (restApi == null || !restApi.isConfigLoaded()) {
Log.e(TAG, "updateFolderStatus: restApi == null"); // Syncthing is not running. Clear last state.
mLocalFolderStatuses.clear();
return; return;
} }
@ -158,6 +175,7 @@ public class FoldersAdapter extends ArrayAdapter<Folder> {
private void onReceiveFolderStatus(String folderId, FolderStatus folderStatus) { private void onReceiveFolderStatus(String folderId, FolderStatus folderStatus) {
mLocalFolderStatuses.put(folderId, folderStatus); mLocalFolderStatuses.put(folderId, folderStatus);
// This will invoke "getView" for all elements.
notifyDataSetChanged(); notifyDataSetChanged();
} }

View file

@ -2,10 +2,10 @@ Maintenance
* Updated syncthing core to v0.14.54 * Updated syncthing core to v0.14.54
* Fallback to http if https tls 1.2 is unavailable on Android 4.x * Fallback to http if https tls 1.2 is unavailable on Android 4.x
Enhancements Enhancements
* UI can be used to configure even when syncthing is not running [NEW]
* Added "Recent changes" UI, click files to open [NEW] * Added "Recent changes" UI, click files to open [NEW]
* Specify sync conditions differently for each folder, device [NEW] * Specify sync conditions differently for each folder, device [NEW]
* Added offline 'tips & tricks' content [NEW] * Added offline 'tips & tricks' content [NEW]
* UI explains why syncthing is running (or not)
Fixes Fixes
* Fixed the "battery eater" * Fixed the "battery eater"
* Android 8 and 9 support * Android 8 and 9 support

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -1,66 +1,107 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <layout xmlns:android="http://schemas.android.com/apk/res/android">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:paddingLeft="@dimen/abc_action_bar_content_inset_material"
android:paddingRight="@dimen/abc_action_bar_content_inset_material"
android:paddingTop="8dp">
<TextView <RelativeLayout
android:id="@+id/name" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentLeft="true" android:descendantFocusability="blocksDescendants">
android:layout_alignParentStart="true"
android:layout_toLeftOf="@+id/status"
android:layout_toStartOf="@+id/status"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?textAppearanceListItemPrimary" />
<TextView <RelativeLayout
android:id="@+id/status" android:id="@+id/inner"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignBottom="@+id/name" android:paddingBottom="8dp"
android:layout_alignParentEnd="true" android:paddingLeft="@dimen/abc_action_bar_content_inset_material"
android:layout_alignParentRight="true" android:paddingRight="@dimen/abc_action_bar_content_inset_material"
android:textAppearance="?textAppearanceListItemSmall" /> android:paddingTop="8dp">
<TextView <TextView
android:id="@+id/download_title" android:id="@+id/name"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/name" android:layout_alignParentStart="true"
android:text="@string/download_title_colon" android:layout_alignParentLeft="true"
android:textAppearance="?textAppearanceListItemSecondary" /> android:ellipsize="end"
android:lines="1"
android:textAppearance="?textAppearanceListItemPrimary" />
<TextView <RelativeLayout
android:id="@+id/download" android:id="@+id/rateInOutContainer"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_alignBaseline="@id/download_title" android:layout_below="@+id/name">
android:layout_toEndOf="@id/download_title"
android:layout_toRightOf="@id/download_title"
android:textAppearance="?textAppearanceListItemSecondary" />
<TextView <TextView
android:id="@+id/upload_title" android:id="@+id/download_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/download_title" android:text="@string/download_title_colon"
android:text="@string/upload_title_colon" android:textAppearance="?textAppearanceListItemSecondary" />
android:textAppearance="?textAppearanceListItemSecondary" />
<TextView <TextView
android:id="@+id/upload" android:id="@+id/download"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignBaseline="@id/upload_title" android:layout_alignBaseline="@id/download_title"
android:layout_toEndOf="@id/upload_title" android:layout_toEndOf="@id/download_title"
android:layout_toRightOf="@id/upload_title" android:layout_toRightOf="@id/download_title"
android:textAppearance="?textAppearanceListItemSecondary" /> android:textAppearance="?textAppearanceListItemSecondary" />
</RelativeLayout> <TextView
android:id="@+id/upload_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/download_title"
android:text="@string/upload_title_colon"
android:textAppearance="?textAppearanceListItemSecondary" />
<TextView
android:id="@+id/upload"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@id/upload_title"
android:layout_toEndOf="@id/upload_title"
android:layout_toRightOf="@id/upload_title"
android:textAppearance="?textAppearanceListItemSecondary" />
</RelativeLayout>
</RelativeLayout>
<TextView
android:id="@+id/status"
android:ellipsize="end"
android:gravity="center_vertical"
android:layout_alignBottom="@+id/inner"
android:layout_alignTop="@+id/inner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toStartOf="@+id/open_device"
android:layout_toLeftOf="@+id/open_device"
android:lines="1"
android:textAppearance="?textAppearanceListItemSmall"
tools:ignore="RelativeOverlap" />
<ImageButton
android:id="@+id/open_device"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/inner"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_alignTop="@+id/inner"
android:background="@null"
android:contentDescription="@null"
android:paddingBottom="5dp"
android:paddingEnd="20dp"
android:paddingLeft="25dp"
android:paddingRight="20dp"
android:paddingStart="30dp"
android:paddingTop="5dp"
android:src="@drawable/ic_phonelink_black_24dp_active" />
</RelativeLayout>
</layout>

View file

@ -32,7 +32,6 @@
android:id="@+id/state" android:id="@+id/state"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignBottom="@id/id"
android:layout_alignParentEnd="true" android:layout_alignParentEnd="true"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:textAppearance="?textAppearanceListItemSmall" /> android:textAppearance="?textAppearanceListItemSmall" />
@ -41,7 +40,7 @@
android:id="@+id/directory" android:id="@+id/directory"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/state" android:layout_below="@id/label"
android:ellipsize="end" android:ellipsize="end"
android:textAppearance="?textAppearanceListItemSecondary" /> android:textAppearance="?textAppearanceListItemSecondary" />

View file

@ -220,6 +220,9 @@ Bitte melden Sie auftretende Probleme via GitHub.</string>
<!-- Toast shown when trying to create a folder with an empty ID --> <!-- Toast shown when trying to create a folder with an empty ID -->
<string name="folder_id_required">Die Verzeichnis ID darf nicht leer sein</string> <string name="folder_id_required">Die Verzeichnis ID darf nicht leer sein</string>
<!-- Toast shown when trying to create a folder with an empty label -->
<string name="folder_label_required">Die Verzeichnisbezeichnung darf nicht leer sein</string>
<!-- Toast shown when trying to create a folder with an empty path --> <!-- Toast shown when trying to create a folder with an empty path -->
<string name="folder_path_required">Der Verzeichnispfad darf nicht leer sein</string> <string name="folder_path_required">Der Verzeichnispfad darf nicht leer sein</string>
@ -283,6 +286,12 @@ Bitte melden Sie auftretende Probleme via GitHub.</string>
<!-- Toast shown when trying to create a device with an empty ID --> <!-- Toast shown when trying to create a device with an empty ID -->
<string name="device_id_required">Die Geräte-ID darf nicht leer sein</string> <string name="device_id_required">Die Geräte-ID darf nicht leer sein</string>
<!-- Toast shown when trying to create a device with an empty ID -->
<string name="device_id_invalid">Die Geräte-ID ist nicht im richtigen Format. Bitte versuche es erneut und gebe die richtige ID ein.</string>
<!-- Toast shown when trying to create a device with an empty name -->
<string name="device_name_required">Der Gerätename darf nicht leer sein</string>
<!-- Content description for device ID qr code icon --> <!-- Content description for device ID qr code icon -->
<string name="scan_qr_code_description">QR Code scannen</string> <string name="scan_qr_code_description">QR Code scannen</string>
@ -416,7 +425,7 @@ Bitte melden Sie auftretende Probleme via GitHub.</string>
<item>Keine</item> <item>Keine</item>
<item>Papierkorb</item> <item>Papierkorb</item>
<item>Einfach</item> <item>Einfach</item>
<item>Stufenweis</item> <item>Gestaffelt</item>
<item>Extern</item> <item>Extern</item>
</string-array> </string-array>

View file

@ -220,6 +220,9 @@ Please report any problems you encounter via Github.</string>
<!-- Toast shown when trying to create a folder with an empty ID --> <!-- Toast shown when trying to create a folder with an empty ID -->
<string name="folder_id_required">The folder ID must not be empty</string> <string name="folder_id_required">The folder ID must not be empty</string>
<!-- Toast shown when trying to create a folder with an empty label -->
<string name="folder_label_required">The folder label must not be empty</string>
<!-- Toast shown when trying to create a folder with an empty path --> <!-- Toast shown when trying to create a folder with an empty path -->
<string name="folder_path_required">The folder path must not be empty</string> <string name="folder_path_required">The folder path must not be empty</string>
@ -283,6 +286,12 @@ Please report any problems you encounter via Github.</string>
<!-- Toast shown when trying to create a device with an empty ID --> <!-- Toast shown when trying to create a device with an empty ID -->
<string name="device_id_required">The device ID must not be empty</string> <string name="device_id_required">The device ID must not be empty</string>
<!-- Toast shown when trying to create a device with an empty ID -->
<string name="device_id_invalid">The device ID is not formatted correctly. Please retry and enter the correct ID.</string>
<!-- Toast shown when trying to create a device with an empty name -->
<string name="device_name_required">The device name must not be empty</string>
<!-- Content description for device ID qr code icon --> <!-- Content description for device ID qr code icon -->
<string name="scan_qr_code_description">Scan QR Code</string> <string name="scan_qr_code_description">Scan QR Code</string>