diff --git a/app/build.gradle b/app/build.gradle index 71c0a2d3..f4a1e1f5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,8 +37,8 @@ android { applicationId "com.github.catfriend1.syncthingandroid" minSdkVersion 16 targetSdkVersion 26 - versionCode 4181 - versionName "0.14.54.2" + versionCode 4182 + versionName "0.14.54.3" testApplicationId 'com.github.catfriend1.syncthingandroid.test' testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner' playAccountConfig = playAccountConfigs.defaultAccountConfig diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java index ba497076..decfbab5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/DeviceActivity.java @@ -34,6 +34,7 @@ import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.util.Compression; +import com.nutomic.syncthingandroid.util.ConfigRouter; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; @@ -68,13 +69,15 @@ public class DeviceActivity extends SyncthingActivity public static final String EXTRA_IS_CREATE = "com.nutomic.syncthingandroid.activities.DeviceActivity.IS_CREATE"; - private static final String TAG = "DeviceSettingsFragment"; + private static final String TAG = "DeviceActivity"; private static final String IS_SHOWING_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_COMPRESSION_DIALOG = "COMPRESSION_FOLDER_DIALOG_STATE"; private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; private static final List DYNAMIC_ADDRESS = Collections.singletonList("dynamic"); + private ConfigRouter mConfig; + private Device mDevice; private View mIdContainer; @@ -186,6 +189,8 @@ public class DeviceActivity extends SyncthingActivity @Override public void onCreate(Bundle savedInstanceState) { + mConfig = new ConfigRouter(DeviceActivity.this); + super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); setContentView(R.layout.fragment_device); @@ -338,14 +343,9 @@ public class DeviceActivity extends SyncthingActivity @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - if (!mIsCreateMode) { - RestApi restApi = getApi(); // restApi != null because of State.ACTIVE - List devices = restApi.getDevices(false); + RestApi restApi = getApi(); + List devices = mConfig.getDevices(restApi, false); String passedId = getIntent().getStringExtra(EXTRA_DEVICE_ID); mDevice = null; for (Device currentDevice : devices) { @@ -427,8 +427,16 @@ public class DeviceActivity extends SyncthingActivity .show(); return true; } - getApi().addDevice(mDevice, error -> - Toast.makeText(this, error, Toast.LENGTH_LONG).show()); + if (isEmpty(mDevice.name)) { + Toast.makeText(this, R.string.device_name_required, Toast.LENGTH_LONG) + .show(); + return true; + } + mConfig.addDevice( + getApi(), + mDevice, + error -> Toast.makeText(this, error, Toast.LENGTH_LONG).show() + ); finish(); return true; case R.id.share_device_id: @@ -455,7 +463,8 @@ public class DeviceActivity extends SyncthingActivity return new android.app.AlertDialog.Builder(this) .setMessage(R.string.remove_device_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - getApi().removeDevice(mDevice.deviceID); + mConfig.removeDevice(getApi(), mDevice.deviceID); + mDeviceNeedsToUpdate = false; finish(); }) .setNegativeButton(android.R.string.no, null) @@ -474,6 +483,9 @@ public class DeviceActivity extends SyncthingActivity } } + /** + * Used in mIsCreateMode. + */ private void initDevice() { mDevice = new Device(); mDevice.name = getIntent().getStringExtra(EXTRA_DEVICE_NAME); @@ -482,6 +494,7 @@ public class DeviceActivity extends SyncthingActivity mDevice.compression = METADATA.getValue(this); mDevice.introducer = false; mDevice.paused = false; + mDevice.introducedBy = ""; } private void prepareEditMode() { @@ -501,13 +514,14 @@ public class DeviceActivity extends SyncthingActivity */ private void updateDevice() { if (mIsCreateMode) { - // If we are about to create this folder, we cannot update via restApi. + // If we are about to create this device, we cannot update via restApi. return; } if (mDevice == null) { Log.e(TAG, "updateDevice: mDevice == null"); return; } + // Log.v(TAG, "deviceID=" + mDevice.deviceID + ", introducedBy=" + mDevice.introducedBy); // Save device specific preferences. Log.v(TAG, "updateDevice: mDevice.deviceID = \'" + mDevice.deviceID + "\'"); @@ -518,26 +532,42 @@ public class DeviceActivity extends SyncthingActivity ); editor.apply(); - // Update device via restApi and send the config to REST endpoint. - RestApi restApi = getApi(); - if (restApi == null) { - Log.e(TAG, "updateDevice: restApi == null"); - return; - } - restApi.updateDevice(mDevice); + // Update device using RestApi or ConfigXml. + mConfig.updateDevice(getApi(), mDevice); } private List persistableAddresses(CharSequence userInput) { - return isEmpty(userInput) - ? DYNAMIC_ADDRESS - : Arrays.asList(userInput.toString().split(" ")); + if (isEmpty(userInput)) { + return DYNAMIC_ADDRESS; + } + + /** + * Be fault-tolerant here. + * The user can write like this: + * tcp4://192.168.1.67:2222, dynamic + * tcp4://192.168.1.67:2222; dynamic + * tcp4://192.168.1.67:2222,dynamic + * tcp4://192.168.1.67:2222;dynamic + * tcp4://192.168.1.67:2222 dynamic + */ + String input = userInput.toString(); + input = input.replace(",", " "); + input = input.replace(";", " "); + input = input.replaceAll("\\s+", ", "); + // Log.v(TAG, "persistableAddresses: Cleaned user input=" + input); + + // Split and return the addresses as String[]. + return Arrays.asList(input.split(", ")); } private String displayableAddresses() { + if (mDevice.addresses == null) { + return ""; + } List list = DYNAMIC_ADDRESS.equals(mDevice.addresses) ? DYNAMIC_ADDRESS : mDevice.addresses; - return TextUtils.join(" ", list); + return TextUtils.join(", ", list); } @Override diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java index c0315577..bd2de43d 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FirstStartActivity.java @@ -93,6 +93,21 @@ public class FirstStartActivity extends Activity { super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); + /** + * Check if a valid config exists that can be read and parsed. + */ + Boolean configParseable = false; + Boolean configExists = Constants.getConfigFile(this).exists(); + if (configExists) { + ConfigXml configParseTest = new ConfigXml(this); + try { + configParseTest.loadConfig(); + configParseable = true; + } catch (ConfigXml.OpenConfigException e) { + Log.d(TAG, "Failed to parse existing config. Will show key generation slide ..."); + } + } + /** * Check if prerequisites to run the app are still in place. * If anything mandatory is missing, the according welcome slide(s) will be shown. @@ -100,7 +115,7 @@ public class FirstStartActivity extends Activity { Boolean showSlideStoragePermission = !haveStoragePermission(); Boolean showSlideIgnoreDozePermission = !haveIgnoreDozePermission(); Boolean showSlideLocationPermission = !haveLocationPermission(); - Boolean showSlideKeyGeneration = !Constants.getConfigFile(this).exists(); + Boolean showSlideKeyGeneration = !configExists || !configParseable; /** * If we don't have to show slides for mandatory prerequisites, @@ -480,8 +495,10 @@ public class FirstStartActivity extends Activity { cancel(true); return null; } + configXml = new ConfigXml(firstStartActivity); try { - configXml = new ConfigXml(firstStartActivity); + // Create new secure keys and config. + configXml.generateConfig(); } catch (ExecutableNotFoundException e) { publishProgress(firstStartActivity.getString(R.string.executable_not_found, e.getMessage())); cancel(true); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java index 8946b72e..11c7970e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -36,6 +36,7 @@ import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.SyncthingApp; +import com.nutomic.syncthingandroid.util.ConfigRouter; import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; @@ -85,6 +86,7 @@ public class FolderActivity extends SyncthingActivity private static final String FOLDER_MARKER_NAME = ".stfolder"; // private static final String IGNORE_FILE_NAME = ".stignore"; + private ConfigRouter mConfig; private Folder mFolder; // Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21) private Uri mFolderUri = null; @@ -154,7 +156,7 @@ public class FolderActivity extends SyncthingActivity case R.id.device_toggle: Device device = (Device) view.getTag(); if (isChecked) { - mFolder.addDevice(device.deviceID); + mFolder.addDevice(device); } else { mFolder.removeDevice(device.deviceID); } @@ -166,6 +168,8 @@ public class FolderActivity extends SyncthingActivity @Override public void onCreate(Bundle savedInstanceState) { + mConfig = new ConfigRouter(FolderActivity.this); + super.onCreate(savedInstanceState); ((SyncthingApp) getApplication()).component().inject(this); setContentView(R.layout.fragment_folder); @@ -384,14 +388,9 @@ public class FolderActivity extends SyncthingActivity @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != ACTIVE) { - finish(); - return; - } - if (!mIsCreateMode) { - RestApi restApi = getApi(); // restApi != null because of State.ACTIVE - List folders = restApi.getFolders(); + RestApi restApi = getApi(); + List folders = mConfig.getFolders(restApi); String passedId = getIntent().getStringExtra(EXTRA_FOLDER_ID); mFolder = null; for (Folder currentFolder : folders) { @@ -405,11 +404,15 @@ public class FolderActivity extends SyncthingActivity finish(); return; } - restApi.getFolderIgnoreList(mFolder.id, this::onReceiveFolderIgnoreList); + mConfig.getFolderIgnoreList(restApi, mFolder, this::onReceiveFolderIgnoreList); checkWriteAndUpdateUI(); } + + // If the extra is set, we should automatically share the current folder with the given device. if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { - mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID)); + Device device = new Device(); + device.deviceID = getIntent().getStringExtra(EXTRA_DEVICE_ID); + mFolder.addDevice(device); mFolderNeedsToUpdate = true; } @@ -471,13 +474,14 @@ public class FolderActivity extends SyncthingActivity mCustomSyncConditionsDialog.setEnabled(mCustomSyncConditionsSwitch.isChecked()); // Populate devicesList. - List devicesList = getApi().getDevices(false); + RestApi restApi = getApi(); + List devicesList = mConfig.getDevices(restApi, false); mDevicesContainer.removeAllViews(); if (devicesList.isEmpty()) { addEmptyDeviceListView(); } else { - for (Device n : devicesList) { - addDeviceViewAndSetListener(n, getLayoutInflater()); + for (Device device : devicesList) { + addDeviceViewAndSetListener(device, getLayoutInflater()); } } @@ -511,6 +515,11 @@ public class FolderActivity extends SyncthingActivity .show(); return true; } + if (TextUtils.isEmpty(mFolder.label)) { + Toast.makeText(this, R.string.folder_label_required, Toast.LENGTH_LONG) + .show(); + return true; + } if (TextUtils.isEmpty(mFolder.path)) { Toast.makeText(this, R.string.folder_path_required, Toast.LENGTH_LONG) .show(); @@ -531,7 +540,7 @@ public class FolderActivity extends SyncthingActivity dfFolder.createDirectory(FOLDER_MARKER_NAME); } } - getApi().createFolder(mFolder); + mConfig.addFolder(getApi(), mFolder); finish(); return true; case R.id.remove: @@ -554,10 +563,7 @@ public class FolderActivity extends SyncthingActivity return new AlertDialog.Builder(this) .setMessage(R.string.remove_folder_confirm) .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> { - RestApi restApi = getApi(); - if (restApi != null) { - restApi.removeFolder(mFolder.id); - } + mConfig.removeFolder(getApi(), mFolder.id); mFolderNeedsToUpdate = false; finish(); }) @@ -731,17 +737,13 @@ public class FolderActivity extends SyncthingActivity // Update folder via restApi and send the config to REST endpoint. RestApi restApi = getApi(); - if (restApi == null) { - Log.e(TAG, "updateFolder: restApi == null"); - return; - } // Update ignore list. String[] ignore = mEditIgnoreListContent.getText().toString().split("\n"); - restApi.postFolderIgnoreList(mFolder.id, ignore); + mConfig.postFolderIgnoreList(restApi, mFolder, ignore); - // Update model and send the config to REST endpoint. - restApi.updateFolder(mFolder); + // Update folder using RestApi or ConfigXml. + mConfig.updateFolder(restApi, mFolder); } @Override diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java index a832391c..b2e092d8 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderPickerActivity.java @@ -9,7 +9,6 @@ import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.Environment; -import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -31,8 +30,6 @@ import com.google.common.collect.Sets; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -45,7 +42,7 @@ import java.util.Iterator; * Activity that allows selecting a directory in the local file system. */ public class FolderPickerActivity extends SyncthingActivity - implements AdapterView.OnItemClickListener, SyncthingService.OnServiceStateChangeListener { + implements AdapterView.OnItemClickListener { private static final String EXTRA_INITIAL_DIRECTORY = "com.nutomic.syncthingandroid.activities.FolderPickerActivity.INITIAL_DIRECTORY"; @@ -151,22 +148,6 @@ public class FolderPickerActivity extends SyncthingActivity mRootsAdapter.addAll(Sets.newTreeSet(roots)); } - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; - syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); - } - } - @Override public boolean onCreateOptionsMenu(Menu menu) { if (mListView.getAdapter() == mRootsAdapter) @@ -324,14 +305,6 @@ public class FolderPickerActivity extends SyncthingActivity } } - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - /** * Displays a list of all available roots, or if there is only one root, the * contents of that folder. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java index ffc05634..add7604c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/MainActivity.java @@ -97,6 +97,9 @@ public class MainActivity extends SyncthingActivity private ActionBarDrawerToggle mDrawerToggle; private DrawerLayout mDrawerLayout; + + private Boolean oneTimeShot = true; + @Inject SharedPreferences mPreferences; /** @@ -104,9 +107,10 @@ public class MainActivity extends SyncthingActivity */ @Override public void onServiceStateChange(SyncthingService.State currentState) { - if (currentState != mSyncthingServiceState) { - mSyncthingServiceState = currentState; + mSyncthingServiceState = currentState; + if (oneTimeShot) { updateViewPager(); + oneTimeShot = false; } switch (currentState) { @@ -178,15 +182,12 @@ public class MainActivity extends SyncthingActivity } if (savedInstanceState != null) { - mViewPager.setCurrentItem(savedInstanceState.getInt("currentTab")); if (savedInstanceState.getBoolean(IS_SHOWING_RESTART_DIALOG)){ showRestartDialog(); } - if(savedInstanceState.getBoolean(IS_QRCODE_DIALOG_DISPLAYED)) { + if (savedInstanceState.getBoolean(IS_QRCODE_DIALOG_DISPLAYED)) { showQrCodeDialog(savedInstanceState.getString(DEVICEID_KEY), savedInstanceState.getParcelable(QRCODE_BITMAP_KEY)); } - } else { - updateViewPager(); } fm.beginTransaction().replace(R.id.drawer, mDrawerFragment).commit(); @@ -211,31 +212,21 @@ public class MainActivity extends SyncthingActivity * Updates the ViewPager to show tabs depending on the service state. */ private void updateViewPager() { - Boolean isServiceActive = mSyncthingServiceState == SyncthingService.State.ACTIVE; - final int numPages = (isServiceActive ? 3 : 1); + final int numPages = 3; FragmentStatePagerAdapter mSectionsPagerAdapter = new FragmentStatePagerAdapter(getSupportFragmentManager()) { @Override public Fragment getItem(int position) { - if (isServiceActive) { - switch (position) { - case 0: - return mFolderListFragment; - case 1: - return mDeviceListFragment; - case 2: - return mStatusFragment; - default: - return null; - } - } else { - switch (position) { - case 0: - return mStatusFragment; - default: - return null; - } + switch (position) { + case 0: + return mFolderListFragment; + case 1: + return mDeviceListFragment; + case 2: + return mStatusFragment; + default: + return null; } } @@ -251,24 +242,15 @@ public class MainActivity extends SyncthingActivity @Override public CharSequence getPageTitle(int position) { - if (isServiceActive) { - switch (position) { - case 0: - return getResources().getString(R.string.folders_fragment_title); - case 1: - return getResources().getString(R.string.devices_fragment_title); - case 2: - return getResources().getString(R.string.status_fragment_title); - default: - return String.valueOf(position); - } - } else { - switch (position) { - case 0: - return getResources().getString(R.string.status_fragment_title); - default: - return String.valueOf(position); - } + switch (position) { + case 0: + return getResources().getString(R.string.folders_fragment_title); + case 1: + return getResources().getString(R.string.devices_fragment_title); + case 2: + return getResources().getString(R.string.status_fragment_title); + default: + return String.valueOf(position); } } }; @@ -316,9 +298,9 @@ public class MainActivity extends SyncthingActivity SyncthingService mSyncthingService = getService(); if (mSyncthingService != null) { mSyncthingService.unregisterOnServiceStateChangeListener(this); + mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mFolderListFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mDeviceListFragment); - mSyncthingService.unregisterOnServiceStateChangeListener(mDrawerFragment); mSyncthingService.unregisterOnServiceStateChangeListener(mStatusFragment); } } @@ -329,9 +311,9 @@ public class MainActivity extends SyncthingActivity SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; SyncthingService syncthingService = syncthingServiceBinder.getService(); syncthingService.registerOnServiceStateChangeListener(this); + syncthingService.registerOnServiceStateChangeListener(mDrawerFragment); syncthingService.registerOnServiceStateChangeListener(mFolderListFragment); syncthingService.registerOnServiceStateChangeListener(mDeviceListFragment); - syncthingService.registerOnServiceStateChangeListener(mDrawerFragment); syncthingService.registerOnServiceStateChangeListener(mStatusFragment); } @@ -351,11 +333,9 @@ public class MainActivity extends SyncthingActivity putFragment.accept(mFolderListFragment); putFragment.accept(mDeviceListFragment); putFragment.accept(mStatusFragment); - putFragment.accept(mDrawerFragment); - outState.putInt("currentTab", mViewPager.getCurrentItem()); outState.putBoolean(IS_SHOWING_RESTART_DIALOG, mRestartDialog != null && mRestartDialog.isShowing()); - if(mQrCodeDialog != null && mQrCodeDialog.isShowing()) { + if (mQrCodeDialog != null && mQrCodeDialog.isShowing()) { outState.putBoolean(IS_QRCODE_DIALOG_DISPLAYED, true); ImageView qrCode = mQrCodeDialog.findViewById(R.id.qrcode_image_view); TextView deviceID = mQrCodeDialog.findViewById(R.id.device_id); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java index 3bb09b58..b4974fae 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/SyncConditionsActivity.java @@ -6,7 +6,6 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; -import android.os.IBinder; import android.support.v7.widget.SwitchCompat; import android.util.Log; import android.util.TypedValue; @@ -23,8 +22,6 @@ import com.google.common.collect.Sets; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingService; -import com.nutomic.syncthingandroid.service.SyncthingServiceBinder; import com.nutomic.syncthingandroid.util.Util; import java.util.HashSet; @@ -41,8 +38,7 @@ import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; /** * Activity that allows selecting a directory in the local file system. */ -public class SyncConditionsActivity extends SyncthingActivity - implements SyncthingService.OnServiceStateChangeListener { +public class SyncConditionsActivity extends SyncthingActivity { private static final String TAG = "SyncConditionsActivity"; @@ -215,20 +211,9 @@ public class SyncConditionsActivity extends SyncthingActivity } }; - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - super.onServiceConnected(componentName, iBinder); - SyncthingServiceBinder syncthingServiceBinder = (SyncthingServiceBinder) iBinder; - syncthingServiceBinder.getService().registerOnServiceStateChangeListener(this); - } - @Override protected void onDestroy() { super.onDestroy(); - SyncthingService syncthingService = getService(); - if (syncthingService != null) { - syncthingService.unregisterOnServiceStateChangeListener(this::onServiceStateChange); - } } @Override @@ -284,12 +269,4 @@ public class SyncConditionsActivity extends SyncthingActivity finish(); } - @Override - public void onServiceStateChange(SyncthingService.State currentState) { - if (!isFinishing() && currentState != SyncthingService.State.ACTIVE) { - setResult(Activity.RESULT_CANCELED); - finish(); - } - } - } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java index 891946a8..2f3b4c60 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/WebGuiActivity.java @@ -133,9 +133,10 @@ public class WebGuiActivity extends SyncthingActivity setContentView(R.layout.activity_web_gui); mLoadingView = findViewById(R.id.loading); + mConfig = new ConfigXml(this); try { - mConfig = new ConfigXml(this); - } catch (Exception e) { + mConfig.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { throw new RuntimeException(e.getMessage()); } loadCaCert(); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java index c96c4824..9a298b95 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/DeviceListFragment.java @@ -20,6 +20,7 @@ import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; +import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.views.DevicesAdapter; import java.util.Collections; @@ -34,7 +35,14 @@ public class DeviceListFragment extends ListFragment implements SyncthingService private final static String TAG = "DeviceListFragment"; - private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> lhs.name.compareTo(rhs.name); + /** + * Compares devices by name, uses the device ID as fallback if the name is empty + */ + private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> { + String lhsName = lhs.name != null && !lhs.name.isEmpty() ? lhs.name : lhs.deviceID; + String rhsName = rhs.name != null && !rhs.name.isEmpty() ? rhs.name : rhs.deviceID; + return lhsName.compareTo(rhsName); + }; private Runnable mUpdateListRunnable = new Runnable() { @Override @@ -107,9 +115,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService * while the user is looking at the current tab. */ private void onTimerEvent() { - if (mServiceState != SyncthingService.State.ACTIVE) { - return; - } MainActivity mainActivity = (MainActivity) getActivity(); if (mainActivity == null) { return; @@ -117,10 +122,6 @@ public class DeviceListFragment extends ListFragment implements SyncthingService if (mainActivity.isFinishing()) { return; } - RestApi restApi = mainActivity.getApi(); - if (restApi == null) { - return; - } Log.v(TAG, "Invoking updateList on UI thread"); mainActivity.runOnUiThread(DeviceListFragment.this::updateList); } @@ -135,11 +136,19 @@ public class DeviceListFragment extends ListFragment implements SyncthingService if (activity == null || getView() == null || activity.isFinishing()) { return; } + List devices; RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; + if (restApi == null || + !restApi.isConfigLoaded() || + mServiceState != SyncthingService.State.ACTIVE) { + // Syncthing is not running or REST API is not available yet. + ConfigXml configXml = new ConfigXml(activity); + configXml.loadConfig(); + devices = configXml.getDevices(false); + } else { + // Syncthing is running and REST API is available. + devices = restApi.getDevices(false); } - List devices = restApi.getDevices(false); if (devices == null) { return; } @@ -153,7 +162,7 @@ public class DeviceListFragment extends ListFragment implements SyncthingService mAdapter.clear(); Collections.sort(devices, DEVICES_COMPARATOR); mAdapter.addAll(devices); - mAdapter.updateConnections(restApi); + mAdapter.updateDeviceStatus(restApi); mAdapter.notifyDataSetChanged(); setListShown(true); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java index d7382f10..8d1c7709 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/FolderListFragment.java @@ -19,6 +19,7 @@ import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.SyncthingService; +import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.views.FoldersAdapter; import java.util.List; @@ -102,9 +103,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService * while the user is looking at the current tab. */ private void onTimerEvent() { - if (mServiceState != SyncthingService.State.ACTIVE) { - return; - } MainActivity mainActivity = (MainActivity) getActivity(); if (mainActivity == null) { return; @@ -112,10 +110,6 @@ public class FolderListFragment extends ListFragment implements SyncthingService if (mainActivity.isFinishing()) { return; } - RestApi restApi = mainActivity.getApi(); - if (restApi == null) { - return; - } Log.v(TAG, "Invoking updateList on UI thread"); mainActivity.runOnUiThread(FolderListFragment.this::updateList); } @@ -130,11 +124,19 @@ public class FolderListFragment extends ListFragment implements SyncthingService if (activity == null || getView() == null || activity.isFinishing()) { return; } + List folders; RestApi restApi = activity.getApi(); - if (restApi == null || !restApi.isConfigLoaded()) { - return; + if (restApi == null || + !restApi.isConfigLoaded() || + mServiceState != SyncthingService.State.ACTIVE) { + // Syncthing is not running or REST API is not available yet. + ConfigXml configXml = new ConfigXml(activity); + configXml.loadConfig(); + folders = configXml.getFolders(); + } else { + // Syncthing is running and REST API is available. + folders = restApi.getFolders(); } - List folders = restApi.getFolders(); if (folders == null) { return; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java b/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java index 3d20709f..1174d6bc 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/fragments/StatusFragment.java @@ -260,7 +260,7 @@ public class StatusFragment extends ListFragment implements SyncthingService.OnS int announceConnected = announceTotal - Optional.fromNullable(systemStatus.discoveryErrors).transform(Map::size).or(0); synchronized (mStatusHolderLock) { - mCpuUsage = (systemStatus.cpuPercent / 100 < 1) ? "" : percentFormat.format(systemStatus.cpuPercent / 100); + mCpuUsage = (systemStatus.cpuPercent < 5) ? "" : percentFormat.format(systemStatus.cpuPercent / 100); mRamUsage = Util.readableFileSize(mActivity, systemStatus.sys); mAnnounceServer = (announceTotal == 0) ? "" : diff --git a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java index 23b67b54..f8a5e938 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/http/ApiRequest.java @@ -38,6 +38,8 @@ public abstract class ApiRequest { private static final String TAG = "ApiRequest"; + private static final Boolean ENABLE_VERBOSE_LOG = false; + /** * The name of the HTTP header used for the syncthing API key. */ @@ -92,7 +94,9 @@ public abstract class ApiRequest { */ void connect(int requestMethod, Uri uri, @Nullable String requestBody, @Nullable OnSuccessListener listener, @Nullable OnErrorListener errorListener) { - Log.v(TAG, "Performing request to " + uri.toString()); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Performing request to " + uri.toString()); + } StringRequest request = new StringRequest(requestMethod, uri.toString(), reply -> { if (listener != null) { listener.onSuccess(reply); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java index 29a307fd..67fd543c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Device.java @@ -10,17 +10,29 @@ public class Device { public List addresses; public String compression; public String certName; - public boolean introducer; + public String introducedBy = ""; + public boolean introducer = false; public boolean paused; public List pendingFolders; public List ignoredFolders; + /** + * Relevant fields for Folder.List "shared-with-device" model, + * handled by {@link ConfigRouter#updateFolder and ConfigXml#updateFolder} + * deviceID + * introducedBy + * Example Tag + * + * + * + */ + /** * Returns the device name, or the first characters of the ID if the name is empty. */ public String getDisplayName() { return (TextUtils.isEmpty(name)) - ? deviceID.substring(0, 7) + ? (TextUtils.isEmpty(deviceID) ? "" : deviceID.substring(0, 7)) : name; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java index a711e445..bd920003 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Folder.java @@ -21,15 +21,15 @@ public class Folder { public boolean fsWatcherEnabled = true; public int fsWatcherDelayS = 10; private List devices = new ArrayList<>(); - public int rescanIntervalS; - public final boolean ignorePerms = true; + public int rescanIntervalS = 3600; + public boolean ignorePerms = true; public boolean autoNormalize = true; public MinDiskFree minDiskFree; public Versioning versioning; - public int copiers; + public int copiers = 0; public int pullerMaxPendingKiB; - public int hashers; - public String order; + public int hashers = 0; + public String order = "random"; public boolean ignoreDelete; public int scanProgressIntervalS; public int pullerPauseS; @@ -52,12 +52,17 @@ public class Folder { public String unit; } - public void addDevice(String deviceId) { + public void addDevice(final Device device) { Device d = new Device(); - d.deviceID = deviceId; + d.deviceID = device.deviceID; + d.introducedBy = device.introducedBy; devices.add(d); } + public List getDevices() { + return devices; + } + public Device getDevice(String deviceId) { for (Device d : devices) { if (d.deviceID.equals(deviceId)) { @@ -78,6 +83,8 @@ public class Folder { @Override public String toString() { - return !TextUtils.isEmpty(label) ? label : id; + return (TextUtils.isEmpty(label)) + ? (TextUtils.isEmpty(id) ? "" : id) + : label; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java index c4790029..4e033c16 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/model/Options.java @@ -34,6 +34,7 @@ public class Options { public String[] alwaysLocalNets; public boolean overwriteRemoteDeviceNamesOnConnect; public int tempIndexMinBlocks; + public String defaultFolderPath; public static final int USAGE_REPORTING_UNDECIDED = 0; public static final int USAGE_REPORTING_DENIED = -1; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java index 7235d69d..16461764 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java @@ -68,6 +68,7 @@ public class Constants { * Cached information which is not available on SettingsActivity. */ public static final String PREF_LAST_BINARY_VERSION = "lastBinaryVersion"; + public static final String PREF_LOCAL_DEVICE_ID = "localDeviceID"; /** * {@link EventProcessor} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index ec69ac21..2b43b224 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -71,7 +71,6 @@ public class RestApi { private final static Comparator FOLDERS_COMPARATOR = (lhs, rhs) -> { String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; - return lhsLabel.compareTo(rhsLabel); }; @@ -453,7 +452,7 @@ public class RestApi { /** * This is only used for new folder creation, see {@link FolderActivity}. */ - public void createFolder(Folder folder) { + public void addFolder(Folder folder) { synchronized (mConfigLock) { // Add the new folder to the model. mConfig.folders.add(folder); @@ -500,7 +499,7 @@ public class RestApi { * * @param includeLocal True if the local device should be included in the result. */ - public List getDevices(boolean includeLocal) { + public List getDevices(Boolean includeLocal) { List devices; synchronized (mConfigLock) { devices = deepCopy(mConfig.devices, new TypeToken>(){}.getType()); @@ -850,6 +849,7 @@ public class RestApi { Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0")); if (folder.paused != !syncConditionsMet) { folder.paused = !syncConditionsMet; + Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0")); configChanged = true; } } @@ -872,6 +872,7 @@ public class RestApi { Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0")); if (device.paused != !syncConditionsMet) { device.paused = !syncConditionsMet; + Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0")); configChanged = true; } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java index 58509851..cf9883df 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RunConditionMonitor.java @@ -58,7 +58,7 @@ public class RunConditionMonitor { } public interface OnSyncPreconditionChangedListener { - void onSyncPreconditionChanged(); + void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor); } private class SyncConditionResult { @@ -189,7 +189,7 @@ public class RunConditionMonitor { // Notify about changed preconditions. if (mOnSyncPreconditionChangedListener != null) { - mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(); + mOnSyncPreconditionChangedListener.onSyncPreconditionChanged(this); } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java index e1456be7..6a9e094e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -40,6 +40,8 @@ import javax.inject.Inject; import eu.chainfire.libsuperuser.Shell; +import static com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE; + /** * Runs the syncthing binary from command line, and prints its output to logcat. * @@ -258,8 +260,10 @@ public class SyncthingRunnable implements Runnable { // Notify {@link SyncthingService} that service state State.ACTIVE is no longer valid. if (!returnStdOut && sendStopToService) { - mContext.startService(new Intent(mContext, SyncthingService.class) - .setAction(SyncthingService.ACTION_STOP)); + Intent intent = new Intent(mContext, SyncthingService.class); + intent.setAction(SyncthingService.ACTION_STOP); + intent.putExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, true); + mContext.startService(intent); } // Return captured command line output. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index 225874c5..21ab0ef2 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -8,6 +8,7 @@ import android.Manifest; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; @@ -20,6 +21,7 @@ import com.google.common.io.Files; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; import com.nutomic.syncthingandroid.http.PollWebGuiAvailableTask; +import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.util.FileUtils; @@ -37,6 +39,7 @@ import java.nio.file.Paths; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.Set; @@ -112,6 +115,13 @@ public class SyncthingService extends Service { public static final String EXTRA_FOLDER_ID = "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_FOLDER_ID"; + /** + * Extra used together with ACTION_STOP. + */ + public static final String EXTRA_STOP_AFTER_CRASHED_NATIVE = + "com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE"; + + public interface OnSyncthingKilled { void onKilled(); } @@ -155,7 +165,6 @@ public class SyncthingService extends Service { */ private State mCurrentState = State.DISABLED; private ConfigXml mConfig; - private StartupTask mStartupTask = null; private Thread mSyncthingRunnableThread = null; private Handler mHandler; @@ -276,9 +285,23 @@ public class SyncthingService extends Service { if (ACTION_RESTART.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { shutdown(State.INIT, () -> launchStartupTask(SyncthingRunnable.Command.main)); - } else if (ACTION_STOP.equals(intent.getAction()) && mCurrentState == State.ACTIVE) { - shutdown(State.DISABLED, () -> { - }); + } else if (ACTION_STOP.equals(intent.getAction())) { + if (intent.getBooleanExtra(EXTRA_STOP_AFTER_CRASHED_NATIVE, false)) { + /** + * We were requested to stop the service because the syncthing native binary crashed. + * Changing mCurrentState prevents the "defer until syncthing is started" routine we normally + * use for clean shutdown to take place. Instead, we will immediately shutdown the crashed + * instance forcefully. + */ + mCurrentState = State.ERROR; + shutdown(State.DISABLED, () -> {}); + } else { + // Graceful shutdown. + if (mCurrentState == State.STARTING || + mCurrentState == State.ACTIVE) { + shutdown(State.DISABLED, () -> {}); + } + } } else if (ACTION_RESET_DATABASE.equals(intent.getAction())) { Log.i(TAG, "Invoking reset of database"); shutdown(State.INIT, () -> { @@ -348,10 +371,84 @@ public class SyncthingService extends Service { * After sync preconditions changed, we need to inform {@link RestApi} to pause or * unpause devices and folders as defined in per-object sync preferences. */ - private void onSyncPreconditionChanged() { - if (mRestApi != null) { - // Forward event. - mRestApi.onSyncPreconditionChanged(mRunConditionMonitor); + private void onSyncPreconditionChanged(RunConditionMonitor runConditionMonitor) { + synchronized (mStateLock) { + if (mRestApi != null && mCurrentState == State.ACTIVE) { + // Forward event because syncthing is running. + mRestApi.onSyncPreconditionChanged(runConditionMonitor); + return; + } + } + + Log.v(TAG, "onSyncPreconditionChanged: Event fired while syncthing is not running."); + Boolean configChanged = false; + ConfigXml configXml; + + // Read and parse the config from disk. + configXml = new ConfigXml(this); + try { + configXml.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { + mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "onSyncPreconditionChanged:ConfigXml.OpenConfigException"); + synchronized (mStateLock) { + onServiceStateChange(State.ERROR); + } + return; + } + + // Check if the folders are available from config. + List folders = configXml.getFolders(); + if (folders != null) { + for (Folder folder : folders) { + // Log.v(TAG, "onSyncPreconditionChanged: Processing config of folder.id=" + folder.id); + Boolean folderCustomSyncConditionsEnabled = mPreferences.getBoolean( + Constants.DYN_PREF_OBJECT_CUSTOM_SYNC_CONDITIONS(Constants.PREF_OBJECT_PREFIX_FOLDER + folder.id), false + ); + if (folderCustomSyncConditionsEnabled) { + Boolean syncConditionsMet = runConditionMonitor.checkObjectSyncConditions( + Constants.PREF_OBJECT_PREFIX_FOLDER + folder.id + ); + Log.v(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? "1" : "0")); + if (folder.paused != !syncConditionsMet) { + configXml.setFolderPause(folder.id, !syncConditionsMet); + Log.d(TAG, "onSyncPreconditionChanged: syncFolder(" + folder.id + ")=" + (syncConditionsMet ? ">1" : ">0")); + configChanged = true; + } + } + } + } else { + Log.d(TAG, "onSyncPreconditionChanged: folders == null"); + return; + } + + // Check if the devices are available from config. + List devices = configXml.getDevices(false); + if (devices != null) { + for (Device device : devices) { + // Log.v(TAG, "onSyncPreconditionChanged: Processing config of device.id=" + device.deviceID); + Boolean deviceCustomSyncConditionsEnabled = mPreferences.getBoolean( + Constants.DYN_PREF_OBJECT_CUSTOM_SYNC_CONDITIONS(Constants.PREF_OBJECT_PREFIX_DEVICE + device.deviceID), false + ); + if (deviceCustomSyncConditionsEnabled) { + Boolean syncConditionsMet = runConditionMonitor.checkObjectSyncConditions( + Constants.PREF_OBJECT_PREFIX_DEVICE + device.deviceID + ); + Log.v(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? "1" : "0")); + if (device.paused != !syncConditionsMet) { + configXml.setDevicePause(device.deviceID, !syncConditionsMet); + Log.d(TAG, "onSyncPreconditionChanged: syncDevice(" + device.deviceID + ")=" + (syncConditionsMet ? ">1" : ">0")); + configChanged = true; + } + } + } + } else { + Log.d(TAG, "onSyncPreconditionChanged: devices == null"); + return; + } + + if (configChanged) { + Log.v(TAG, "onSyncPreconditionChanged: Saving changed config to disk ..."); + configXml.saveChanges(); } } @@ -359,77 +456,25 @@ public class SyncthingService extends Service { * Prepares to launch the syncthing binary. */ private void launchStartupTask(SyncthingRunnable.Command srCommand) { - Log.v(TAG, "Starting syncthing"); synchronized (mStateLock) { if (mCurrentState != State.DISABLED && mCurrentState != State.INIT) { Log.e(TAG, "launchStartupTask: Wrong state " + mCurrentState + " detected. Cancelling."); return; } } - - // Safety check: Log warning if a previously launched startup task did not finish properly. - if (mStartupTask != null && (mStartupTask.getStatus() == AsyncTask.Status.RUNNING)) { - Log.w(TAG, "launchStartupTask: StartupTask is still running. Skipped starting it twice."); + Log.v(TAG, "Starting syncthing"); + onServiceStateChange(State.STARTING); + mConfig = new ConfigXml(this); + try { + mConfig.loadConfig(); + } catch (ConfigXml.OpenConfigException e) { + mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException"); + synchronized (mStateLock) { + onServiceStateChange(State.ERROR); + } return; } - onServiceStateChange(State.STARTING); - mStartupTask = new StartupTask(this, srCommand); - mStartupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - /** - * Sets up the initial configuration, and updates the config when coming from an old - * version. - */ - private static class StartupTask extends AsyncTask { - private WeakReference refSyncthingService; - private SyncthingRunnable.Command srCommand; - - StartupTask(SyncthingService context, SyncthingRunnable.Command srCommand) { - refSyncthingService = new WeakReference<>(context); - this.srCommand = srCommand; - } - - @Override - protected Void doInBackground(Void... voids) { - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService == null) { - cancel(true); - return null; - } - try { - syncthingService.mConfig = new ConfigXml(syncthingService); - syncthingService.mConfig.updateIfNeeded(); - } catch (SyncthingRunnable.ExecutableNotFoundException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "SycnthingRunnable.ExecutableNotFoundException"); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - cancel(true); - } catch (ConfigXml.OpenConfigException e) { - syncthingService.mNotificationHandler.showCrashedNotification(R.string.config_read_failed, "ConfigXml.OpenConfigException"); - synchronized (syncthingService.mStateLock) { - syncthingService.onServiceStateChange(State.ERROR); - } - cancel(true); - } - return null; - } - - @Override - protected void onPostExecute(Void aVoid) { - // Get a reference to the service if it is still there. - SyncthingService syncthingService = refSyncthingService.get(); - if (syncthingService != null) { - syncthingService.onStartupTaskCompleteListener(srCommand); - } - } - } - - /** - * Callback on {@link StartupTask#onPostExecute}. - */ - private void onStartupTaskCompleteListener(SyncthingRunnable.Command srCommand) { if (mRestApi == null) { mRestApi = new RestApi(this, mConfig.getWebGuiUrl(), mConfig.getApiKey(), this::onApiAvailable, () -> onServiceStateChange(mCurrentState)); @@ -652,13 +697,13 @@ public class SyncthingService extends Service { mCurrentState = newState; mHandler.post(() -> { mNotificationHandler.updatePersistentNotification(this); - for (Iterator i = mOnServiceStateChangeListeners.iterator(); - i.hasNext(); ) { - OnServiceStateChangeListener listener = i.next(); + Iterator it = mOnServiceStateChangeListeners.iterator(); + while (it.hasNext()) { + OnServiceStateChangeListener listener = it.next(); if (listener != null) { listener.onServiceStateChange(mCurrentState); } else { - i.remove(); + it.remove(); } } }); @@ -773,7 +818,14 @@ public class SyncthingService extends Service { // Start syncthing after export if run conditions apply. if (mLastDeterminedShouldRun) { - launchStartupTask(SyncthingRunnable.Command.main); + Handler mainLooper = new Handler(Looper.getMainLooper()); + Runnable launchStartupTaskRunnable = new Runnable() { + @Override + public void run() { + launchStartupTask(SyncthingRunnable.Command.main); + } + }; + mainLooper.post(launchStartupTaskRunnable); } return failSuccess; } @@ -844,6 +896,7 @@ public class SyncthingService extends Service { case Constants.PREF_DEBUG_FACILITIES_AVAILABLE: case Constants.PREF_EVENT_PROCESSOR_LAST_SYNC_ID: case Constants.PREF_LAST_BINARY_VERSION: + case Constants.PREF_LOCAL_DEVICE_ID: Log.v(TAG, "importConfig: Ignoring cache pref \"" + prefKey + "\"."); break; default: @@ -929,7 +982,14 @@ public class SyncthingService extends Service { // Start syncthing after import if run conditions apply. if (mLastDeterminedShouldRun) { - launchStartupTask(SyncthingRunnable.Command.main); + Handler mainLooper = new Handler(Looper.getMainLooper()); + Runnable launchStartupTaskRunnable = new Runnable() { + @Override + public void run() { + launchStartupTask(SyncthingRunnable.Command.main); + } + }; + mainLooper.post(launchStartupTaskRunnable); } return failSuccess; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java new file mode 100644 index 00000000..5aaecca9 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigRouter.java @@ -0,0 +1,166 @@ +package com.nutomic.syncthingandroid.util; + +import android.content.Context; +// import android.util.Log; + +import com.nutomic.syncthingandroid.model.Device; +import com.nutomic.syncthingandroid.model.Folder; +import com.nutomic.syncthingandroid.model.FolderIgnoreList; +import com.nutomic.syncthingandroid.service.RestApi; +import com.nutomic.syncthingandroid.util.ConfigXml; + +import java.util.List; + +/** + * Provides a transparent access to the config if ... + * a) Syncthing is running and REST API is available. + * b) Syncthing is NOT running and config.xml is accessed. + */ +public class ConfigRouter { + + private static final String TAG = "ConfigRouter"; + + public interface OnResultListener1 { + void onResult(T t); + } + + private final Context mContext; + + private ConfigXml configXml; + + public ConfigRouter(Context context) { + mContext = context; + configXml = new ConfigXml(mContext); + } + + public List getFolders(RestApi restApi) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + return configXml.getFolders(); + } + + // Syncthing is running and REST API is available. + return restApi.getFolders(); + } + + public void addFolder(RestApi restApi, Folder folder) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.addFolder(folder); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.addFolder(folder); // This will send the config afterwards. + } + + public void updateFolder(RestApi restApi, final Folder folder) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.updateFolder(folder); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.updateFolder(folder); // This will send the config afterwards. + } + + public void removeFolder(RestApi restApi, final String folderId) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.removeFolder(folderId); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.removeFolder(folderId); // This will send the config afterwards. + } + + /** + * Gets ignore list for given folder. + */ + public void getFolderIgnoreList(RestApi restApi, Folder folder, OnResultListener1 listener) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.getFolderIgnoreList(folder, folderIgnoreList -> listener.onResult(folderIgnoreList)); + return; + } + + // Syncthing is running and REST API is available. + restApi.getFolderIgnoreList(folder.id, folderIgnoreList -> listener.onResult(folderIgnoreList)); + } + + /** + * Stores ignore list for given folder. + */ + public void postFolderIgnoreList(RestApi restApi, Folder folder, String[] ignore) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.postFolderIgnoreList(folder, ignore); + return; + } + + // Syncthing is running and REST API is available. + restApi.postFolderIgnoreList(folder.id, ignore); + } + + public List getDevices(RestApi restApi, Boolean includeLocal) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + return configXml.getDevices(includeLocal); + } + + // Syncthing is running and REST API is available. + return restApi.getDevices(includeLocal); + } + + public void addDevice(RestApi restApi, Device device, OnResultListener1 errorListener) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.addDevice(device, error -> errorListener.onResult(error)); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.addDevice(device, error -> errorListener.onResult(error)); // This will send the config afterwards. + } + + public void updateDevice(RestApi restApi, final Device device) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.updateDevice(device); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.updateDevice(device); // This will send the config afterwards. + } + + public void removeDevice(RestApi restApi, final String deviceID) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running or REST API is not (yet) available. + configXml.loadConfig(); + configXml.removeDevice(deviceID); + configXml.saveChanges(); + return; + } + + // Syncthing is running and REST API is available. + restApi.removeDevice(deviceID); // This will send the config afterwards. + } + +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java index 12e53124..f1737655 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java @@ -1,13 +1,15 @@ package com.nutomic.syncthingandroid.util; import android.content.Context; -import android.content.SharedPreferences; import android.os.Build; import android.os.Environment; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; +import com.nutomic.syncthingandroid.model.Device; +import com.nutomic.syncthingandroid.model.Folder; +import com.nutomic.syncthingandroid.model.FolderIgnoreList; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.SyncthingRunnable; @@ -23,12 +25,16 @@ import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Random; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.inject.Inject; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -53,53 +59,117 @@ import org.xml.sax.InputSource; */ public class ConfigXml { + private static final String TAG = "ConfigXml"; + + private static final Boolean ENABLE_VERBOSE_LOG = false; + public class OpenConfigException extends RuntimeException { } - private static final String TAG = "ConfigXml"; + /** + * Compares devices by name, uses the device ID as fallback if the name is empty + */ + private final static Comparator DEVICES_COMPARATOR = (lhs, rhs) -> { + String lhsName = lhs.name != null && !lhs.name.isEmpty() ? lhs.name : lhs.deviceID; + String rhsName = rhs.name != null && !rhs.name.isEmpty() ? rhs.name : rhs.deviceID; + return lhsName.compareTo(rhsName); + }; + + /** + * Compares folders by labels, uses the folder ID as fallback if the label is empty + */ + private final static Comparator FOLDERS_COMPARATOR = (lhs, rhs) -> { + String lhsLabel = lhs.label != null && !lhs.label.isEmpty() ? lhs.label : lhs.id; + String rhsLabel = rhs.label != null && !rhs.label.isEmpty() ? rhs.label : rhs.id; + return lhsLabel.compareTo(rhsLabel); + }; + + public interface OnResultListener1 { + void onResult(T t); + } + private static final int FOLDER_ID_APPENDIX_LENGTH = 4; private final Context mContext; - @Inject - SharedPreferences mPreferences; - private final File mConfigFile; private Document mConfig; - public ConfigXml(Context context) throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException { + public ConfigXml(Context context) { mContext = context; mConfigFile = Constants.getConfigFile(mContext); - boolean isFirstStart = !mConfigFile.exists(); - if (isFirstStart) { - Log.i(TAG, "App started for the first time. Generating keys and config."); - new SyncthingRunnable(context, SyncthingRunnable.Command.generate).run(true); + } + + public void loadConfig() throws OpenConfigException { + parseConfig(); + updateIfNeeded(); + } + + /** + * This should run within an AsyncTask as it can cause a full CPU load + * for more than 30 seconds on older phone hardware. + */ + public void generateConfig() throws OpenConfigException, SyncthingRunnable.ExecutableNotFoundException { + // Create new secret keys and config. + Log.i(TAG, "(Re)Generating keys and config."); + new SyncthingRunnable(mContext, SyncthingRunnable.Command.generate).run(true); + parseConfig(); + Boolean changed = false; + + // Set local device name. + Log.i(TAG, "Starting syncthing to retrieve local device id."); + String localDeviceID = getLocalDeviceIDandStoreToPref(); + if (!TextUtils.isEmpty(localDeviceID)) { + changed = changeLocalDeviceName(localDeviceID) || changed; } - readConfig(); + // Set default folder to the "camera" folder: path and name + changed = changeDefaultFolder() || changed; - if (isFirstStart) { - boolean changed = false; - - Log.i(TAG, "Starting syncthing to retrieve local device id."); - String logOutput = new SyncthingRunnable(context, SyncthingRunnable.Command.deviceid).run(true); - String localDeviceID = logOutput.replace("\n", ""); - // Verify local device ID is correctly formatted. - if (localDeviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$")) { - changed = changeLocalDeviceName(localDeviceID) || changed; - } - changed = changeDefaultFolder() || changed; - - // Save changes if we made any. - if (changed) { - saveChanges(); - } + // Save changes if we made any. + if (changed) { + saveChanges(); } } - private void readConfig() { + private String getLocalDeviceIDfromPref() { + String localDeviceID = PreferenceManager.getDefaultSharedPreferences(mContext).getString(Constants.PREF_LOCAL_DEVICE_ID, ""); + if (TextUtils.isEmpty(localDeviceID)) { + Log.d(TAG, "getLocalDeviceIDfromPref: Local device ID unavailable, trying to retrieve it from syncthing ..."); + try { + localDeviceID = getLocalDeviceIDandStoreToPref(); + } catch (SyncthingRunnable.ExecutableNotFoundException e) { + Log.e(TAG, "getLocalDeviceIDfromPref: Failed to execute syncthing core"); + } + if (TextUtils.isEmpty(localDeviceID)) { + Log.e(TAG, "getLocalDeviceIDfromPref: Local device ID unavailable"); + } + } + return localDeviceID; + } + + private String getLocalDeviceIDandStoreToPref() throws SyncthingRunnable.ExecutableNotFoundException { + String logOutput = new SyncthingRunnable(mContext, SyncthingRunnable.Command.deviceid).run(true); + String localDeviceID = logOutput.replace("\n", ""); + + // Verify that local device ID is correctly formatted. + if (!isDeviceIdValid(localDeviceID)) { + Log.w(TAG, "getLocalDeviceIDandStoreToPref: Syncthing core returned a bad formatted device ID \"" + localDeviceID + "\""); + return ""; + } + + // Store local device ID to pref. This saves us expensive calls to the syncthing binary if we need it later. + PreferenceManager.getDefaultSharedPreferences(mContext).edit() + .putString(Constants.PREF_LOCAL_DEVICE_ID, localDeviceID) + .apply(); + Log.v(TAG, "getLocalDeviceIDandStoreToPref: Cached local device ID \"" + localDeviceID + "\""); + return localDeviceID; + } + + private void parseConfig() { if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) { + Log.w(TAG, "Failed to open config file '" + mConfigFile + "'"); throw new OpenConfigException(); } try { @@ -107,11 +177,16 @@ public class ConfigXml { InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8"); InputSource inputSource = new InputSource(inputStreamReader); inputSource.setEncoding("UTF-8"); - DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); - Log.d(TAG, "Parsing config file '" + mConfigFile + "'"); + DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder db = dbfactory.newDocumentBuilder(); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Parsing config file '" + mConfigFile + "'"); + } mConfig = db.parse(inputSource); inputStream.close(); - Log.i(TAG, "Successfully parsed config file"); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Successfully parsed config file"); + } } catch (SAXException | ParserConfigurationException | IOException e) { Log.w(TAG, "Failed to parse config file '" + mConfigFile + "'", e); throw new OpenConfigException(); @@ -141,7 +216,7 @@ public class ConfigXml { * username/password, and disables weak hash checking. */ @SuppressWarnings("SdCardPath") - public void updateIfNeeded() { + private void updateIfNeeded() { boolean changed = false; /* Perform one-time migration tasks on syncthing's config file when coming from an older config version. */ @@ -173,7 +248,7 @@ public class ConfigXml { Boolean forceHttps = Constants.osSupportsTLS12(); if (!gui.hasAttribute("tls") || Boolean.parseBoolean(gui.getAttribute("tls")) != forceHttps) { - gui.setAttribute("tls", forceHttps ? "true" : "false"); + gui.setAttribute("tls", Boolean.toString(forceHttps)); changed = true; } @@ -222,6 +297,9 @@ public class ConfigXml { } } + // Disable "startBrowser" because it applies to desktop environments and cannot start a mobile browser app. + changed = setConfigElement(options, "startBrowser", "false") || changed; + // Save changes if we made any. if (changed) { saveChanges(); @@ -238,7 +316,9 @@ public class ConfigXml { /* Read existing config version */ int iConfigVersion = Integer.parseInt(mConfig.getDocumentElement().getAttribute("version")); int iOldConfigVersion = iConfigVersion; - Log.i(TAG, "Found existing config version " + Integer.toString(iConfigVersion)); + if (ENABLE_VERBOSE_LOG) { + Log.v(TAG, "Found existing config version " + Integer.toString(iConfigVersion)); + } /* Check if we have to do manual migration from version X to Y */ if (iConfigVersion == 27) { @@ -273,6 +353,438 @@ public class ConfigXml { } } + private Boolean getAttributeOrDefault(final Element element, String attribute, Boolean defaultValue) { + return element.hasAttribute(attribute) ? Boolean.parseBoolean(element.getAttribute(attribute)) : defaultValue; + } + + private Integer getAttributeOrDefault(final Element element, String attribute, Integer defaultValue) { + return element.hasAttribute(attribute) ? Integer.parseInt(element.getAttribute(attribute)) : defaultValue; + } + + private String getAttributeOrDefault(final Element element, String attribute, String defaultValue) { + return element.hasAttribute(attribute) ? element.getAttribute(attribute) : defaultValue; + } + + private Boolean getContentOrDefault(final Node node, Boolean defaultValue) { + return (node == null) ? defaultValue : Boolean.parseBoolean(node.getTextContent()); + } + + private Integer getContentOrDefault(final Node node, Integer defaultValue) { + return (node == null) ? defaultValue : Integer.parseInt(node.getTextContent()); + } + + private String getContentOrDefault(final Node node, String defaultValue) { + return (node == null) ? defaultValue : node.getTextContent(); + } + + public List getFolders() { + String localDeviceID = getLocalDeviceIDfromPref(); + List folders = new ArrayList<>(); + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + Folder folder = new Folder(); + folder.id = getAttributeOrDefault(r, "id", ""); + folder.label = getAttributeOrDefault(r, "label", ""); + folder.path = getAttributeOrDefault(r, "path", ""); + folder.type = getAttributeOrDefault(r, "type", Constants.FOLDER_TYPE_SEND_RECEIVE); + folder.autoNormalize = getAttributeOrDefault(r, "autoNormalize", true); + folder.fsWatcherDelayS =getAttributeOrDefault(r, "fsWatcherDelayS", 10); + folder.fsWatcherEnabled = getAttributeOrDefault(r, "fsWatcherEnabled", true); + folder.ignorePerms = getAttributeOrDefault(r, "ignorePerms", true); + folder.rescanIntervalS = getAttributeOrDefault(r, "rescanIntervalS", 3600); + + folder.copiers = getContentOrDefault(r.getElementsByTagName("copiers").item(0), 0); + folder.hashers = getContentOrDefault(r.getElementsByTagName("hashers").item(0), 0); + folder.order = getContentOrDefault(r.getElementsByTagName("order").item(0), "random"); + folder.paused = getContentOrDefault(r.getElementsByTagName("paused").item(0), false); + + // Devices + /* + + */ + NodeList nodeDevices = r.getElementsByTagName("device"); + for (int j = 0; j < nodeDevices.getLength(); j++) { + Element elementDevice = (Element) nodeDevices.item(j); + Device device = new Device(); + device.deviceID = getAttributeOrDefault(elementDevice, "id", ""); + + // Exclude self. + if (!TextUtils.isEmpty(device.deviceID) && !device.deviceID.equals(localDeviceID)) { + device.introducedBy = getAttributeOrDefault(elementDevice, "introducedBy", ""); + // Log.v(TAG, "getFolders: deviceID=" + device.deviceID + ", introducedBy=" + device.introducedBy); + folder.addDevice(device); + } + } + + // Versioning + /* + + + + + */ + folder.versioning = new Folder.Versioning(); + Element elementVersioning = (Element) r.getElementsByTagName("versioning").item(0); + folder.versioning.type = getAttributeOrDefault(elementVersioning, "type", ""); + NodeList nodeVersioningParam = elementVersioning.getElementsByTagName("param"); + for (int j = 0; j < nodeVersioningParam.getLength(); j++) { + Element elementVersioningParam = (Element) nodeVersioningParam.item(j); + folder.versioning.params.put( + getAttributeOrDefault(elementVersioningParam, "key", ""), + getAttributeOrDefault(elementVersioningParam, "val", "") + ); + /* + Log.v(TAG, "folder.versioning.type=" + folder.versioning.type + + ", key=" + getAttributeOrDefault(elementVersioningParam, "key", "") + + ", val=" + getAttributeOrDefault(elementVersioningParam, "val", "") + ); + */ + } + + // For testing purposes only. + // Log.v(TAG, "folder.label=" + folder.label + "/" +"folder.type=" + folder.type + "/" + "folder.paused=" + folder.paused); + folders.add(folder); + } + Collections.sort(folders, FOLDERS_COMPARATOR); + return folders; + } + + public void addFolder(final Folder folder) { + Log.v(TAG, "addFolder: folder.id=" + folder.id); + Node nodeConfig = mConfig.getDocumentElement(); + Node nodeFolder = mConfig.createElement("folder"); + nodeConfig.appendChild(nodeFolder); + Element elementFolder = (Element) nodeFolder; + elementFolder.setAttribute("id", folder.id); + updateFolder(folder); + } + + public void updateFolder(final Folder folder) { + String localDeviceID = getLocalDeviceIDfromPref(); + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + if (folder.id.equals(getAttributeOrDefault(r, "id", ""))) { + // Found folder node to update. + r.setAttribute("label", folder.label); + r.setAttribute("path", folder.path); + r.setAttribute("type", folder.type); + r.setAttribute("autoNormalize", Boolean.toString(folder.autoNormalize)); + r.setAttribute("fsWatcherDelayS", Integer.toString(folder.fsWatcherDelayS)); + r.setAttribute("fsWatcherEnabled", Boolean.toString(folder.fsWatcherEnabled)); + r.setAttribute("ignorePerms", Boolean.toString(folder.ignorePerms)); + r.setAttribute("rescanIntervalS", Integer.toString(folder.rescanIntervalS)); + + setConfigElement(r, "copiers", Integer.toString(folder.copiers)); + setConfigElement(r, "hashers", Integer.toString(folder.hashers)); + setConfigElement(r, "order", folder.order); + setConfigElement(r, "paused", Boolean.toString(folder.paused)); + + // Update devices that share this folder. + // Pass 1: Remove all devices below that folder in XML except the local device. + NodeList nodeDevices = r.getElementsByTagName("device"); + for (int j = nodeDevices.getLength() - 1; j >= 0; j--) { + Element elementDevice = (Element) nodeDevices.item(j); + if (!getAttributeOrDefault(elementDevice, "id", "").equals(localDeviceID)) { + Log.v(TAG, "updateFolder: nodeDevices: Removing deviceID=" + getAttributeOrDefault(elementDevice, "id", "")); + removeChildElementFromTextNode(r, elementDevice); + } + } + + // Pass 2: Add devices below that folder from the POJO model. + final List devices = folder.getDevices(); + for (Device device : devices) { + Log.v(TAG, "updateFolder: nodeDevices: Adding deviceID=" + device.deviceID); + Node nodeDevice = mConfig.createElement("device"); + r.appendChild(nodeDevice); + Element elementDevice = (Element) nodeDevice; + elementDevice.setAttribute("id", device.deviceID); + elementDevice.setAttribute("introducedBy", device.introducedBy); + } + + // Versioning + // Pass 1: Remove all versioning nodes in XML (usually one) + /* + NodeList nlVersioning = r.getElementsByTagName("versioning"); + for (int j = nlVersioning.getLength() - 1; j >= 0; j--) { + Log.v(TAG, "updateFolder: nodeVersioning: Removing versioning node"); + removeChildElementFromTextNode(r, (Element) nlVersioning.item(j)); + } + */ + Element elementVersioning = (Element) r.getElementsByTagName("versioning").item(0); + if (elementVersioning != null) { + Log.v(TAG, "updateFolder: nodeVersioning: Removing versioning node"); + removeChildElementFromTextNode(r, elementVersioning); + } + + // Pass 2: Add versioning node from the POJO model. + Node nodeVersioning = mConfig.createElement("versioning"); + r.appendChild(nodeVersioning); + elementVersioning = (Element) nodeVersioning; + if (!TextUtils.isEmpty(folder.versioning.type)) { + elementVersioning.setAttribute("type", folder.versioning.type); + for (Map.Entry param : folder.versioning.params.entrySet()) { + Log.v(TAG, "updateFolder: nodeVersioning: Adding param key=" + param.getKey() + ", val=" + param.getValue()); + Node nodeParam = mConfig.createElement("param"); + elementVersioning.appendChild(nodeParam); + Element elementParam = (Element) nodeParam; + elementParam.setAttribute("key", param.getKey()); + elementParam.setAttribute("val", param.getValue()); + } + } + + break; + } + } + } + + public void removeFolder(String folderId) { + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = nodeFolders.getLength() - 1; i >= 0; i--) { + Element r = (Element) nodeFolders.item(i); + if (folderId.equals(getAttributeOrDefault(r, "id", ""))) { + // Found folder node to remove. + Log.v(TAG, "removeFolder: Removing folder node, folderId=" + folderId); + removeChildElementFromTextNode((Element) r.getParentNode(), r); + break; + } + } + } + + public void setFolderPause(String folderId, Boolean paused) { + NodeList nodeFolders = mConfig.getDocumentElement().getElementsByTagName("folder"); + for (int i = 0; i < nodeFolders.getLength(); i++) { + Element r = (Element) nodeFolders.item(i); + if (getAttributeOrDefault(r, "id", "").equals(folderId)) + { + setConfigElement(r, "paused", Boolean.toString(paused)); + break; + } + } + } + + /** + * Gets ignore list for given folder. + */ + public void getFolderIgnoreList(Folder folder, OnResultListener1 listener) { + FolderIgnoreList folderIgnoreList = new FolderIgnoreList(); + File file; + FileInputStream fileInputStream = null; + try { + file = new File(folder.path, ".stignore"); + if (file.exists()) { + fileInputStream = new FileInputStream(file); + InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, "UTF-8"); + byte[] data = new byte[(int) file.length()]; + fileInputStream.read(data); + folderIgnoreList.ignore = new String(data, "UTF-8").split("\n"); + } else { + // File not found. + Log.w(TAG, "getFolderIgnoreList: File missing " + file); + /** + * Don't fail as the file might be expectedly missing when users didn't + * set ignores in the past storyline of that folder. + */ + } + } catch (IOException e) { + Log.e(TAG, "getFolderIgnoreList: Failed to read '" + folder.path + "/.stignore' #1", e); + } finally { + try { + if (fileInputStream != null) { + fileInputStream.close(); + } + } catch (IOException e) { + Log.e(TAG, "getFolderIgnoreList: Failed to read '" + folder.path + "/.stignore' #2", e); + } + } + listener.onResult(folderIgnoreList); + } + + /** + * Stores ignore list for given folder. + */ + public void postFolderIgnoreList(Folder folder, String[] ignore) { + File file; + FileOutputStream fileOutputStream = null; + try { + file = new File(folder.path, ".stignore"); + if (!file.exists()) { + file.createNewFile(); + } + fileOutputStream = new FileOutputStream(file); + // Log.v(TAG, "postFolderIgnoreList: Writing .stignore content=" + TextUtils.join("\n", ignore)); + fileOutputStream.write(TextUtils.join("\n", ignore).getBytes("UTF-8")); + fileOutputStream.flush(); + } catch (IOException e) { + /** + * This will happen on external storage folders which exist outside the + * "/Android/data/[package_name]/files" folder on Android 5+. + */ + Log.w(TAG, "postFolderIgnoreList: Failed to write '" + folder.path + "/.stignore' #1", e); + } finally { + try { + if (fileOutputStream != null) { + fileOutputStream.close(); + } + } catch (IOException e) { + Log.e(TAG, "postFolderIgnoreList: Failed to write '" + folder.path + "/.stignore' #2", e); + } + } + } + + public List getDevices(Boolean includeLocal) { + String localDeviceID = getLocalDeviceIDfromPref(); + List devices = new ArrayList<>(); + + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + Device device = new Device(); + device.compression = getAttributeOrDefault(r, "compression", "metadata"); + device.deviceID = getAttributeOrDefault(r, "id", ""); + device.introducedBy = getAttributeOrDefault(r, "introducedBy", ""); + device.introducer = getAttributeOrDefault(r, "introducer", false); + device.name = getAttributeOrDefault(r, "name", ""); + device.paused = getContentOrDefault(r.getElementsByTagName("paused").item(0), false); + + // Addresses + /* + +
dynamic
+
tcp4://192.168.1.67:2222
+
+ */ + device.addresses = new ArrayList<>(); + NodeList nodeAddresses = r.getElementsByTagName("address"); + for (int j = 0; j < nodeAddresses.getLength(); j++) { + String address = getContentOrDefault(nodeAddresses.item(j), ""); + device.addresses.add(address); + // Log.v(TAG, "getDevices: address=" + address); + } + + // For testing purposes only. + // Log.v(TAG, "getDevices: device.name=" + device.name + "/" +"device.id=" + device.deviceID + "/" + "device.paused=" + device.paused); + + // Exclude self if requested. + Boolean isLocalDevice = !TextUtils.isEmpty(device.deviceID) && device.deviceID.equals(localDeviceID); + if (includeLocal || !isLocalDevice) { + devices.add(device); + } + } + } + Collections.sort(devices, DEVICES_COMPARATOR); + return devices; + } + + public void addDevice(final Device device, OnResultListener1 errorListener) { + if (!isDeviceIdValid(device.deviceID)) { + errorListener.onResult(mContext.getString(R.string.device_id_invalid)); + return; + } + + Log.v(TAG, "addDevice: deviceID=" + device.deviceID); + Node nodeConfig = mConfig.getDocumentElement(); + Node nodeDevice = mConfig.createElement("device"); + nodeConfig.appendChild(nodeDevice); + Element elementDevice = (Element) nodeDevice; + elementDevice.setAttribute("id", device.deviceID); + updateDevice(device); + } + + public void updateDevice(final Device device) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (device.deviceID.equals(getAttributeOrDefault(r, "id", ""))) { + // Found device to update. + r.setAttribute("compression", device.compression); + r.setAttribute("introducedBy", device.introducedBy); + r.setAttribute("introducer", Boolean.toString(device.introducer)); + r.setAttribute("name", device.name); + + setConfigElement(r, "paused", Boolean.toString(device.paused)); + + // Addresses + // Pass 1: Remove all addresses in XML. + NodeList nodeAddresses = r.getElementsByTagName("address"); + for (int j = nodeAddresses.getLength() - 1; j >= 0; j--) { + Element elementAddress = (Element) nodeAddresses.item(j); + Log.v(TAG, "updateDevice: nodeAddresses: Removing address=" + getContentOrDefault(elementAddress, "")); + removeChildElementFromTextNode(r, elementAddress); + } + + // Pass 2: Add addresses from the POJO model. + if (device.addresses != null) { + for (String address : device.addresses) { + Log.v(TAG, "updateDevice: nodeAddresses: Adding address=" + address); + Node nodeAddress = mConfig.createElement("address"); + r.appendChild(nodeAddress); + Element elementAddress = (Element) nodeAddress; + elementAddress.setTextContent(address); + } + } + + break; + } + } + } + } + + public void removeDevice(String deviceID) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (deviceID.equals(getAttributeOrDefault(r, "id", ""))) { + // Found device to remove. + Log.v(TAG, "removeDevice: Removing device node, deviceID=" + deviceID); + removeChildElementFromTextNode((Element) r.getParentNode(), r); + break; + } + } + } + } + + public void setDevicePause(String deviceId, Boolean paused) { + // Prevent enumerating "" tags below "" nodes by enumerating child nodes manually. + NodeList childNodes = mConfig.getDocumentElement().getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (node.getNodeName().equals("device")) { + Element r = (Element) node; + if (getAttributeOrDefault(r, "id", "").equals(deviceId)) + { + setConfigElement(r, "paused", Boolean.toString(paused)); + break; + } + } + } + } + + /** + * If an indented child element is removed, whitespace and line break will be left by + * Element.removeChild(). + * See https://stackoverflow.com/questions/14255064/removechild-how-to-remove-indent-too + */ + private void removeChildElementFromTextNode(Element parentElement, Element childElement) { + Node prev = childElement.getPreviousSibling(); + if (prev != null && + prev.getNodeType() == Node.TEXT_NODE && + prev.getNodeValue().trim().length() == 0) { + parentElement.removeChild(prev); + } + parentElement.removeChild(childElement); + } + private boolean setConfigElement(Element parent, String tagName, String textContent) { Node element = parent.getElementsByTagName(tagName).item(0); if (element == null) { @@ -347,16 +859,23 @@ public class ConfigXml { return sb.toString(); } + /** + * Returns if a syncthing device ID is correctly formatted. + */ + private Boolean isDeviceIdValid(final String deviceID) { + return deviceID.matches("^([A-Z0-9]{7}-){7}[A-Z0-9]{7}$"); + } + /** * Writes updated mConfig back to file. */ - private void saveChanges() { + public void saveChanges() { if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) { Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file."); return; } - Log.i(TAG, "Writing updated config file"); + Log.i(TAG, "Saving config file"); File mConfigTempFile = Constants.getConfigTempFile(mContext); try { // Write XML header. diff --git a/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java b/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java index c531efeb..33601992 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/views/DevicesAdapter.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.res.Resources; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,11 +17,16 @@ import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.util.Util; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + /** * Generates item views for device items. */ public class DevicesAdapter extends ArrayAdapter { + private static final String TAG = "DevicesAdapter"; + private Connections mConnections; public DevicesAdapter(Context context) { @@ -36,6 +42,7 @@ public class DevicesAdapter extends ArrayAdapter { convertView = inflater.inflate(R.layout.item_device_list, parent, false); } + View rateInOutView = convertView.findViewById(R.id.rateInOutContainer); TextView name = convertView.findViewById(R.id.name); TextView status = convertView.findViewById(R.id.status); TextView download = convertView.findViewById(R.id.download); @@ -52,16 +59,17 @@ public class DevicesAdapter extends ArrayAdapter { } if (conn == null) { - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + // Syncthing is not running. + rateInOutView.setVisibility(GONE); + status.setVisibility(GONE); status.setText(r.getString(R.string.device_state_unknown)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); return convertView; } if (conn.paused) { - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + rateInOutView.setVisibility(GONE); + status.setVisibility(VISIBLE); status.setText(r.getString(R.string.device_paused)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_black)); return convertView; @@ -70,6 +78,8 @@ public class DevicesAdapter extends ArrayAdapter { if (conn.connected) { download.setText(Util.readableTransferRate(getContext(), conn.inBits)); upload.setText(Util.readableTransferRate(getContext(), conn.outBits)); + rateInOutView.setVisibility(VISIBLE); + status.setVisibility(VISIBLE); if (conn.completion == 100) { status.setText(r.getString(R.string.device_up_to_date)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_green)); @@ -81,8 +91,8 @@ public class DevicesAdapter extends ArrayAdapter { } // !conn.connected - download.setText(Util.readableTransferRate(getContext(), 0)); - upload.setText(Util.readableTransferRate(getContext(), 0)); + rateInOutView.setVisibility(GONE); + status.setVisibility(VISIBLE); status.setText(r.getString(R.string.device_disconnected)); status.setTextColor(ContextCompat.getColor(getContext(), R.color.text_red)); return convertView; @@ -91,14 +101,20 @@ public class DevicesAdapter extends ArrayAdapter { /** * Requests new connection info for all devices visible in listView. */ - public void updateConnections(RestApi api) { + public void updateDeviceStatus(RestApi restApi) { + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running. Clear last state. + mConnections = null; + return; + } for (int i = 0; i < getCount(); i++) { - api.getConnections(this::onReceiveConnections); + restApi.getConnections(this::onReceiveConnections); } } private void onReceiveConnections(Connections connections) { mConnections = connections; + // This will invoke "getView" for all elements. notifyDataSetChanged(); } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java b/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java index 8e5d9839..32ab4f96 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/views/FoldersAdapter.java @@ -62,6 +62,20 @@ public class FoldersAdapter extends ArrayAdapter { mContext.startService(intent); }); binding.openFolder.setOnClickListener(view -> { FileUtils.openFolder(mContext, folder.path); } ); + + // Update folder icon. + int drawableId = R.drawable.ic_folder_black_24dp_active; + switch (folder.type) { + case Constants.FOLDER_TYPE_RECEIVE_ONLY: + drawableId = R.drawable.ic_folder_receive_only; + break; + case Constants.FOLDER_TYPE_SEND_ONLY: + drawableId = R.drawable.ic_folder_send_only; + break; + default: + } + binding.openFolder.setImageResource(drawableId); + updateFolderStatusView(binding, folder); return binding.getRoot(); } @@ -72,6 +86,7 @@ public class FoldersAdapter extends ArrayAdapter { binding.items.setVisibility(GONE); binding.override.setVisibility(GONE); binding.size.setVisibility(GONE); + binding.state.setVisibility(GONE); setTextOrHide(binding.invalid, folder.invalid); return; } @@ -80,6 +95,7 @@ public class FoldersAdapter extends ArrayAdapter { boolean outOfSync = folderStatus.state.equals("idle") && neededItems > 0; boolean overrideButtonVisible = folder.type.equals(Constants.FOLDER_TYPE_SEND_ONLY) && outOfSync; binding.override.setVisibility(overrideButtonVisible ? VISIBLE : GONE); + binding.state.setVisibility(VISIBLE); if (outOfSync) { binding.state.setText(mContext.getString(R.string.status_outofsync)); binding.state.setTextColor(ContextCompat.getColor(mContext, R.color.text_red)); @@ -143,8 +159,9 @@ public class FoldersAdapter extends ArrayAdapter { * Requests updated folder status from the api for all visible items. */ public void updateFolderStatus(RestApi restApi) { - if (restApi == null) { - Log.e(TAG, "updateFolderStatus: restApi == null"); + if (restApi == null || !restApi.isConfigLoaded()) { + // Syncthing is not running. Clear last state. + mLocalFolderStatuses.clear(); return; } @@ -158,6 +175,7 @@ public class FoldersAdapter extends ArrayAdapter { private void onReceiveFolderStatus(String folderId, FolderStatus folderStatus) { mLocalFolderStatuses.put(folderId, folderStatus); + // This will invoke "getView" for all elements. notifyDataSetChanged(); } diff --git a/app/src/main/play/en-GB/whatsnew b/app/src/main/play/en-GB/whatsnew index 6d5ae720..4ed7219d 100644 --- a/app/src/main/play/en-GB/whatsnew +++ b/app/src/main/play/en-GB/whatsnew @@ -2,10 +2,10 @@ Maintenance * Updated syncthing core to v0.14.54 * Fallback to http if https tls 1.2 is unavailable on Android 4.x Enhancements +* UI can be used to configure even when syncthing is not running [NEW] * Added "Recent changes" UI, click files to open [NEW] * Specify sync conditions differently for each folder, device [NEW] * Added offline 'tips & tricks' content [NEW] -* UI explains why syncthing is running (or not) Fixes * Fixed the "battery eater" * Android 8 and 9 support diff --git a/app/src/main/res/drawable-hdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-hdpi/ic_folder_receive_only.png new file mode 100644 index 00000000..d8def251 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_folder_send_only.png b/app/src/main/res/drawable-hdpi/ic_folder_send_only.png new file mode 100644 index 00000000..56de3e43 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_folder_receive_only.png b/app/src/main/res/drawable-ldpi/ic_folder_receive_only.png new file mode 100644 index 00000000..ff5a1c5a Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_folder_send_only.png b/app/src/main/res/drawable-ldpi/ic_folder_send_only.png new file mode 100644 index 00000000..506b68ae Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-mdpi/ic_folder_receive_only.png new file mode 100644 index 00000000..e4261b40 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_folder_send_only.png b/app/src/main/res/drawable-mdpi/ic_folder_send_only.png new file mode 100644 index 00000000..1b14b491 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png new file mode 100644 index 00000000..50834a80 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xhdpi/ic_folder_send_only.png new file mode 100644 index 00000000..bf02698c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png new file mode 100644 index 00000000..7e534dd4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png new file mode 100644 index 00000000..4df6e5d2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_receive_only.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_receive_only.png new file mode 100644 index 00000000..62e3ff2e Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_receive_only.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png b/app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png new file mode 100644 index 00000000..f21d8aa2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_folder_send_only.png differ diff --git a/app/src/main/res/layout/item_device_list.xml b/app/src/main/res/layout/item_device_list.xml index 4fc15f04..bc0e1fe3 100644 --- a/app/src/main/res/layout/item_device_list.xml +++ b/app/src/main/res/layout/item_device_list.xml @@ -1,66 +1,107 @@ - + - + android:descendantFocusability="blocksDescendants"> - + - + - + - + - + - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_folder_list.xml b/app/src/main/res/layout/item_folder_list.xml index 0856e370..cd098c0d 100644 --- a/app/src/main/res/layout/item_folder_list.xml +++ b/app/src/main/res/layout/item_folder_list.xml @@ -32,7 +32,6 @@ android:id="@+id/state" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_alignBottom="@id/id" android:layout_alignParentEnd="true" android:layout_alignParentRight="true" android:textAppearance="?textAppearanceListItemSmall" /> @@ -41,7 +40,7 @@ android:id="@+id/directory" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_below="@id/state" + android:layout_below="@id/label" android:ellipsize="end" android:textAppearance="?textAppearanceListItemSecondary" /> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 46396773..dde9bd39 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -220,6 +220,9 @@ Bitte melden Sie auftretende Probleme via GitHub. Die Verzeichnis ID darf nicht leer sein + + Die Verzeichnisbezeichnung darf nicht leer sein + Der Verzeichnispfad darf nicht leer sein @@ -283,6 +286,12 @@ Bitte melden Sie auftretende Probleme via GitHub.
Die Geräte-ID darf nicht leer sein + + Die Geräte-ID ist nicht im richtigen Format. Bitte versuche es erneut und gebe die richtige ID ein. + + + Der Gerätename darf nicht leer sein + QR Code scannen @@ -416,7 +425,7 @@ Bitte melden Sie auftretende Probleme via GitHub.
Keine Papierkorb Einfach - Stufenweis + Gestaffelt Extern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9397b71..b85f25c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,9 @@ Please report any problems you encounter via Github.
The folder ID must not be empty + + The folder label must not be empty + The folder path must not be empty @@ -283,6 +286,12 @@ Please report any problems you encounter via Github.
The device ID must not be empty + + The device ID is not formatted correctly. Please retry and enter the correct ID. + + + The device name must not be empty + Scan QR Code