Commit 0b83cfe0 authored by Nicolas Fella's avatar Nicolas Fella Committed by Albert Vaca

Implement Android 6 Runtime Permissions

Differential Revision: https://phabricator.kde.org/D5876
parent e1309006
......@@ -5,7 +5,7 @@
android:versionName="1.6.2">
<uses-sdk android:minSdkVersion="9"
android:targetSdkVersion="22" />
android:targetSdkVersion="25" />
<supports-screens
android:anyDensity="true"
......
......@@ -3,7 +3,7 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
classpath 'com.android.tools.build:gradle:2.3.2'
}
}
......@@ -14,7 +14,7 @@ android {
compileSdkVersion 25
defaultConfig {
minSdkVersion 9
targetSdkVersion 22 //Bumping to >22 means we have to support the new permissions model
targetSdkVersion 25
//multiDexEnabled true
//testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner"
}
......@@ -57,7 +57,7 @@ android {
minifyEnabled false
useProguard false
}
release { //keep on 'releae', set to 'all' when testing to make sure proguard is not deleting important stuff
release { //keep on 'release', set to 'all' when testing to make sure proguard is not deleting important stuff
minifyEnabled true
useProguard true
proguardFiles getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro'
......@@ -83,5 +83,6 @@ dependencies {
androidTestCompile 'org.mockito:mockito-core:1.10.19'
androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.1'// Because mockito has some problems with dex environment
androidTestCompile 'org.skyscreamer:jsonassert:1.3.0'
testCompile 'junit:junit:4.12'
}
......@@ -206,4 +206,8 @@
<string name="open">Open</string>
<string name="close">Close</string>
<string name="no_permissions_storage">You need to grant permissions to access the storage</string>
<string name="plugins_need_permission">Some Plugins need permissions to work (tap for more info):</string>
<string name="permission_explanation">This plugin needs permissions to work</string>
</resources>
......@@ -82,6 +82,7 @@ public class Device implements BaseLink.PackageReceiver {
private List<String> m_supportedPlugins = new ArrayList<>();
private final ConcurrentHashMap<String, Plugin> plugins = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Plugin> failedPlugins = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Plugin> pluginsWithoutPermissions = new ConcurrentHashMap<>();
private Map<String, ArrayList<String>> pluginsByIncomingInterface = new HashMap<>();
private final SharedPreferences settings;
......@@ -722,6 +723,16 @@ public class Device implements BaseLink.PackageReceiver {
failedPlugins.put(pluginKey, plugin);
}
if(!plugin.checkRequiredPermissions()){
Log.e("KDE/addPlugin", "No permission " + pluginKey);
plugins.remove(pluginKey);
pluginsWithoutPermissions.put(pluginKey, plugin);
success = false;
} else {
Log.i("KDE/addPlugin", "Permission OK " + pluginKey);
pluginsWithoutPermissions.remove(pluginKey);
}
return success;
}
......@@ -812,6 +823,10 @@ public class Device implements BaseLink.PackageReceiver {
return failedPlugins;
}
public ConcurrentHashMap<String, Plugin> getPluginsWithoutPermissions() {
return pluginsWithoutPermissions;
}
public void addPluginsChangedListener(PluginsChangedListener listener) {
pluginsChangedListeners.add(listener);
}
......
......@@ -20,23 +20,33 @@
package org.kde.kdeconnect.Plugins;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.support.annotation.StringRes;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import org.kde.kdeconnect.Device;
import org.kde.kdeconnect.NetworkPackage;
import org.kde.kdeconnect.UserInterface.MaterialActivity;
import org.kde.kdeconnect.UserInterface.PluginSettingsActivity;
import org.kde.kdeconnect.UserInterface.SettingsActivity;
import org.kde.kdeconnect_tp.R;
public abstract class Plugin {
protected Device device;
protected Context context;
protected int permissionExplanation = R.string.permission_explanation;
public final void setContext(Context context, Device device) {
this.device = device;
......@@ -167,14 +177,6 @@ public abstract class Plugin {
*/
public boolean onPackageReceived(NetworkPackage np) { return false; }
/**
* If onCreate returns false, should create a dialog explaining
* the problem (and how to fix it, if possible) to the user.
*/
public AlertDialog getErrorDialog(Activity deviceActivity) {
return null;
}
/**
* Should return the list of NetworkPackage types that this plugin can handle
*/
......@@ -205,4 +207,70 @@ public abstract class Plugin {
return b;
}
public String[] getRequiredPermissions() {
return new String[0];
}
public String[] getOptionalPermissions() {
return new String[0];
}
//Permission from Manifest.permission.*
protected boolean isPermissionGranted(String permission) {
int result = ContextCompat.checkSelfPermission(context, permission);
return (result == PackageManager.PERMISSION_GRANTED);
}
protected boolean arePermissionsGranted(String[] permissions) {
for(String permission: permissions){
if(!isPermissionGranted(permission)){
return false;
}
}
return true;
}
protected AlertDialog requestPermissionDialog(Activity activity, String permissions, @StringRes int reason) {
return requestPermissionDialog(activity, new String[]{permissions}, reason);
}
protected AlertDialog requestPermissionDialog(final Activity activity, final String[] permissions, @StringRes int reason){
return new AlertDialog.Builder(activity)
.setTitle(getDisplayName())
.setMessage(reason)
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
ActivityCompat.requestPermissions(activity, permissions, 0);
}
})
.setNegativeButton(R.string.cancel,new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
//Do nothing
}
})
.create();
}
/**
* If onCreate returns false, should create a dialog explaining
* the problem (and how to fix it, if possible) to the user.
*/
public AlertDialog getErrorDialog(Activity deviceActivity) {
return null;
}
public AlertDialog getPermissionExplanationDialog(Activity deviceActivity) {
return requestPermissionDialog(deviceActivity,getRequiredPermissions(), permissionExplanation);
}
public boolean checkRequiredPermissions(){
if (!arePermissionsGranted(getRequiredPermissions())) {
return false;
}
return true;
}
}
......@@ -20,8 +20,16 @@
package org.kde.kdeconnect.Plugins.SftpPlugin;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.pm.PackageManager;
import android.os.Environment;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import org.json.JSONException;
import org.kde.kdeconnect.Helpers.StorageHelper;
import org.kde.kdeconnect.NetworkPackage;
import org.kde.kdeconnect.Plugins.Plugin;
......@@ -38,6 +46,9 @@ public class SftpPlugin extends Plugin {
private static final SimpleSftpServer server = new SimpleSftpServer();
@Override
public String getDisplayName() {
return context.getResources().getString(R.string.pref_plugin_sftp);
......@@ -75,52 +86,50 @@ public class SftpPlugin extends Plugin {
//Kept for compatibility, in case "multiPaths" is not possible or the other end does not support it
np2.set("path", Environment.getExternalStorageDirectory().getAbsolutePath());
File root = new File("/");
if (root.canExecute() && root.canRead()) {
List<StorageHelper.StorageInfo> storageList = StorageHelper.getStorageList();
ArrayList<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
List<StorageHelper.StorageInfo> storageList = StorageHelper.getStorageList();
ArrayList<String> paths = new ArrayList<>();
ArrayList<String> pathNames = new ArrayList<>();
for (StorageHelper.StorageInfo storage : storageList) {
paths.add(storage.path);
StringBuilder res = new StringBuilder();
for (StorageHelper.StorageInfo storage : storageList) {
paths.add(storage.path);
StringBuilder res = new StringBuilder();
if (storageList.size() > 1) {
if (!storage.removable) {
res.append(context.getString(R.string.sftp_internal_storage));
} else if (storage.number > 1) {
res.append(context.getString(R.string.sftp_sdcard_num, storage.number));
} else {
res.append(context.getString(R.string.sftp_sdcard));
}
if (storageList.size() > 1) {
if (!storage.removable) {
res.append(context.getString(R.string.sftp_internal_storage));
} else if (storage.number > 1) {
res.append(context.getString(R.string.sftp_sdcard_num, storage.number));
} else {
res.append(context.getString(R.string.sftp_all_files));
}
String pathName = res.toString();
if (storage.readonly) {
res.append(" ");
res.append(context.getString(R.string.sftp_readonly));
}
pathNames.add(res.toString());
//Shortcut for users that only want to browse camera pictures
String dcim = storage.path + "/DCIM/Camera";
if (new File(dcim).exists()) {
paths.add(dcim);
if (storageList.size() > 1) {
pathNames.add(context.getString(R.string.sftp_camera) + "(" + pathName + ")");
} else {
pathNames.add(context.getString(R.string.sftp_camera));
}
res.append(context.getString(R.string.sftp_sdcard));
}
} else {
res.append(context.getString(R.string.sftp_all_files));
}
String pathName = res.toString();
if (storage.readonly) {
res.append(" ");
res.append(context.getString(R.string.sftp_readonly));
}
pathNames.add(res.toString());
if (paths.size() > 0) {
np2.set("multiPaths", paths);
np2.set("pathNames", pathNames);
//Shortcut for users that only want to browse camera pictures
String dcim = storage.path + "/DCIM/Camera";
if (new File(dcim).exists()) {
paths.add(dcim);
if (storageList.size() > 1) {
pathNames.add(context.getString(R.string.sftp_camera) + "(" + pathName + ")");
} else {
pathNames.add(context.getString(R.string.sftp_camera));
}
}
}
if (paths.size() > 0) {
np2.set("multiPaths", paths);
np2.set("pathNames", pathNames);
}
device.sendPackage(np2);
return true;
......@@ -129,6 +138,12 @@ public class SftpPlugin extends Plugin {
return false;
}
@Override
public String[] getRequiredPermissions() {
String[] perms = {Manifest.permission.READ_EXTERNAL_STORAGE};
return perms;
}
@Override
public String[] getSupportedPackageTypes() {
return new String[]{PACKAGE_TYPE_SFTP_REQUEST};
......
......@@ -20,7 +20,9 @@
package org.kde.kdeconnect.Plugins.SharePlugin;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationManager;
......@@ -30,6 +32,7 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
......@@ -110,6 +113,17 @@ public class SharePlugin extends Plugin {
Log.i("SharePlugin", "hasPayload");
int permissionCheck = ContextCompat.checkSelfPermission(context,
Manifest.permission.WRITE_EXTERNAL_STORAGE);
if(permissionCheck == PackageManager.PERMISSION_GRANTED) {
} else if(permissionCheck == PackageManager.PERMISSION_DENIED){
// TODO Request Permission for storage
Log.i("SharePlugin", "no Permission for Storage");
return false;
}
final InputStream input = np.getPayload();
final long fileLength = np.getPayloadSize();
final String originalFilename = np.getString("filename", Long.toString(System.currentTimeMillis()));
......@@ -132,6 +146,8 @@ public class SharePlugin extends Plugin {
final OutputStream destinationOutput = context.getContentResolver().openOutputStream(destinationDocument.getUri());
final Uri destinationUri = destinationDocument.getUri();
final int notificationId = (int)System.currentTimeMillis();
Resources res = context.getResources();
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
......@@ -192,20 +208,27 @@ public class SharePlugin extends Plugin {
.setAutoCancel(true)
.setProgress(100,100,false)
.setOngoing(false);
if (successful) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(destinationUri, mimeType);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
stackBuilder.addNextIntent(intent);
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentText(res.getString(R.string.received_file_text, destinationDocument.getName()))
.setContentIntent(resultPendingIntent);
// Nougat requires share:// URIs instead of file:// URIs
// TODO use FileProvider for >Nougat
if(Build.VERSION.SDK_INT < 24) {
if (successful) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(destinationUri, mimeType);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
stackBuilder.addNextIntent(intent);
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentText(res.getString(R.string.received_file_text, destinationDocument.getName()))
.setContentIntent(resultPendingIntent);
}
}
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (prefs.getBoolean("share_notification_preference", true)) {
builder.setDefaults(Notification.DEFAULT_ALL);
}
NotificationHelper.notifyCompat(notificationManager, notificationId, builder.build());
if (successful) {
......@@ -409,5 +432,10 @@ public class SharePlugin extends Plugin {
return new String[]{PACKAGE_TYPE_SHARE_REQUEST};
}
@Override
public String[] getRequiredPermissions() {
String[] perms = {Manifest.permission.READ_EXTERNAL_STORAGE};
return perms;
}
}
......@@ -20,6 +20,10 @@
package org.kde.kdeconnect.Plugins.TelepathyPlugin;
import android.Manifest;
import android.content.pm.PackageManager;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.telephony.SmsManager;
import android.util.Log;
......@@ -43,11 +47,6 @@ public class TelepathyPlugin extends Plugin {
return context.getResources().getString(R.string.pref_plugin_telepathy_desc);
}
@Override
public boolean onCreate() {
return true;
}
@Override
public void onDestroy() {
}
......@@ -63,8 +62,18 @@ public class TelepathyPlugin extends Plugin {
String phoneNo = np.getString("phoneNumber");
String sms = np.getString("messageBody");
try {
SmsManager smsManager = SmsManager.getDefault();
smsManager.sendTextMessage(phoneNo, null, sms, null, null);
int permissionCheck = ContextCompat.checkSelfPermission(context,
Manifest.permission.SEND_SMS);
if(permissionCheck == PackageManager.PERMISSION_GRANTED) {
SmsManager smsManager = SmsManager.getDefault();
smsManager.sendTextMessage(phoneNo, null, sms, null, null);
Log.d("TelepathyPlugin", "SMS sent");
} else if(permissionCheck == PackageManager.PERMISSION_DENIED){
// TODO Request Permission SEND_SMS
}
//TODO: Notify other end
} catch (Exception e) {
//TODO: Notify other end
......@@ -176,4 +185,8 @@ public class TelepathyPlugin extends Plugin {
return new String[]{};
}
@Override
public String[] getRequiredPermissions() {
return new String[]{Manifest.permission.SEND_SMS/*, Manifest.permission.READ_CONTACTS*/};
}
}
......@@ -20,13 +20,16 @@
package org.kde.kdeconnect.Plugins.TelephonyPlugin;
import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.content.ContextCompat;
import android.telephony.SmsMessage;
import android.telephony.TelephonyManager;
import android.util.Log;
......@@ -102,32 +105,40 @@ public class TelephonyPlugin extends Plugin {
//Log.e("TelephonyPlugin", "callBroadcastReceived");
Map<String, String> contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber);
NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_TELEPHONY);
if (phoneNumber != null) {
np.set("phoneNumber", phoneNumber);
}
int permissionCheck = ContextCompat.checkSelfPermission(context,
Manifest.permission.READ_CONTACTS);
if (contactInfo.containsKey("name")) {
np.set("contactName", contactInfo.get("name"));
} else {
np.set("contactName", phoneNumber);
}
if(permissionCheck==PackageManager.PERMISSION_GRANTED) {
Map<String, String> contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber);
if (contactInfo.containsKey("photoID")) {
String photoUri = contactInfo.get("photoID");
if (photoUri != null) {
try {
String base64photo = ContactsHelper.photoId64Encoded(context, photoUri);
if (base64photo != null && !base64photo.isEmpty()) {
np.set("phoneThumbnail", base64photo);
if (contactInfo.containsKey("name")) {
np.set("contactName", contactInfo.get("name"));
}
if (contactInfo.containsKey("photoID")) {
String photoUri = contactInfo.get("photoID");
if (photoUri != null) {
try {
String base64photo = ContactsHelper.photoId64Encoded(context, photoUri);
if (base64photo != null && !base64photo.isEmpty()) {
np.set("phoneThumbnail", base64photo);
}
} catch (Exception e) {
Log.e("TelephonyPlugin", "Failed to get contact photo");
}
} catch (Exception e) {
Log.e("TelephonyPlugin", "Failed to get contact photo");
}
}
} else {
np.set("contactName", phoneNumber);
}
if (phoneNumber != null) {
np.set("phoneNumber", phoneNumber);
}
switch (state) {
......@@ -208,18 +219,26 @@ public class TelephonyPlugin extends Plugin {
}
String phoneNumber = message.getOriginatingAddress();
Map<String, String> contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber);
int permissionCheck = ContextCompat.checkSelfPermission(context,
Manifest.permission.READ_CONTACTS);
if(permissionCheck==PackageManager.PERMISSION_GRANTED) {
Map<String, String> contactInfo = ContactsHelper.phoneNumberLookup(context, phoneNumber);
if (contactInfo.containsKey("name")) {
np.set("contactName", contactInfo.get("name"));
}
if (contactInfo.containsKey("photoID")) {
np.set("phoneThumbnail", ContactsHelper.photoId64Encoded(context, contactInfo.get("photoID")));
}
}
if (phoneNumber != null) {
np.set("phoneNumber", phoneNumber);
}
if (contactInfo.containsKey("name")) {
np.set("contactName", contactInfo.get("name"));
}
if (contactInfo.containsKey("photoID")) {
np.set("phoneThumbnail", ContactsHelper.photoId64Encoded(context, contactInfo.get("photoID")));
}
device.sendPackage(np);
}
......@@ -267,4 +286,9 @@ public class TelephonyPlugin extends Plugin {
return new String[]{PACKAGE_TYPE_TELEPHONY};
}
@Override
public String[] getRequiredPermissions() {
return new String[]{Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_CONTACTS, Manifest.permission.SEND_SMS};
}
}
......@@ -68,6 +68,7 @@ public class DeviceFragment extends Fragment {
Device device;
TextView errorHeader;
TextView noPermissionsHeader;
MaterialActivity mActivity;
......@@ -389,6 +390,38 @@ public class DeviceFragment extends Fragment {
}
}
//Plugins without permissions List
final ConcurrentHashMap<String, Plugin> permissionsNeeded = device.getPluginsWithoutPermissions();
if (!permissionsNeeded.isEmpty()) {
if (noPermissionsHeader == null) {
noPermissionsHeader = new TextView(mActivity);
noPermissionsHeader.setPadding(
0,
((int) (28 * getResources().getDisplayMetrics().density)),
0,
((int) (8 * getResources().getDisplayMetrics().density))
);
noPermissionsHeader.setOnClickListener(null);
noPermissionsHeader.setOnLongClickListener(null);
noPermissionsHeader.setText(getResources().getString(R.string.plugins_need_permission));
}
items.add(new CustomItem(noPermissionsHeader));
for (Map.Entry<String, Plugin> entry : permissionsNeeded.entrySet()) {
String pluginKey = entry.getKey();
final Plugin plugin = entry.getValue();
if (plugin == null) {
items.add(new SmallEntryItem(pluginKey));
} else {
items.add(new SmallEntryItem(plugin.getDisplayName(), new View.OnClickListener() {
@Override
public void onClick(View v) {
plugin.getPermissionExplanationDialog(mActivity).show();
}
}));
}
}
}
ListView buttonsList = (ListView) rootView.findViewById(R.id.buttons_list);
ListAdapter adapter = new ListAdapter(mActivity, items);
buttonsList.setAdapter(adapter);
......
package org.kde.kdeconnect.UserInterface;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.design.widget.NavigationView;
import android.support.v4.app.ActivityCompat;
import android.support.v4.app.Fragment;
import android.support.v4.view.GravityCompat;
import android.support.v4.widget.DrawerLayout;
......@@ -294,6 +297,22 @@ public class MaterialActivity extends AppCompatActivity {
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
for (int result : grantResults) {
if (result == PackageManager.PERMISSION_GRANTED) {
//New permission granted, reload plugins
BackgroundService.RunCommand(this, new BackgroundService.InstanceCallback() {
@Override
public void onServiceStart(BackgroundService service) {
Device device = service.getDevice(mCurrentDevice);
device.reloadPluginsFromSettings();
}
});
}
}
}
public void renameDevice() {
final TextView nameView = (TextView) mNavigationView.findViewById(R.id.device_name);
final EditText deviceNameEdit = new EditText(MaterialActivity.this);
......
Markdown is supported