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 34423257..af87d842 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/FolderActivity.java @@ -1,5 +1,7 @@ package com.nutomic.syncthingandroid.activities; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; @@ -8,6 +10,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.support.v4.provider.DocumentFile; import android.support.v7.widget.SwitchCompat; import android.text.Editable; import android.text.TextUtils; @@ -29,8 +32,10 @@ import com.google.gson.Gson; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.model.Device; 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.FileUtils; import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.Util; @@ -65,21 +70,26 @@ public class FolderActivity extends SyncthingActivity public static final String EXTRA_DEVICE_ID = "com.nutomic.syncthingandroid.activities.FolderActivity.DEVICE_ID"; - private static final String TAG = "EditFolderFragment"; + private static final String TAG = "FolderActivity"; private static final String IS_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE"; private static final String IS_SHOW_DISCARD_DIALOG = "DISCARD_FOLDER_DIALOG_STATE"; private static final int FILE_VERSIONING_DIALOG_REQUEST = 3454; private static final int PULL_ORDER_DIALOG_REQUEST = 3455; + private static final int CHOOSE_FOLDER_REQUEST = 3459; + private static final String FOLDER_MARKER_NAME = ".stfolder"; private static final String IGNORE_FILE_NAME = ".stignore"; private Folder mFolder; + // Contains SAF readwrite access URI on API level >= Build.VERSION_CODES.LOLLIPOP (21) + private Uri mFolderUri = null; private EditText mLabelView; private EditText mIdView; private TextView mPathView; + private TextView mAccessExplanationView; private SwitchCompat mFolderMasterView; private SwitchCompat mFolderFileWatcher; private SwitchCompat mFolderPaused; @@ -102,8 +112,8 @@ public class FolderActivity extends SyncthingActivity @Override public void afterTextChanged(Editable s) { mFolder.label = mLabelView.getText().toString(); - mFolder.id = mIdView.getText().toString(); - mFolder.path = mPathView.getText().toString(); + mFolder.id = mIdView.getText().toString();; + // mPathView must not be handled here as it's handled by {@link onActivityResult} mFolderNeedsToUpdate = true; } }; @@ -114,7 +124,7 @@ public class FolderActivity extends SyncthingActivity public void onCheckedChanged(CompoundButton view, boolean isChecked) { switch (view.getId()) { case R.id.master: - mFolder.type = (isChecked) ? "readonly" : "readwrite"; + mFolder.type = (isChecked) ? Constants.FOLDER_TYPE_SEND_ONLY : Constants.FOLDER_TYPE_SEND_RECEIVE; mFolderNeedsToUpdate = true; break; case R.id.fileWatcher: @@ -150,6 +160,7 @@ public class FolderActivity extends SyncthingActivity mLabelView = findViewById(R.id.label); mIdView = findViewById(R.id.id); mPathView = findViewById(R.id.directoryTextView); + mAccessExplanationView = findViewById(R.id.accessExplanationView); mFolderMasterView = findViewById(R.id.master); mFolderFileWatcher = findViewById(R.id.fileWatcher); mFolderPaused = findViewById(R.id.folderPause); @@ -160,8 +171,7 @@ public class FolderActivity extends SyncthingActivity mDevicesContainer = findViewById(R.id.devicesContainer); mEditIgnores = findViewById(R.id.edit_ignores); - mPathView.setOnClickListener(view -> - startActivityForResult(FolderPickerActivity.createIntent(this, mFolder.path, null), FolderPickerActivity.DIRECTORY_REQUEST_CODE)); + mPathView.setOnClickListener(view -> onPathViewClick()); findViewById(R.id.pullOrderContainer).setOnClickListener(v -> showPullOrderDialog()); findViewById(R.id.versioningContainer).setOnClickListener(v -> showVersioningDialog()); @@ -182,7 +192,11 @@ public class FolderActivity extends SyncthingActivity mEditIgnores.setEnabled(false); } else { - prepareEditMode(); + // Prepare edit mode. + mIdView.clearFocus(); + mIdView.setFocusable(false); + mIdView.setEnabled(false); + mPathView.setEnabled(false); } if (savedInstanceState != null){ @@ -198,6 +212,30 @@ public class FolderActivity extends SyncthingActivity } } + /** + * Invoked after user clicked on the {@link mPathView} label. + */ + @SuppressLint("InlinedAPI") + private void onPathViewClick() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + startActivityForResult(FolderPickerActivity.createIntent(this, mFolder.path, null), + FolderPickerActivity.DIRECTORY_REQUEST_CODE); + return; + } + + // This has to be android.net.Uri as it implements a Parcelable. + android.net.Uri externalFilesDirUri = FileUtils.getExternalFilesDirUri(FolderActivity.this); + + // Display storage access framework directory picker UI. + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + if (externalFilesDirUri != null) { + intent.putExtra("android.provider.extra.INITIAL_URI", externalFilesDirUri); + } + intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); + intent.putExtra("android.content.extra.SHOW_ADVANCED", true); + startActivityForResult(intent, CHOOSE_FOLDER_REQUEST); + } + private void editIgnores() { try { File ignoreFile = new File(mFolder.path, IGNORE_FILE_NAME); @@ -256,7 +294,6 @@ public class FolderActivity extends SyncthingActivity } mLabelView.removeTextChangedListener(mTextWatcher); mIdView.removeTextChangedListener(mTextWatcher); - mPathView.removeTextChangedListener(mTextWatcher); } @Override @@ -315,6 +352,7 @@ public class FolderActivity extends SyncthingActivity finish(); return; } + checkWriteAndUpdateUI(); } if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID)); @@ -339,7 +377,6 @@ public class FolderActivity extends SyncthingActivity private void updateViewsAndSetListeners() { mLabelView.removeTextChangedListener(mTextWatcher); mIdView.removeTextChangedListener(mTextWatcher); - mPathView.removeTextChangedListener(mTextWatcher); mFolderMasterView.setOnCheckedChangeListener(null); mFolderFileWatcher.setOnCheckedChangeListener(null); mFolderPaused.setOnCheckedChangeListener(null); @@ -347,10 +384,9 @@ public class FolderActivity extends SyncthingActivity // Update views mLabelView.setText(mFolder.label); mIdView.setText(mFolder.id); - mPathView.setText(mFolder.path); updatePullOrderDescription(); updateVersioningDescription(); - mFolderMasterView.setChecked(Objects.equal(mFolder.type, "readonly")); + mFolderMasterView.setChecked(Objects.equal(mFolder.type, Constants.FOLDER_TYPE_SEND_ONLY)); mFolderFileWatcher.setChecked(mFolder.fsWatcherEnabled); mFolderPaused.setChecked(mFolder.paused); List devicesList = getApi().getDevices(false); @@ -367,7 +403,6 @@ public class FolderActivity extends SyncthingActivity // Keep state updated mLabelView.addTextChangedListener(mTextWatcher); mIdView.addTextChangedListener(mTextWatcher); - mPathView.addTextChangedListener(mTextWatcher); mFolderMasterView.setOnCheckedChangeListener(mCheckedListener); mFolderFileWatcher.setOnCheckedChangeListener(mCheckedListener); mFolderPaused.setOnCheckedChangeListener(mCheckedListener); @@ -400,7 +435,22 @@ public class FolderActivity extends SyncthingActivity .show(); return true; } - getApi().addFolder(mFolder); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && + mFolderUri != null && + mFolder.type.equals(Constants.FOLDER_TYPE_SEND_ONLY)) { + /** + * Normally, syncthing takes care of creating the ".stfolder" marker. + * This fails on newer android versions if the syncthing binary only has + * readonly access on the path and the user tries to configure a + * sendonly folder. To fix this, we'll precreate the marker using java code. + */ + DocumentFile dfFolder = DocumentFile.fromTreeUri(this, mFolderUri); + if (dfFolder != null) { + Log.v(TAG, "Creating new directory " + mFolder.path + File.separator + FOLDER_MARKER_NAME); + dfFolder.createDirectory(FOLDER_MARKER_NAME); + } + } + getApi().createFolder(mFolder); finish(); return true; case R.id.remove: @@ -436,11 +486,35 @@ public class FolderActivity extends SyncthingActivity @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE) { - mFolder.path = data.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY); - mPathView.setText(mFolder.path); + if (resultCode == Activity.RESULT_OK && requestCode == CHOOSE_FOLDER_REQUEST) { + // This result case only occurs on API level >= Build.VERSION_CODES.LOLLIPOP (21) + mFolderUri = data.getData(); + if (mFolderUri == null) { + return; + } + // Get the folder path unix style, e.g. "/storage/0000-0000/DCIM" + String targetPath = FileUtils.getAbsolutePathFromSAFUri(FolderActivity.this, mFolderUri); + if (targetPath != null) { + targetPath = Util.formatPath(targetPath); + } + if (targetPath == null || TextUtils.isEmpty(targetPath) || (targetPath.equals(File.separator))) { + mFolder.path = ""; + mFolderUri = null; + checkWriteAndUpdateUI(); + // Show message to the user suggesting to select a folder on internal or external storage. + Toast.makeText(this, R.string.toast_invalid_folder_selected, Toast.LENGTH_LONG).show(); + return; + } + mFolder.path = FileUtils.cutTrailingSlash(targetPath); + Log.v(TAG, "onActivityResult/CHOOSE_FOLDER_REQUEST: Got directory path '" + mFolder.path + "'"); + checkWriteAndUpdateUI(); + // Postpone sending the config changes using syncthing REST API. + mFolderNeedsToUpdate = true; + } else if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE) { + mFolder.path = data.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY); + checkWriteAndUpdateUI(); + // Postpone sending the config changes using syncthing REST API. mFolderNeedsToUpdate = true; - mEditIgnores.setEnabled(true); } else if (resultCode == Activity.RESULT_OK && requestCode == FILE_VERSIONING_DIALOG_REQUEST) { updateVersioning(data.getExtras()); } else if (resultCode == Activity.RESULT_OK && requestCode == PULL_ORDER_DIALOG_REQUEST) { @@ -450,6 +524,41 @@ public class FolderActivity extends SyncthingActivity } } + /** + * Prerequisite: mFolder.path must be non-empty + */ + private void checkWriteAndUpdateUI() { + mPathView.setText(mFolder.path); + if (TextUtils.isEmpty(mFolder.path)) { + return; + } + + /** + * Check if the permissions we have on that folder is readonly or readwrite. + * Access level readonly: folder can only be configured "sendonly". + * Access level readwrite: folder can be configured "sendonly" or "sendreceive". + */ + Boolean canWrite = Util.nativeBinaryCanWriteToPath(FolderActivity.this, mFolder.path); + if (canWrite) { + /** + * Suggest FOLDER_TYPE_SEND_RECEIVE folder because the user most probably + * intentionally chose a special folder like + * "[storage]/Android/data/com.nutomic.syncthingandroid/files" + * or enabled root mode thus having write access. + */ + mAccessExplanationView.setText(R.string.folder_path_readwrite); + mFolderMasterView.setChecked(false); + mFolderMasterView.setEnabled(true); + mEditIgnores.setEnabled(true); + } else { + // Force "sendonly" folder. + mAccessExplanationView.setText(R.string.folder_path_readonly); + mFolderMasterView.setChecked(true); + mFolderMasterView.setEnabled(false); + mEditIgnores.setEnabled(false); + } + } + private String generateRandomFolderId() { char[] chars = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); StringBuilder sb = new StringBuilder(); @@ -480,13 +589,6 @@ public class FolderActivity extends SyncthingActivity mFolder.versioning = new Folder.Versioning(); } - private void prepareEditMode() { - mIdView.clearFocus(); - mIdView.setFocusable(false); - mIdView.setEnabled(false); - mPathView.setEnabled(false); - } - private void addEmptyDeviceListView() { int height = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(WRAP_CONTENT, height); @@ -512,7 +614,11 @@ public class FolderActivity extends SyncthingActivity private void updateFolder() { if (!mIsCreateMode) { - getApi().editFolder(mFolder); + /** + * RestApi is guaranteed not to be null as {@link onServiceStateChange} + * immediately finishes this activity if SyncthingService shuts down. + */ + getApi().updateFolder(mFolder); } } 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 100b136e..54e7dfbe 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java @@ -30,6 +30,14 @@ public class Constants { */ public static final String PREF_DEBUG_FACILITIES_AVAILABLE = "debug_facilities_available"; + /** + * Available folder types. + */ + public static final String FOLDER_TYPE_SEND_ONLY = "sendonly"; + public static final String FOLDER_TYPE_SEND_RECEIVE = "sendreceive"; + // public static final String FOLDER_TYPE_RECEIVE_ONLY = "receiveonly" + + /** * On Android 8.1, ACCESS_COARSE_LOCATION is required to access WiFi SSID. * This is the request code used when requesting the permission. 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 bd41f236..2720a2a7 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -307,14 +307,20 @@ public class RestApi { return folders; } - public void addFolder(Folder folder) { + /** + * This is only used for new folder creation, see {@link FolderActivity}. + */ + public void createFolder(Folder folder) { + // Add the new folder to the model. mConfig.folders.add(folder); + // Send model changes to syncthing, does not require a restart. sendConfig(); } - public void editFolder(Folder newFolder) { + public void updateFolder(Folder newFolder) { removeFolderInternal(newFolder.id); - addFolder(newFolder); + mConfig.folders.add(newFolder); + sendConfig(); } public void removeFolder(String id) { 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 a0e535ca..b4259a27 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java @@ -311,7 +311,7 @@ public class ConfigXml { folder.setAttribute("id", mContext.getString(R.string.default_folder_id, defaultFolderId)); folder.setAttribute("path", Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()); - folder.setAttribute("type", "readonly"); + folder.setAttribute("type", Constants.FOLDER_TYPE_SEND_ONLY); folder.setAttribute("fsWatcherEnabled", "true"); folder.setAttribute("fsWatcherDelayS", "10"); return true; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.java b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.java new file mode 100644 index 00000000..8c3af8a6 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/FileUtils.java @@ -0,0 +1,192 @@ +package com.nutomic.syncthingandroid.util; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.provider.DocumentsContract; +import android.support.annotation.Nullable; +import android.util.Log; + +import java.io.File; +import java.lang.reflect.Method; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Utils for dealing with Storage Access Framework URIs. + */ +public class FileUtils { + + private static final String TAG = "FileUtils"; + + // TargetApi(21) + private static final Boolean isCompatible = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP); + + private FileUtils() { + // Private constructor to enforce Singleton pattern. + } + + private static final String PRIMARY_VOLUME_NAME = "primary"; + private static final String HOME_VOLUME_NAME = "home"; + + @Nullable + @TargetApi(21) + public static String getAbsolutePathFromSAFUri(Context context, @Nullable final Uri safResultUri) { + Uri treeUri = DocumentsContract.buildDocumentUriUsingTree(safResultUri, + DocumentsContract.getTreeDocumentId(safResultUri)); + return getAbsolutePathFromTreeUri(context, treeUri); + } + + @Nullable + public static String getAbsolutePathFromTreeUri(Context context, @Nullable final Uri treeUri) { + if (!isCompatible) { + Log.e(TAG, "getAbsolutePathFromTreeUri: called on unsupported API level"); + return null; + } + if (treeUri == null) { + Log.w(TAG, "getAbsolutePathFromTreeUri: called with treeUri == null"); + return null; + } + + // Determine volumeId, e.g. "home", "documents" + String volumeId = getVolumeIdFromTreeUri(treeUri); + if (volumeId == null) { + return null; + } + + // Handle Uri referring to internal or external storage. + String volumePath = getVolumePath(volumeId, context); + if (volumePath == null) { + return File.separator; + } + if (volumePath.endsWith(File.separator)) { + volumePath = volumePath.substring(0, volumePath.length() - 1); + } + String documentPath = getDocumentPathFromTreeUri(treeUri); + if (documentPath.endsWith(File.separator)) { + documentPath = documentPath.substring(0, documentPath.length() - 1); + } + if (documentPath.length() > 0) { + if (documentPath.startsWith(File.separator)) { + return volumePath + documentPath; + } else { + return volumePath + File.separator + documentPath; + } + } else { + return volumePath; + } + } + + @SuppressLint("ObsoleteSdkInt") + @TargetApi(21) + private static String getVolumePath(final String volumeId, Context context) { + if (!isCompatible) { + Log.e(TAG, "getVolumePath called on unsupported API level"); + return null; + } + try { + StorageManager mStorageManager = + (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + Class storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); + Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); + Method getUuid = storageVolumeClazz.getMethod("getUuid"); + Method getPath = storageVolumeClazz.getMethod("getPath"); + Method isPrimary = storageVolumeClazz.getMethod("isPrimary"); + Object result = getVolumeList.invoke(mStorageManager); + + final int length = Array.getLength(result); + for (int i = 0; i < length; i++) { + Object storageVolumeElement = Array.get(result, i); + String uuid = (String) getUuid.invoke(storageVolumeElement); + Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement); + Boolean isPrimaryVolume = (primary && PRIMARY_VOLUME_NAME.equals(volumeId)); + Boolean isExternalVolume = ((uuid != null) && uuid.equals(volumeId)); + Boolean isHomeVolume = (uuid == null && HOME_VOLUME_NAME.equals(volumeId)); + if (isPrimaryVolume || isExternalVolume) { + // Return path if the correct volume corresponding to volumeId was found. + return (String) getPath.invoke(storageVolumeElement); + } else if (isHomeVolume) { + // Reading the environment var avoids hard coding the case of the "documents" folder. + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath(); + } + Log.d(TAG, "Skipping volume, uuid = '" + uuid + "', volumeId = '" + volumeId + "'"); + } + } catch (Exception e) { + Log.w(TAG, "getVolumePath exception", e); + } + return null; + } + + /** + * FileProvider does not support converting the absolute path from + * getExternalFilesDir() to a "content://" Uri. As "file://" Uri + * has been blocked since Android 7+, we need to build the Uri + * manually after discovering the first external storage. + * This is crucial to assist the user finding a writeable folder + * to use syncthing's two way sync feature. + */ + @TargetApi(19) + public static android.net.Uri getExternalFilesDirUri(Context context) { + try { + /** + * Determine the app's private data folder on external storage if present. + * e.g. "/storage/abcd-efgh/Android/com.nutomic.syncthinandroid/files" + */ + ArrayList externalFilesDir = new ArrayList<>(); + externalFilesDir.addAll(Arrays.asList(context.getExternalFilesDirs(null))); + externalFilesDir.remove(context.getExternalFilesDir(null)); + if (externalFilesDir.size() == 0) { + Log.w(TAG, "Could not determine app's private files directory on external storage."); + return null; + } + String absPath = externalFilesDir.get(0).getAbsolutePath(); + String[] segments = absPath.split("/"); + if (segments.length < 2) { + Log.w(TAG, "Could not extract volumeId from app's private files path '" + absPath + "'"); + return null; + } + // Extract the volumeId, e.g. "abcd-efgh" + String volumeId = segments[2]; + // Build the content Uri for our private "files" folder. + return android.net.Uri.parse( + "content://com.android.externalstorage.documents/document/" + + volumeId + "%3AAndroid%2Fdata%2F" + + context.getPackageName() + "%2Ffiles"); + } catch (Exception e) { + Log.w(TAG, "getExternalFilesDirUri exception", e); + } + return null; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getVolumeIdFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if (split.length > 0) { + return split[0]; + } else { + return null; + } + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static String getDocumentPathFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if ((split.length >= 2) && (split[1] != null)) return split[1]; + else return File.separator; + } + + @Nullable + public static String cutTrailingSlash(final String path) { + if (path.endsWith(File.separator)) { + return path.substring(0, path.length() - 1); + } + return path; + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java index 90971bb5..e59d0526 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -8,10 +8,12 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Build; +import android.preference.PreferenceManager; import android.util.Log; import android.widget.Toast; import com.nutomic.syncthingandroid.R; +import com.nutomic.syncthingandroid.service.Constants; import java.io.DataOutputStream; import java.io.IOException; @@ -122,6 +124,81 @@ public class Util { return false; } + /** + * Returns if the syncthing binary would be able to write a file into + * the given folder given the configured access level. + */ + public static boolean nativeBinaryCanWriteToPath(Context context, String absoluteFolderPath) { + final String TOUCH_FILE_NAME = ".stwritetest"; + Boolean useRoot = false; + Boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.PREF_USE_ROOT, false); + if (prefUseRoot && Shell.SU.available()) { + useRoot = true; + } + + // Write permission test file. + String touchFile = absoluteFolderPath + "/" + TOUCH_FILE_NAME; + int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n", useRoot); + if (exitCode != 0) { + String error; + switch (exitCode) { + case 1: + error = "Permission denied"; + break; + default: + error = "Shell execution failed"; + } + Log.i(TAG, "Failed to write test file '" + touchFile + + "', " + error); + return false; + } + + // Detected we have write permission. + Log.i(TAG, "Successfully wrote test file '" + touchFile + "'"); + + // Remove test file. + if (runShellCommand("rm \"" + touchFile + "\"\n", useRoot) != 0) { + // This is very unlikely to happen, so we have less error handling. + Log.i(TAG, "Failed to remove test file"); + } + return true; + } + + /** + * Run command in a shell and return the exit code. + */ + public static int runShellCommand(String cmd, Boolean useRoot) { + // Assume "failure" exit code if an error is caught. + int exitCode = 255; + Process shellProc = null; + DataOutputStream shellOut = null; + try { + shellProc = Runtime.getRuntime().exec((useRoot) ? "su" : "sh"); + shellOut = new DataOutputStream(shellProc.getOutputStream()); + Log.d(TAG, "runShellCommand: " + cmd); + shellOut.writeBytes(cmd); + shellOut.flush(); + shellOut.close(); + shellOut = null; + exitCode = shellProc.waitFor(); + } catch (IOException | InterruptedException e) { + Log.w(TAG, "runShellCommand: Exception", e); + } finally { + try { + if (shellOut != null) { + shellOut.close(); + } + } catch (IOException e) { + Log.w(TAG, "Failed to close shell stream", e); + } + if (shellProc != null) { + shellProc.destroy(); + } + } + return exitCode; + } + /** * Make sure that dialog is showing and activity is valid before dismissing dialog, to prevent * various crashes. diff --git a/app/src/main/res/layout/fragment_folder.xml b/app/src/main/res/layout/fragment_folder.xml index 03c24ce3..1524866a 100644 --- a/app/src/main/res/layout/fragment_folder.xml +++ b/app/src/main/res/layout/fragment_folder.xml @@ -80,6 +80,16 @@ android:drawableStart="@drawable/ic_lock_black_24dp_active" android:text="@string/folder_master" /> + + + android:text="@string/folder_fileWatcherDescription" + android:focusable="false"/> Discard your changes? + + Your Android version only grants Syncthing readonly access to the selected folder. + + + Your Android version grants Syncthing read and write access to the selected folder. + + + Sorry. The selected folder cannot be used. Please select a folder located on the internal or external storage. + Ignore Patterns Failed to create ignore file. Is the directory writable?