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:
parent
23f407ed74
commit
40b16cb065
8 changed files with 439 additions and 30 deletions
|
@ -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<Device> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -80,6 +80,16 @@
|
|||
android:drawableStart="@drawable/ic_lock_black_24dp_active"
|
||||
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:id="@+id/fileWatcher"
|
||||
style="@style/Widget.Syncthing.TextView.Label.Details"
|
||||
|
@ -98,7 +108,8 @@
|
|||
android:layout_marginStart="75dp"
|
||||
android:layout_marginTop="-20dp"
|
||||
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:id="@+id/folderPause"
|
||||
|
|
|
@ -192,6 +192,15 @@ Please report any problems you encounter via Github.</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="create_ignore_file_error">Failed to create ignore file. Is the directory writable?</string>
|
||||
|
|
Loading…
Reference in a new issue