1
0
Fork 0
mirror of https://github.com/syncthing/syncthing-android.git synced 2024-12-23 03:11:30 +00:00

Implement proper permission handling using SAF (fixes #1160)

This commit is contained in:
Catfriend1 2018-07-15 23:34:25 +02:00 committed by Audrius Butkevicius
parent 23f407ed74
commit 40b16cb065
8 changed files with 439 additions and 30 deletions

View file

@ -1,5 +1,7 @@
package com.nutomic.syncthingandroid.activities; package com.nutomic.syncthingandroid.activities;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity; import android.app.Activity;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.Dialog; import android.app.Dialog;
@ -8,6 +10,7 @@ import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.support.v4.provider.DocumentFile;
import android.support.v7.widget.SwitchCompat; import android.support.v7.widget.SwitchCompat;
import android.text.Editable; import android.text.Editable;
import android.text.TextUtils; import android.text.TextUtils;
@ -29,8 +32,10 @@ import com.google.gson.Gson;
import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.model.Device; import com.nutomic.syncthingandroid.model.Device;
import com.nutomic.syncthingandroid.model.Folder; import com.nutomic.syncthingandroid.model.Folder;
import com.nutomic.syncthingandroid.service.Constants;
import com.nutomic.syncthingandroid.service.RestApi; import com.nutomic.syncthingandroid.service.RestApi;
import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.service.SyncthingService;
import com.nutomic.syncthingandroid.util.FileUtils;
import com.nutomic.syncthingandroid.util.TextWatcherAdapter; import com.nutomic.syncthingandroid.util.TextWatcherAdapter;
import com.nutomic.syncthingandroid.util.Util; import com.nutomic.syncthingandroid.util.Util;
@ -65,21 +70,26 @@ public class FolderActivity extends SyncthingActivity
public static final String EXTRA_DEVICE_ID = public static final String EXTRA_DEVICE_ID =
"com.nutomic.syncthingandroid.activities.FolderActivity.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_SHOWING_DELETE_DIALOG = "DELETE_FOLDER_DIALOG_STATE";
private static final String IS_SHOW_DISCARD_DIALOG = "DISCARD_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 FILE_VERSIONING_DIALOG_REQUEST = 3454;
private static final int PULL_ORDER_DIALOG_REQUEST = 3455; 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 static final String IGNORE_FILE_NAME = ".stignore";
private Folder mFolder; 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 mLabelView;
private EditText mIdView; private EditText mIdView;
private TextView mPathView; private TextView mPathView;
private TextView mAccessExplanationView;
private SwitchCompat mFolderMasterView; private SwitchCompat mFolderMasterView;
private SwitchCompat mFolderFileWatcher; private SwitchCompat mFolderFileWatcher;
private SwitchCompat mFolderPaused; private SwitchCompat mFolderPaused;
@ -102,8 +112,8 @@ public class FolderActivity extends SyncthingActivity
@Override @Override
public void afterTextChanged(Editable s) { public void afterTextChanged(Editable s) {
mFolder.label = mLabelView.getText().toString(); mFolder.label = mLabelView.getText().toString();
mFolder.id = mIdView.getText().toString(); mFolder.id = mIdView.getText().toString();;
mFolder.path = mPathView.getText().toString(); // mPathView must not be handled here as it's handled by {@link onActivityResult}
mFolderNeedsToUpdate = true; mFolderNeedsToUpdate = true;
} }
}; };
@ -114,7 +124,7 @@ public class FolderActivity extends SyncthingActivity
public void onCheckedChanged(CompoundButton view, boolean isChecked) { public void onCheckedChanged(CompoundButton view, boolean isChecked) {
switch (view.getId()) { switch (view.getId()) {
case R.id.master: case R.id.master:
mFolder.type = (isChecked) ? "readonly" : "readwrite"; mFolder.type = (isChecked) ? Constants.FOLDER_TYPE_SEND_ONLY : Constants.FOLDER_TYPE_SEND_RECEIVE;
mFolderNeedsToUpdate = true; mFolderNeedsToUpdate = true;
break; break;
case R.id.fileWatcher: case R.id.fileWatcher:
@ -150,6 +160,7 @@ public class FolderActivity extends SyncthingActivity
mLabelView = findViewById(R.id.label); mLabelView = findViewById(R.id.label);
mIdView = findViewById(R.id.id); mIdView = findViewById(R.id.id);
mPathView = findViewById(R.id.directoryTextView); mPathView = findViewById(R.id.directoryTextView);
mAccessExplanationView = findViewById(R.id.accessExplanationView);
mFolderMasterView = findViewById(R.id.master); mFolderMasterView = findViewById(R.id.master);
mFolderFileWatcher = findViewById(R.id.fileWatcher); mFolderFileWatcher = findViewById(R.id.fileWatcher);
mFolderPaused = findViewById(R.id.folderPause); mFolderPaused = findViewById(R.id.folderPause);
@ -160,8 +171,7 @@ public class FolderActivity extends SyncthingActivity
mDevicesContainer = findViewById(R.id.devicesContainer); mDevicesContainer = findViewById(R.id.devicesContainer);
mEditIgnores = findViewById(R.id.edit_ignores); mEditIgnores = findViewById(R.id.edit_ignores);
mPathView.setOnClickListener(view -> mPathView.setOnClickListener(view -> onPathViewClick());
startActivityForResult(FolderPickerActivity.createIntent(this, mFolder.path, null), FolderPickerActivity.DIRECTORY_REQUEST_CODE));
findViewById(R.id.pullOrderContainer).setOnClickListener(v -> showPullOrderDialog()); findViewById(R.id.pullOrderContainer).setOnClickListener(v -> showPullOrderDialog());
findViewById(R.id.versioningContainer).setOnClickListener(v -> showVersioningDialog()); findViewById(R.id.versioningContainer).setOnClickListener(v -> showVersioningDialog());
@ -182,7 +192,11 @@ public class FolderActivity extends SyncthingActivity
mEditIgnores.setEnabled(false); mEditIgnores.setEnabled(false);
} }
else { else {
prepareEditMode(); // Prepare edit mode.
mIdView.clearFocus();
mIdView.setFocusable(false);
mIdView.setEnabled(false);
mPathView.setEnabled(false);
} }
if (savedInstanceState != null){ 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() { private void editIgnores() {
try { try {
File ignoreFile = new File(mFolder.path, IGNORE_FILE_NAME); File ignoreFile = new File(mFolder.path, IGNORE_FILE_NAME);
@ -256,7 +294,6 @@ public class FolderActivity extends SyncthingActivity
} }
mLabelView.removeTextChangedListener(mTextWatcher); mLabelView.removeTextChangedListener(mTextWatcher);
mIdView.removeTextChangedListener(mTextWatcher); mIdView.removeTextChangedListener(mTextWatcher);
mPathView.removeTextChangedListener(mTextWatcher);
} }
@Override @Override
@ -315,6 +352,7 @@ public class FolderActivity extends SyncthingActivity
finish(); finish();
return; return;
} }
checkWriteAndUpdateUI();
} }
if (getIntent().hasExtra(EXTRA_DEVICE_ID)) { if (getIntent().hasExtra(EXTRA_DEVICE_ID)) {
mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID)); mFolder.addDevice(getIntent().getStringExtra(EXTRA_DEVICE_ID));
@ -339,7 +377,6 @@ public class FolderActivity extends SyncthingActivity
private void updateViewsAndSetListeners() { private void updateViewsAndSetListeners() {
mLabelView.removeTextChangedListener(mTextWatcher); mLabelView.removeTextChangedListener(mTextWatcher);
mIdView.removeTextChangedListener(mTextWatcher); mIdView.removeTextChangedListener(mTextWatcher);
mPathView.removeTextChangedListener(mTextWatcher);
mFolderMasterView.setOnCheckedChangeListener(null); mFolderMasterView.setOnCheckedChangeListener(null);
mFolderFileWatcher.setOnCheckedChangeListener(null); mFolderFileWatcher.setOnCheckedChangeListener(null);
mFolderPaused.setOnCheckedChangeListener(null); mFolderPaused.setOnCheckedChangeListener(null);
@ -347,10 +384,9 @@ public class FolderActivity extends SyncthingActivity
// Update views // Update views
mLabelView.setText(mFolder.label); mLabelView.setText(mFolder.label);
mIdView.setText(mFolder.id); mIdView.setText(mFolder.id);
mPathView.setText(mFolder.path);
updatePullOrderDescription(); updatePullOrderDescription();
updateVersioningDescription(); updateVersioningDescription();
mFolderMasterView.setChecked(Objects.equal(mFolder.type, "readonly")); mFolderMasterView.setChecked(Objects.equal(mFolder.type, Constants.FOLDER_TYPE_SEND_ONLY));
mFolderFileWatcher.setChecked(mFolder.fsWatcherEnabled); mFolderFileWatcher.setChecked(mFolder.fsWatcherEnabled);
mFolderPaused.setChecked(mFolder.paused); mFolderPaused.setChecked(mFolder.paused);
List<Device> devicesList = getApi().getDevices(false); List<Device> devicesList = getApi().getDevices(false);
@ -367,7 +403,6 @@ public class FolderActivity extends SyncthingActivity
// Keep state updated // Keep state updated
mLabelView.addTextChangedListener(mTextWatcher); mLabelView.addTextChangedListener(mTextWatcher);
mIdView.addTextChangedListener(mTextWatcher); mIdView.addTextChangedListener(mTextWatcher);
mPathView.addTextChangedListener(mTextWatcher);
mFolderMasterView.setOnCheckedChangeListener(mCheckedListener); mFolderMasterView.setOnCheckedChangeListener(mCheckedListener);
mFolderFileWatcher.setOnCheckedChangeListener(mCheckedListener); mFolderFileWatcher.setOnCheckedChangeListener(mCheckedListener);
mFolderPaused.setOnCheckedChangeListener(mCheckedListener); mFolderPaused.setOnCheckedChangeListener(mCheckedListener);
@ -400,7 +435,22 @@ public class FolderActivity extends SyncthingActivity
.show(); .show();
return true; 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(); finish();
return true; return true;
case R.id.remove: case R.id.remove:
@ -436,11 +486,35 @@ public class FolderActivity extends SyncthingActivity
@Override @Override
public void onActivityResult(int requestCode, int resultCode, Intent data) { public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == Activity.RESULT_OK && requestCode == FolderPickerActivity.DIRECTORY_REQUEST_CODE) { if (resultCode == Activity.RESULT_OK && requestCode == CHOOSE_FOLDER_REQUEST) {
mFolder.path = data.getStringExtra(FolderPickerActivity.EXTRA_RESULT_DIRECTORY); // This result case only occurs on API level >= Build.VERSION_CODES.LOLLIPOP (21)
mPathView.setText(mFolder.path); 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; mFolderNeedsToUpdate = true;
mEditIgnores.setEnabled(true);
} else if (resultCode == Activity.RESULT_OK && requestCode == FILE_VERSIONING_DIALOG_REQUEST) { } else if (resultCode == Activity.RESULT_OK && requestCode == FILE_VERSIONING_DIALOG_REQUEST) {
updateVersioning(data.getExtras()); updateVersioning(data.getExtras());
} else if (resultCode == Activity.RESULT_OK && requestCode == PULL_ORDER_DIALOG_REQUEST) { } 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() { private String generateRandomFolderId() {
char[] chars = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); char[] chars = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@ -480,13 +589,6 @@ public class FolderActivity extends SyncthingActivity
mFolder.versioning = new Folder.Versioning(); mFolder.versioning = new Folder.Versioning();
} }
private void prepareEditMode() {
mIdView.clearFocus();
mIdView.setFocusable(false);
mIdView.setEnabled(false);
mPathView.setEnabled(false);
}
private void addEmptyDeviceListView() { private void addEmptyDeviceListView() {
int height = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); int height = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics());
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(WRAP_CONTENT, height); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(WRAP_CONTENT, height);
@ -512,7 +614,11 @@ public class FolderActivity extends SyncthingActivity
private void updateFolder() { private void updateFolder() {
if (!mIsCreateMode) { 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);
} }
} }

View file

@ -30,6 +30,14 @@ public class Constants {
*/ */
public static final String PREF_DEBUG_FACILITIES_AVAILABLE = "debug_facilities_available"; 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. * On Android 8.1, ACCESS_COARSE_LOCATION is required to access WiFi SSID.
* This is the request code used when requesting the permission. * This is the request code used when requesting the permission.

View file

@ -307,14 +307,20 @@ public class RestApi {
return folders; 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); mConfig.folders.add(folder);
// Send model changes to syncthing, does not require a restart.
sendConfig(); sendConfig();
} }
public void editFolder(Folder newFolder) { public void updateFolder(Folder newFolder) {
removeFolderInternal(newFolder.id); removeFolderInternal(newFolder.id);
addFolder(newFolder); mConfig.folders.add(newFolder);
sendConfig();
} }
public void removeFolder(String id) { public void removeFolder(String id) {

View file

@ -311,7 +311,7 @@ public class ConfigXml {
folder.setAttribute("id", mContext.getString(R.string.default_folder_id, defaultFolderId)); folder.setAttribute("id", mContext.getString(R.string.default_folder_id, defaultFolderId));
folder.setAttribute("path", Environment folder.setAttribute("path", Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath()); .getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath());
folder.setAttribute("type", "readonly"); folder.setAttribute("type", Constants.FOLDER_TYPE_SEND_ONLY);
folder.setAttribute("fsWatcherEnabled", "true"); folder.setAttribute("fsWatcherEnabled", "true");
folder.setAttribute("fsWatcherDelayS", "10"); folder.setAttribute("fsWatcherDelayS", "10");
return true; return true;

View file

@ -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<File> 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;
}
}

View file

@ -8,10 +8,12 @@ import android.content.Context;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Build; import android.os.Build;
import android.preference.PreferenceManager;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.R;
import com.nutomic.syncthingandroid.service.Constants;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
@ -122,6 +124,81 @@ public class Util {
return false; 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 * Make sure that dialog is showing and activity is valid before dismissing dialog, to prevent
* various crashes. * various crashes.

View file

@ -80,6 +80,16 @@
android:drawableStart="@drawable/ic_lock_black_24dp_active" android:drawableStart="@drawable/ic_lock_black_24dp_active"
android:text="@string/folder_master" /> android:text="@string/folder_master" />
<TextView
android:id="@+id/accessExplanationView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="75dp"
android:layout_marginStart="75dp"
android:layout_marginTop="-20dp"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
android:focusable="false"/>
<android.support.v7.widget.SwitchCompat <android.support.v7.widget.SwitchCompat
android:id="@+id/fileWatcher" android:id="@+id/fileWatcher"
style="@style/Widget.Syncthing.TextView.Label.Details" style="@style/Widget.Syncthing.TextView.Label.Details"
@ -98,7 +108,8 @@
android:layout_marginStart="75dp" android:layout_marginStart="75dp"
android:layout_marginTop="-20dp" android:layout_marginTop="-20dp"
android:textAppearance="@style/TextAppearance.AppCompat.Caption" android:textAppearance="@style/TextAppearance.AppCompat.Caption"
android:text="@string/folder_fileWatcherDescription" /> android:text="@string/folder_fileWatcherDescription"
android:focusable="false"/>
<android.support.v7.widget.SwitchCompat <android.support.v7.widget.SwitchCompat
android:id="@+id/folderPause" android:id="@+id/folderPause"

View file

@ -192,6 +192,15 @@ Please report any problems you encounter via Github.</string>
<string name="dialog_discard_changes">Discard your changes?</string> <string name="dialog_discard_changes">Discard your changes?</string>
<!-- Summary shown if we only have readonly access to the folder path -->
<string name="folder_path_readonly">Your Android version only grants Syncthing readonly access to the selected folder.</string>
<!-- Summary shown if we only have readwrite access to the folder path -->
<string name="folder_path_readwrite">Your Android version grants Syncthing read and write access to the selected folder.</string>
<!-- Toast shown if the user selected an invalid location for a new syncthing folder -->
<string name="toast_invalid_folder_selected">Sorry. The selected folder cannot be used. Please select a folder located on the internal or external storage.</string>
<string name="ignore_patterns">Ignore Patterns</string> <string name="ignore_patterns">Ignore Patterns</string>
<string name="create_ignore_file_error">Failed to create ignore file. Is the directory writable?</string> <string name="create_ignore_file_error">Failed to create ignore file. Is the directory writable?</string>