Merge pull request #3632 from GoneUp/runtime_contacts

Runtime permissions for contacts
This commit is contained in:
Vincent Breitmoser 2018-11-28 10:56:27 +01:00 committed by GitHub
commit 0455157eb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 276 additions and 26 deletions

View file

@ -1,14 +1,18 @@
package com.fsck.k9.helper;
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.AbstractCursor;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import timber.log.Timber;
import android.provider.ContactsContract.CommonDataKinds.Photo;
import android.support.v4.content.ContextCompat;
import com.fsck.k9.mail.Address;
@ -263,6 +267,14 @@ public class Contacts {
}
}
private boolean hasContactPermission() {
boolean canRead = ContextCompat.checkSelfPermission(mContext,
Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED;
boolean canWrite = ContextCompat.checkSelfPermission(mContext,
Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED;
return canRead && canWrite;
}
/**
* Return a {@link Cursor} instance that can be used to fetch information
* about the contact with the given email address.
@ -273,12 +285,17 @@ public class Contacts {
*/
private Cursor getContactByAddress(final String address) {
final Uri uri = Uri.withAppendedPath(ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, Uri.encode(address));
if (hasContactPermission()) {
return mContentResolver.query(
uri,
PROJECTION,
null,
null,
SORT_ORDER);
} else {
return new EmptyCursor();
}
}
/**

View file

@ -0,0 +1,54 @@
package com.fsck.k9.helper;
import android.database.AbstractCursor;
/**
* A dummy class that provides a empty cursor
*/
public class EmptyCursor extends AbstractCursor {
@Override
public int getCount() {
return 0;
}
@Override
public String[] getColumnNames() {
return new String[0];
}
@Override
public String getString(int column) {
return null;
}
@Override
public short getShort(int column) {
return 0;
}
@Override
public int getInt(int column) {
return 0;
}
@Override
public long getLong(int column) {
return 0;
}
@Override
public float getFloat(int column) {
return 0;
}
@Override
public double getDouble(int column) {
return 0;
}
@Override
public boolean isNull(int column) {
return false;
}
}

View file

@ -46,7 +46,7 @@ android {
versionName '5.700-SNAPSHOT'
minSdkVersion buildConfig.minSdk
targetSdkVersion 22
targetSdkVersion 23
generatedDensities = ['mdpi', 'hdpi', 'xhdpi']

View file

@ -61,7 +61,7 @@ android {
defaultConfig {
minSdkVersion buildConfig.minSdk
// For Robolectric tests
targetSdkVersion 22
targetSdkVersion 23
}
lintOptions {

View file

@ -1,14 +1,27 @@
package com.fsck.k9.activity;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.annotation.StringRes;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.view.MotionEvent;
import com.fsck.k9.activity.K9ActivityCommon.K9ActivityMagic;
import com.fsck.k9.activity.misc.SwipeGestureDetector.OnSwipeGestureListener;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.permissions.PermissionRationaleDialogFragment;
import timber.log.Timber;
public abstract class K9Activity extends AppCompatActivity implements K9ActivityMagic {
public static final int PERMISSIONS_REQUEST_READ_CONTACTS = 1;
public static final int PERMISSIONS_REQUEST_WRITE_CONTACTS = 2;
private static final String FRAGMENT_TAG_RATIONALE = "rationale";
private K9ActivityCommon mBase;
@ -29,4 +42,53 @@ public abstract class K9Activity extends AppCompatActivity implements K9Activity
public void setupGestureDetector(OnSwipeGestureListener listener) {
mBase.setupGestureDetector(listener);
}
public boolean hasPermission(Permission permission) {
return ContextCompat.checkSelfPermission(this, permission.permission) == PackageManager.PERMISSION_GRANTED;
}
public void requestPermissionOrShowRationale(Permission permission) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission.permission)) {
PermissionRationaleDialogFragment dialogFragment =
PermissionRationaleDialogFragment.newInstance(permission);
dialogFragment.show(getSupportFragmentManager(), FRAGMENT_TAG_RATIONALE);
} else {
requestPermission(permission);
}
}
public void requestPermission(Permission permission) {
Timber.i("Requesting permission: " + permission.permission);
ActivityCompat.requestPermissions(this, new String[] { permission.permission }, permission.requestCode);
}
public enum Permission {
READ_CONTACTS(
Manifest.permission.READ_CONTACTS,
PERMISSIONS_REQUEST_READ_CONTACTS,
R.string.permission_contacts_rationale_title,
R.string.permission_contacts_rationale_message
),
WRITE_CONTACTS(
Manifest.permission.WRITE_CONTACTS,
PERMISSIONS_REQUEST_WRITE_CONTACTS,
R.string.permission_contacts_rationale_title,
R.string.permission_contacts_rationale_message
);
public final String permission;
public final int requestCode;
public final int rationaleTitle;
public final int rationaleMessage;
Permission(String permission, int requestCode, @StringRes int rationaleTitle, @StringRes int rationaleMessage) {
this.permission = permission;
this.requestCode = requestCode;
this.rationaleTitle = rationaleTitle;
this.rationaleMessage = rationaleMessage;
}
}
}

View file

@ -48,8 +48,6 @@ import com.fsck.k9.Account.MessageFormat;
import com.fsck.k9.Identity;
import com.fsck.k9.K9;
import com.fsck.k9.Preferences;
import com.fsck.k9.controller.MessageReference;
import com.fsck.k9.ui.R;
import com.fsck.k9.activity.MessageLoaderHelper.MessageLoaderCallbacks;
import com.fsck.k9.activity.compose.AttachmentPresenter;
import com.fsck.k9.activity.compose.AttachmentPresenter.AttachmentMvpView;
@ -65,6 +63,7 @@ import com.fsck.k9.activity.compose.RecipientMvpView;
import com.fsck.k9.activity.compose.RecipientPresenter;
import com.fsck.k9.activity.compose.SaveMessageTask;
import com.fsck.k9.activity.misc.Attachment;
import com.fsck.k9.controller.MessageReference;
import com.fsck.k9.controller.MessagingController;
import com.fsck.k9.controller.MessagingListener;
import com.fsck.k9.controller.SimpleMessagingListener;
@ -98,6 +97,7 @@ import com.fsck.k9.message.SimpleMessageBuilder;
import com.fsck.k9.message.SimpleMessageFormat;
import com.fsck.k9.search.LocalSearch;
import com.fsck.k9.ui.EolConvertingEditText;
import com.fsck.k9.ui.R;
import com.fsck.k9.ui.compose.QuotedMessageMvpView;
import com.fsck.k9.ui.compose.QuotedMessagePresenter;
import org.openintents.openpgp.OpenPgpApiManager;
@ -456,6 +456,10 @@ public class MessageCompose extends K9Activity implements OnClickListener,
setProgressBarIndeterminateVisibility(true);
currentMessageBuilder.reattachCallback(this);
}
if (savedInstanceState == null) {
checkAndRequestPermissions();
}
}
/**
@ -639,6 +643,12 @@ public class MessageCompose extends K9Activity implements OnClickListener,
updateMessageFormat();
}
private void checkAndRequestPermissions() {
if (!hasPermission(Permission.READ_CONTACTS)) {
requestPermissionOrShowRationale(Permission.READ_CONTACTS);
}
}
private void setTitle() {
setTitle(action.getTitleResource());
}

View file

@ -1,6 +1,9 @@
package com.fsck.k9.activity;
import java.util.Collection;
import java.util.List;
import android.annotation.SuppressLint;
import android.app.SearchManager;
import android.content.Context;
@ -63,14 +66,11 @@ import com.fsck.k9.view.MessageTitleView;
import com.fsck.k9.view.ViewSwitcher;
import com.fsck.k9.view.ViewSwitcher.OnSwitchCompleteListener;
import com.mikepenz.materialdrawer.Drawer.OnDrawerListener;
import java.util.Collection;
import java.util.List;
import de.cketti.library.changelog.ChangeLog;
import timber.log.Timber;
/**
* MessageList is the primary user interface for the program. This Activity
* shows a list of messages.
@ -253,6 +253,10 @@ public class MessageList extends K9Activity implements MessageListFragmentListen
if (cl.isFirstRun()) {
cl.getLogDialog().show();
}
if (savedInstanceState == null) {
checkAndRequestPermissions();
}
}
@Override
@ -491,6 +495,13 @@ public class MessageList extends K9Activity implements MessageListFragmentListen
return true;
}
private void checkAndRequestPermissions() {
if (!hasPermission(Permission.READ_CONTACTS)) {
requestPermissionOrShowRationale(Permission.READ_CONTACTS);
}
}
@Override
public void onPause() {
super.onPause();

View file

@ -8,8 +8,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
@ -19,7 +21,9 @@ import android.provider.ContactsContract.Contacts.Data;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.AsyncTaskLoader;
import android.support.v4.content.ContextCompat;
import com.fsck.k9.helper.EmptyCursor;
import com.fsck.k9.ui.R;
import com.fsck.k9.mail.Address;
import com.fsck.k9.view.RecipientSelectView.Recipient;
@ -285,18 +289,30 @@ public class RecipientLoader extends AsyncTaskLoader<List<Recipient>> {
return contactUri.getLastPathSegment();
}
private boolean hasContactPermission() {
boolean canRead = ContextCompat.checkSelfPermission(getContext(),
Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED;
boolean canWrite = ContextCompat.checkSelfPermission(getContext(),
Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED;
return canRead && canWrite;
}
private Cursor getNicknameCursor(String nickname) {
nickname = "%" + nickname + "%";
Uri queryUriForNickname = ContactsContract.Data.CONTENT_URI;
if (hasContactPermission()) {
return contentResolver.query(queryUriForNickname,
PROJECTION_NICKNAME,
ContactsContract.CommonDataKinds.Nickname.NAME + " LIKE ? AND " +
Data.MIMETYPE + " = ?",
new String[] { nickname, ContactsContract.CommonDataKinds.Nickname.CONTENT_ITEM_TYPE },
null);
} else {
return new EmptyCursor();
}
}
@SuppressWarnings("ConstantConditions")
@ -358,7 +374,11 @@ public class RecipientLoader extends AsyncTaskLoader<List<Recipient>> {
List<Recipient> recipients = new ArrayList<>();
Uri queryUri = Email.CONTENT_URI;
Cursor cursor = contentResolver.query(queryUri, PROJECTION, null, null, SORT_ORDER);
Cursor cursor = null;
if (hasContactPermission()) {
cursor = contentResolver.query(queryUri, PROJECTION, null, null, SORT_ORDER);
}
if (cursor == null) {
return recipients;
@ -380,7 +400,11 @@ public class RecipientLoader extends AsyncTaskLoader<List<Recipient>> {
String selection = Contacts.DISPLAY_NAME_PRIMARY + " LIKE ? " +
" OR (" + Email.ADDRESS + " LIKE ? AND " + Data.MIMETYPE + " = '" + Email.CONTENT_ITEM_TYPE + "')";
String[] selectionArgs = { query, query };
Cursor cursor = contentResolver.query(queryUri, PROJECTION, selection, selectionArgs, SORT_ORDER);
Cursor cursor = null;
if (hasContactPermission()) {
cursor = contentResolver.query(queryUri, PROJECTION, selection, selectionArgs, SORT_ORDER);
}
if (cursor == null) {
return false;

View file

@ -0,0 +1,48 @@
package com.fsck.k9.ui.permissions
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import androidx.core.os.bundleOf
import com.fsck.k9.activity.K9Activity
import com.fsck.k9.activity.K9Activity.Permission
import com.fsck.k9.ui.R
/**
* A dialog displaying a message to explain why the app requests a certain permission.
*
* Closing the dialog triggers a permission request. For this to work the Activity needs to be a subclass of
* [K9Activity].
*/
class PermissionRationaleDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val args = arguments!!
val permissionName = args.getString(ARG_PERMISSION)
val permission = Permission.valueOf(permissionName)
return AlertDialog.Builder(requireContext()).apply {
setTitle(permission.rationaleTitle)
setMessage(permission.rationaleMessage)
setPositiveButton(R.string.okay_action) { _, _ ->
val activity = requireActivity() as? K9Activity
?: throw AssertionError("PermissionRationaleDialogFragment can only be used with K9Activity")
activity.requestPermission(permission)
}
}.create()
}
companion object {
private const val ARG_PERMISSION = "permission"
@JvmStatic
fun newInstance(permission: Permission): PermissionRationaleDialogFragment {
return PermissionRationaleDialogFragment().apply {
arguments = bundleOf(ARG_PERMISSION to permission.name)
}
}
}
}

View file

@ -1350,4 +1350,9 @@ You can keep this message and use it as a backup for your secret key. If you wan
<string name="navigation_drawer_open">Open</string>
<string name="navigation_drawer_close">Close</string>
<!-- permissions -->
<string name="permission_contacts_rationale_title">Allow access to contacts</string>
<string name="permission_contacts_rationale_message">To be able to provide contact suggestions and to display contact names and photos, the app needs access to your contacts.</string>
</resources>

View file

@ -3,6 +3,7 @@ package com.fsck.k9.activity.compose;
import java.util.List;
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
import android.database.MatrixCursor;
@ -16,7 +17,7 @@ import com.fsck.k9.view.RecipientSelectView.Recipient;
import com.fsck.k9.view.RecipientSelectView.RecipientCryptoStatus;
import org.junit.Before;
import org.junit.Test;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.shadows.ShadowApplication;
import static android.provider.ContactsContract.CommonDataKinds.Email.TYPE_HOME;
import static org.junit.Assert.assertEquals;
@ -65,18 +66,21 @@ public class RecipientLoaderTest extends RobolectricTest {
static final String QUERYSTRING = "querystring";
ShadowApplication shadowApp;
Context context;
ContentResolver contentResolver;
@Before
public void setUp() throws Exception {
shadowApp = ShadowApplication.getInstance();
shadowApp.grantPermissions(Manifest.permission.READ_CONTACTS);
shadowApp.grantPermissions(Manifest.permission.WRITE_CONTACTS);
context = mock(Context.class);
contentResolver = mock(ContentResolver.class);
when(context.getApplicationContext()).thenReturn(RuntimeEnvironment.application);
when(context.getApplicationContext()).thenReturn(shadowApp.getApplicationContext());
when(context.getContentResolver()).thenReturn(contentResolver);
}
@ -213,6 +217,7 @@ public class RecipientLoaderTest extends RobolectricTest {
nullable(String.class))).thenReturn(cursor);
}
@Test
public void queryContactProvider() throws Exception {
RecipientLoader recipientLoader = new RecipientLoader(context, CRYPTO_PROVIDER, QUERYSTRING);
@ -225,6 +230,20 @@ public class RecipientLoaderTest extends RobolectricTest {
assertEquals(RecipientCryptoStatus.UNAVAILABLE, recipients.get(0).getCryptoStatus());
}
@Test
public void queryContactProviderWithoutPermission() throws Exception {
shadowApp.denyPermissions(Manifest.permission.READ_CONTACTS);
shadowApp.denyPermissions(Manifest.permission.WRITE_CONTACTS);
RecipientLoader recipientLoader = new RecipientLoader(context, CRYPTO_PROVIDER, QUERYSTRING);
setupContactProvider("%" + QUERYSTRING + "%", CONTACT_1);
List<Recipient> recipients = recipientLoader.loadInBackground();
assertEquals(0, recipients.size());
}
@Test
public void queryContactProvider_ignoresRecipientWithNoEmail() throws Exception {
RecipientLoader recipientLoader = new RecipientLoader(context, CRYPTO_PROVIDER, QUERYSTRING);