1
0
Fork 0
mirror of https://github.com/syncthing/syncthing-android.git synced 2024-11-29 15:51: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"
minSdkVersion 16
targetSdkVersion 26
versionCode 4181
versionName "0.14.54.2"
versionCode 4182
versionName "0.14.54.3"
testApplicationId 'com.github.catfriend1.syncthingandroid.test'
testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
playAccountConfig = playAccountConfigs.defaultAccountConfig

View file

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

View file

@ -93,6 +93,21 @@ public class FirstStartActivity extends Activity {
super.onCreate(savedInstanceState);
((SyncthingApp) getApplication()).component().inject(this);
/**
* Check if a valid config exists that can be read and parsed.
*/
Boolean configParseable = false;
Boolean configExists = Constants.getConfigFile(this).exists();
if (configExists) {
ConfigXml configParseTest = new ConfigXml(this);
try {
configParseTest.loadConfig();
configParseable = true;
} catch (ConfigXml.OpenConfigException e) {
Log.d(TAG, "Failed to parse existing config. Will show key generation slide ...");
}
}
/**
* Check if prerequisites to run the app are still in place.
* If anything mandatory is missing, the according welcome slide(s) will be shown.
@ -100,7 +115,7 @@ public class FirstStartActivity extends Activity {
Boolean showSlideStoragePermission = !haveStoragePermission();
Boolean showSlideIgnoreDozePermission = !haveIgnoreDozePermission();
Boolean showSlideLocationPermission = !haveLocationPermission();
Boolean showSlideKeyGeneration = !Constants.getConfigFile(this).exists();
Boolean showSlideKeyGeneration = !configExists || !configParseable;
/**
* If we don't have to show slides for mandatory prerequisites,
@ -480,8 +495,10 @@ public class FirstStartActivity extends Activity {
cancel(true);
return null;
}
try {
configXml = new ConfigXml(firstStartActivity);
try {
// Create new secure keys and config.
configXml.generateConfig();
} catch (ExecutableNotFoundException e) {
publishProgress(firstStartActivity.getString(R.string.executable_not_found, e.getMessage()));
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.SyncthingService;
import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.util.ConfigRouter;
import com.nutomic.syncthingandroid.util.FileUtils;
import com.nutomic.syncthingandroid.util.TextWatcherAdapter;
import com.nutomic.syncthingandroid.util.Util;
@ -85,6 +86,7 @@ public class FolderActivity extends SyncthingActivity
private static final String FOLDER_MARKER_NAME = ".stfolder";
// private static final String IGNORE_FILE_NAME = ".stignore";
private ConfigRouter mConfig;
private Folder mFolder;
// Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21)
private Uri mFolderUri = null;
@ -154,7 +156,7 @@ public class FolderActivity extends SyncthingActivity
case R.id.device_toggle:
Device device = (Device) view.getTag();
if (isChecked) {
mFolder.addDevice(device.deviceID);
mFolder.addDevice(device);
} else {
mFolder.removeDevice(device.deviceID);
}
@ -166,6 +168,8 @@ public class FolderActivity extends SyncthingActivity
@Override
public void onCreate(Bundle savedInstanceState) {
mConfig = new ConfigRouter(FolderActivity.this);
super.onCreate(savedInstanceState);
((SyncthingApp) getApplication()).component().inject(this);
setContentView(R.layout.fragment_folder);
@ -384,14 +388,9 @@ public class FolderActivity extends SyncthingActivity
@Override
public void onServiceStateChange(SyncthingService.State currentState) {
if (currentState != ACTIVE) {
finish();
return;
}
if (!mIsCreateMode) {
RestApi restApi = getApi(); // restApi != null because of State.ACTIVE
List<Folder> folders = restApi.getFolders();
RestApi restApi = getApi();
List<Folder> folders = mConfig.getFolders(restApi);
String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID);
mFolder = null;
for (Folder currentFolder : folders) {
@ -405,11 +404,15 @@ public class FolderActivity extends SyncthingActivity
finish();
return;
}
restApi.getFolderIgnoreList(mFolder.id, this::onReceiveFolderIgnoreList);
mConfig.getFolderIgnoreList(restApi, mFolder, this::onReceiveFolderIgnoreList);
checkWriteAndUpdateUI();
}
// If the extra is set, we should automatically share the current folder with the given device.
if (getIntent().hasExtra(EXTRA_DEVICE_ID)) {
mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID));
Device device = new Device();
device.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID);
mFolder.addDevice(device);
mFolderNeedsToUpdate = true;
}
@ -471,13 +474,14 @@ public class FolderActivity extends SyncthingActivity
mCustomSyncConditionsDialog.setEnabled(mCustomSyncConditionsSwitch.isChecked());
// Populate devicesList.
List<Device> devicesList = getApi().getDevices(false);
RestApi restApi = getApi();
List<Device> devicesList = mConfig.getDevices(restApi, false);
mDevicesContainer.removeAllViews();
if (devicesList.isEmpty()) {
addEmptyDeviceListView();
} else {
for (Device n : devicesList) {
addDeviceViewAndSetListener(n, getLayoutInflater());
for (Device device : devicesList) {
addDeviceViewAndSetListener(device, getLayoutInflater());
}
}
@ -511,6 +515,11 @@ public class FolderActivity extends SyncthingActivity
.show();
return true;
}
if (TextUtils.isEmpty(mFolder.label)) {
Toast.makeText(this, R.string.folder_label_required, Toast.LENGTH_LONG)
.show();
return true;
}
if (TextUtils.isEmpty(mFolder.path)) {
Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG)
.show();
@ -531,7 +540,7 @@ public class FolderActivity extends SyncthingActivity
dfFolder.createDirectory(FOLDER_MARKER_NAME);
}
}
getApi().createFolder(mFolder);
mConfig.addFolder(getApi(), mFolder);
finish();
return true;
case R.id.remove:
@ -554,10 +563,7 @@ public class FolderActivity extends SyncthingActivity
return new AlertDialog.Builder(this)
.setMessage(R.string.remove_folder_confirm)
.setPositiveButton(android.R.string.yes, (dialogInterface, i) -> {
RestApi restApi = getApi();
if (restApi != null) {
restApi.removeFolder(mFolder.id);
}
mConfig.removeFolder(getApi(), mFolder.id);
mFolderNeedsToUpdate = false;
finish();
})
@ -731,17 +737,13 @@ public class FolderActivity extends SyncthingActivity
// Update folder via restApi and send the config to REST endpoint.
RestApi restApi = getApi();
if (restApi == null) {
Log.e(TAG, "updateFolder: restApi == null");
return;
}
// Update ignore list.
String[] ignore = mEditIgnoreListContent.getText().toString().split("\n");
restApi.postFolderIgnoreList(mFolder.id, ignore);
mConfig.postFolderIgnoreList(restApi, mFolder, ignore);
// Update model and send the config to REST endpoint.
restApi.updateFolder(mFolder);
// Update folder using RestApi or ConfigXml.
mConfig.updateFolder(restApi, mFolder);
}
@Override

View file

@ -9,7 +9,6 @@ import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
@ -31,8 +30,6 @@ import com.google.common.collect.Sets;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.service.SyncthingServiceBinder;
import com.nutomic.syncthingandroid.util.Util;
import java.io.File;
@ -45,7 +42,7 @@ import java.util.Iterator;
* Activity that allows selecting a directory in the local file system.
*/
public class FolderPickerActivity extends SyncthingActivity
implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener {
implements AdapterView.OnItemClickListener {
private static final String EXTRA_INITIAL_DIRECTORY =
"com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY";
@ -151,22 +148,6 @@ public class FolderPickerActivity extends SyncthingActivity
mRootsAdapter.addAll(Sets.newTreeSet(roots));
}
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
super.onServiceConnected(componentName, iBinder);
SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder;
syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this);
}
@Override
protected void onDestroy() {
super.onDestroy();
SyncthingService syncthingService = getService();
if (syncthingService != null) {
syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange);
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
if (mListView.getAdapter() == mRootsAdapter)
@ -324,14 +305,6 @@ public class FolderPickerActivity extends SyncthingActivity
}
}
@Override
public void onServiceStateChange(SyncthingService.State currentState) {
if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) {
setResult(Activity.RESULT_CANCELED);
finish();
}
}
/**
* Displays a list of all available roots, or if there is only one root, the
* contents of that folder.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -260,7 +260,7 @@ public class StatusFragment extends ListFragment implements SyncthingService.OnS
int announceConnected =
announceTotal - Optional.fromNullable(systemStatus.discoveryErrors).transform(Map::size).or(0);
synchronized (mStatusHolderLock) {
mCpuUsage = (systemStatus.cpuPercent / 100 < 1) ? "" : percentFormat.format(systemStatus.cpuPercent / 100);
mCpuUsage = (systemStatus.cpuPercent < 5) ? "" : percentFormat.format(systemStatus.cpuPercent / 100);
mRamUsage = Util.readableFileSize(mActivity, systemStatus.sys);
mAnnounceServer = (announceTotal == 0) ?
"" :

View file

@ -38,6 +38,8 @@ public abstract class ApiRequest {
private static final String TAG = "ApiRequest";
private static final Boolean ENABLE_VERBOSE_LOG = false;
/**
* The name of the HTTP header used for the syncthing API key.
*/
@ -92,7 +94,9 @@ public abstract class ApiRequest {
*/
void connect(int requestMethod, Uri uri, @Nullable String requestBody,
@Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) {
if (ENABLE_VERBOSE_LOG) {
Log.v(TAG, "Performing request to " + uri.toString());
}
StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> {
if (listener != null) {
listener.onSuccess(reply);

View file

@ -10,17 +10,29 @@ public class Device {
public List<String> addresses;
public String compression;
public String certName;
public boolean introducer;
public String introducedBy = "";
public boolean introducer = false;
public boolean paused;
public List<PendingFolder> pendingFolders;
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.
*/
public String getDisplayName() {
return (TextUtils.isEmpty(name))
? deviceID.substring(0, 7)
? (TextUtils.isEmpty(deviceID) ? "" : deviceID.substring(0, 7))
: name;
}
}

View file

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

View file

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

View file

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

View file

@ -71,7 +71,6 @@ public class RestApi {
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);
};
@ -453,7 +452,7 @@ public class RestApi {
/**
* This is only used for new folder creation, see {@link FolderActivity}.
*/
public void createFolder(Folder folder) {
public void addFolder(Folder folder) {
synchronized (mConfigLock) {
// Add the new folder to the model.
mConfig.folders.add(folder);
@ -500,7 +499,7 @@ public class RestApi {
*
* @param includeLocal True if the local device should be included in the result.
*/
public List<Device> getDevices(boolean includeLocal) {
public List<Device> getDevices(Boolean includeLocal) {
List<Device> devices;
synchronized (mConfigLock) {
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"));
if (folder.paused != !syncConditionsMet) {
folder.paused = !syncConditionsMet;
Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0"));
configChanged = true;
}
}
@ -872,6 +872,7 @@ public class RestApi {
Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0"));
if (device.paused != !syncConditionsMet) {
device.paused = !syncConditionsMet;
Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0"));
configChanged = true;
}
}

View file

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

View file

@ -40,6 +40,8 @@ import javax.inject.Inject;
import eu.chainfire.libsuperuser.Shell;
import static com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE;
/**
* Runs the syncthing binary from command line, and prints its output to logcat.
*
@ -258,8 +260,10 @@ public class SyncthingRunnable implements Runnable {
// Notify {@link SyncthingService} that service state State.ACTIVE is no longer valid.
if (!returnStdOut && sendStopToService) {
mContext.startService(new Intent(mContext, SyncthingService.class)
.setAction(SyncthingService.ACTION_STOP));
Intent intent = new Intent(mContext, SyncthingService.class);
intent.setAction(SyncthingService.ACTION_STOP);
intent.putExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, true);
mContext.startService(intent);
}
// Return captured command line output.

View file

@ -8,6 +8,7 @@ import android.Manifest;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
@ -20,6 +21,7 @@ import com.google.common.io.Files;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.SyncthingApp;
import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask;
import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.util.ConfigXml;
import com.nutomic.syncthingandroid.util.FileUtils;
@ -37,6 +39,7 @@ import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -112,6 +115,13 @@ public class SyncthingService extends Service {
public static final String EXTRA_FOLDER_ID =
"com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID";
/**
* Extra used together with ACTION_STOP.
*/
public static final String EXTRA_STOP_AFTER_CRASHED_NATIVE =
"com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE";
public interface OnSyncthingKilled {
void onKilled();
}
@ -155,7 +165,6 @@ public class SyncthingService extends Service {
*/
private State mCurrentState = State.DISABLED;
private ConfigXml mConfig;
private StartupTask mStartupTask = null;
private Thread mSyncthingRunnableThread = null;
private Handler mHandler;
@ -276,9 +285,23 @@ public class SyncthingService extends Service {
if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
shutdown(State.INIT, () -> launchStartupTask(SyncthingRunnable.Command.main));
} else if (ACTION_STOP.equals(intent.getAction()) && mCurrentState == State.ACTIVE) {
shutdown(State.DISABLED, () -> {
});
} else if (ACTION_STOP.equals(intent.getAction())) {
if (intent.getBooleanExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, false)) {
/**
* We were requested to stop the service because the syncthing native binary crashed.
* Changing mCurrentState prevents the "defer until syncthing is started" routine we normally
* use for clean shutdown to take place. Instead, we will immediately shutdown the crashed
* instance forcefully.
*/
mCurrentState = State.ERROR;
shutdown(State.DISABLED, () -> {});
} else {
// Graceful shutdown.
if (mCurrentState == State.STARTING ||
mCurrentState == State.ACTIVE) {
shutdown(State.DISABLED, () -> {});
}
}
} else if (ACTION_RESET_DATABASE.equals(intent.getAction())) {
Log.i(TAG, "Invoking reset of database");
shutdown(State.INIT, () -> {
@ -348,10 +371,84 @@ public class SyncthingService extends Service {
* After sync preconditions changed, we need to inform {@link RestApi} to pause or
* unpause devices and folders as defined in per-object sync preferences.
*/
private void onSyncPreconditionChanged() {
if (mRestApi != null) {
// Forward event.
mRestApi.onSyncPreconditionChanged(mRunConditionMonitor);
private void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor) {
synchronized (mStateLock) {
if (mRestApi != null && mCurrentState == State.ACTIVE) {
// Forward event because syncthing is running.
mRestApi.onSyncPreconditionChanged(runConditionMonitor);
return;
}
}
Log.v(TAG, "onSyncPreconditionChanged: Event fired while syncthing is not running.");
Boolean configChanged = false;
ConfigXml configXml;
// Read and parse the config from disk.
configXml = new ConfigXml(this);
try {
configXml.loadConfig();
} catch (ConfigXml.OpenConfigException e) {
mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "onSyncPreconditionChanged:ConfigXml.OpenConfigException");
synchronized (mStateLock) {
onServiceStateChange(State.ERROR);
}
return;
}
// Check if the folders are available from config.
List<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.
*/
private void launchStartupTask(SyncthingRunnable.Command srCommand) {
Log.v(TAG, "Starting syncthing");
synchronized (mStateLock) {
if (mCurrentState != State.DISABLED && mCurrentState != State.INIT) {
Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling.");
return;
}
}
// Safety check: Log warning if a previously launched startup task did not finish properly.
if (mStartupTask != null && (mStartupTask.getStatus() == AsyncTask.Status.RUNNING)) {
Log.w(TAG, "launchStartupTask: StartupTask is still running. Skipped starting it twice.");
Log.v(TAG, "Starting syncthing");
onServiceStateChange(State.STARTING);
mConfig = new ConfigXml(this);
try {
mConfig.loadConfig();
} catch (ConfigXml.OpenConfigException e) {
mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException");
synchronized (mStateLock) {
onServiceStateChange(State.ERROR);
}
return;
}
onServiceStateChange(State.STARTING);
mStartupTask = new StartupTask(this, srCommand);
mStartupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Sets up the initial configuration, and updates the config when coming from an old
* version.
*/
private static class StartupTask extends AsyncTask<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) {
mRestApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(),
this::onApiAvailable, () -> onServiceStateChange(mCurrentState));
@ -652,13 +697,13 @@ public class SyncthingService extends Service {
mCurrentState = newState;
mHandler.post(() -> {
mNotificationHandler.updatePersistentNotification(this);
for (Iterator<OnServiceStateChangeListener> i = mOnServiceStateChangeListeners.iterator();
i.hasNext(); ) {
OnServiceStateChangeListener listener = i.next();
Iterator<OnServiceStateChangeListener> it = mOnServiceStateChangeListeners.iterator();
while (it.hasNext()) {
OnServiceStateChangeListener listener = it.next();
if (listener != null) {
listener.onServiceStateChange(mCurrentState);
} else {
i.remove();
it.remove();
}
}
});
@ -773,8 +818,15 @@ public class SyncthingService extends Service {
// Start syncthing after export if run conditions apply.
if (mLastDeterminedShouldRun) {
Handler mainLooper = new Handler(Looper.getMainLooper());
Runnable launchStartupTaskRunnable = new Runnable() {
@Override
public void run() {
launchStartupTask(SyncthingRunnable.Command.main);
}
};
mainLooper.post(launchStartupTaskRunnable);
}
return failSuccess;
}
@ -844,6 +896,7 @@ public class SyncthingService extends Service {
case Constants.PREF_DEBUG_FACILITIES_AVAILABLE:
case Constants.PREF_EVENT_PROCESSOR_LAST_SYNC_ID:
case Constants.PREF_LAST_BINARY_VERSION:
case Constants.PREF_LOCAL_DEVICE_ID:
Log.v(TAG, "importConfig: Ignoring cache pref \"" + prefKey + "\".");
break;
default:
@ -929,8 +982,15 @@ public class SyncthingService extends Service {
// Start syncthing after import if run conditions apply.
if (mLastDeterminedShouldRun) {
Handler mainLooper = new Handler(Looper.getMainLooper());
Runnable launchStartupTaskRunnable = new Runnable() {
@Override
public void run() {
launchStartupTask(SyncthingRunnable.Command.main);
}
};
mainLooper.post(launchStartupTaskRunnable);
}
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;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;
import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.model.FolderIgnoreList;
import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.SyncthingRunnable;
@ -23,12 +25,16 @@ import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
@ -53,42 +59,72 @@ import org.xml.sax.InputSource;
*/
public class ConfigXml {
private static final String TAG = "ConfigXml";
private static final Boolean ENABLE_VERBOSE_LOG = false;
public class OpenConfigException extends RuntimeException {
}
private static final String TAG = "ConfigXml";
/**
* Compares devices by name, uses the device ID as fallback if the name is empty
*/
private final static Comparator<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 final Context mContext;
@Inject
SharedPreferences mPreferences;
private final File mConfigFile;
private Document mConfig;
public ConfigXml(Context context) throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException {
public ConfigXml(Context context) {
mContext = context;
mConfigFile = Constants.getConfigFile(mContext);
boolean isFirstStart = !mConfigFile.exists();
if (isFirstStart) {
Log.i(TAG, "App started for the first time. Generating keys and config.");
new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run(true);
}
readConfig();
public void loadConfig() throws OpenConfigException {
parseConfig();
updateIfNeeded();
}
if (isFirstStart) {
boolean changed = false;
/**
* 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 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}$")) {
String localDeviceID = getLocalDeviceIDandStoreToPref();
if (!TextUtils.isEmpty(localDeviceID)) {
changed = changeLocalDeviceName(localDeviceID) || changed;
}
// Set default folder to the "camera" folder: path and name
changed = changeDefaultFolder() || changed;
// Save changes if we made any.
@ -96,10 +132,44 @@ public class ConfigXml {
saveChanges();
}
}
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 void readConfig() {
private String getLocalDeviceIDandStoreToPref() throws SyncthingRunnable.ExecutableNotFoundException {
String logOutput = new SyncthingRunnable(mContext, SyncthingRunnable.Command.deviceid).run(true);
String localDeviceID = logOutput.replace("\n", "");
// Verify that local device ID is correctly formatted.
if (!isDeviceIdValid(localDeviceID)) {
Log.w(TAG, "getLocalDeviceIDandStoreToPref: Syncthing core returned a bad formatted device ID \"" + localDeviceID + "\"");
return "";
}
// Store local device ID to pref. This saves us expensive calls to the syncthing binary if we need it later.
PreferenceManager.getDefaultSharedPreferences(mContext).edit()
.putString(Constants.PREF_LOCAL_DEVICE_ID, localDeviceID)
.apply();
Log.v(TAG, "getLocalDeviceIDandStoreToPref: Cached local device ID \"" + localDeviceID + "\"");
return localDeviceID;
}
private void parseConfig() {
if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) {
Log.w(TAG, "Failed to open config file '" + mConfigFile + "'");
throw new OpenConfigException();
}
try {
@ -107,11 +177,16 @@ public class ConfigXml {
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8");
InputSource inputSource = new InputSource(inputStreamReader);
inputSource.setEncoding("UTF-8");
DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Log.d(TAG, "Parsing config file '" + mConfigFile + "'");
DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbfactory.newDocumentBuilder();
if (ENABLE_VERBOSE_LOG) {
Log.v(TAG, "Parsing config file '" + mConfigFile + "'");
}
mConfig = db.parse(inputSource);
inputStream.close();
Log.i(TAG, "Successfully parsed config file");
if (ENABLE_VERBOSE_LOG) {
Log.v(TAG, "Successfully parsed config file");
}
} catch (SAXException | ParserConfigurationException | IOException e) {
Log.w(TAG, "Failed to parse config file '" + mConfigFile + "'", e);
throw new OpenConfigException();
@ -141,7 +216,7 @@ public class ConfigXml {
* username/password, and disables weak hash checking.
*/
@SuppressWarnings("SdCardPath")
public void updateIfNeeded() {
private void updateIfNeeded() {
boolean changed = false;
/* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */
@ -173,7 +248,7 @@ public class ConfigXml {
Boolean forceHttps = Constants.osSupportsTLS12();
if (!gui.hasAttribute("tls") ||
Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) {
gui.setAttribute("tls", forceHttps ? "true" : "false");
gui.setAttribute("tls", Boolean.toString(forceHttps));
changed = true;
}
@ -222,6 +297,9 @@ public class ConfigXml {
}
}
// Disable "startBrowser" because it applies to desktop environments and cannot start a mobile browser app.
changed = setConfigElement(options, "startBrowser", "false") || changed;
// Save changes if we made any.
if (changed) {
saveChanges();
@ -238,7 +316,9 @@ public class ConfigXml {
/* Read existing config version */
int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version"));
int iOldConfigVersion = iConfigVersion;
Log.i(TAG, "Found existing config version " + Integer.toString(iConfigVersion));
if (ENABLE_VERBOSE_LOG) {
Log.v(TAG, "Found existing config version " + Integer.toString(iConfigVersion));
}
/* Check if we have to do manual migration from version X to Y */
if (iConfigVersion == 27) {
@ -273,6 +353,438 @@ public class ConfigXml {
}
}
private Boolean getAttributeOrDefault(final Element element, String attribute, Boolean defaultValue) {
return element.hasAttribute(attribute) ? Boolean.parseBoolean(element.getAttribute(attribute)) : defaultValue;
}
private Integer getAttributeOrDefault(final Element element, String attribute, Integer defaultValue) {
return element.hasAttribute(attribute) ? Integer.parseInt(element.getAttribute(attribute)) : defaultValue;
}
private String getAttributeOrDefault(final Element element, String attribute, String defaultValue) {
return element.hasAttribute(attribute) ? element.getAttribute(attribute) : defaultValue;
}
private Boolean getContentOrDefault(final Node node, Boolean defaultValue) {
return (node == null) ? defaultValue : Boolean.parseBoolean(node.getTextContent());
}
private Integer getContentOrDefault(final Node node, Integer defaultValue) {
return (node == null) ? defaultValue : Integer.parseInt(node.getTextContent());
}
private String getContentOrDefault(final Node node, String defaultValue) {
return (node == null) ? defaultValue : node.getTextContent();
}
public List<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) {
Node element = parent.getElementsByTagName(tagName).item(0);
if (element == null) {
@ -347,16 +859,23 @@ public class ConfigXml {
return sb.toString();
}
/**
* Returns if a syncthing device ID is correctly formatted.
*/
private Boolean isDeviceIdValid(final String deviceID) {
return deviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$");
}
/**
* Writes updated mConfig back to file.
*/
private void saveChanges() {
public void saveChanges() {
if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) {
Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file.");
return;
}
Log.i(TAG, "Writing updated config file");
Log.i(TAG, "Saving config file");
File mConfigTempFile = Constants.getConfigTempFile(mContext);
try {
// Write XML header.

View file

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

View file

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

View file

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

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,6 +1,15 @@
<?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">
<RelativeLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:descendantFocusability="blocksDescendants">
<RelativeLayout
android:id="@+id/inner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
@ -12,28 +21,22 @@
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_toLeftOf="@+id/status"
android:layout_toStartOf="@+id/status"
android:layout_alignParentLeft="true"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?textAppearanceListItemPrimary" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@+id/name"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:textAppearance="?textAppearanceListItemSmall" />
<RelativeLayout
android:id="@+id/rateInOutContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/name">
<TextView
android:id="@+id/download_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/name"
android:text="@string/download_title_colon"
android:textAppearance="?textAppearanceListItemSecondary" />
@ -63,4 +66,42 @@
android:layout_toRightOf="@id/upload_title"
android:textAppearance="?textAppearanceListItemSecondary" />
</RelativeLayout>
</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:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/id"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:textAppearance="?textAppearanceListItemSmall" />
@ -41,7 +40,7 @@
android:id="@+id/directory"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/state"
android:layout_below="@id/label"
android:ellipsize="end"
android:textAppearance="?textAppearanceListItemSecondary" />

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 -->
<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 -->
<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 -->
<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 -->
<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>Papierkorb</item>
<item>Einfach</item>
<item>Stufenweis</item>
<item>Gestaffelt</item>
<item>Extern</item>
</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 -->
<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 -->
<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 -->
<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 -->
<string name="scan_qr_code_description">Scan QR Code</string>