diff --git a/android/easypermissions/build.gradle b/android/easypermissions/build.gradle new file mode 100644 index 00000000..5c6af4bc --- /dev/null +++ b/android/easypermissions/build.gradle @@ -0,0 +1,52 @@ +apply plugin: 'com.android.library' + +// See: https://github.com/vanniktech/gradle-maven-publish-plugin/issues/206 +ext { + RELEASE_REPOSITORY_URL = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" + SNAPSHOT_REPOSITORY_URL = "https://s01.oss.sonatype.org/content/repositories/snapshots/" +} + +apply plugin: 'com.vanniktech.maven.publish' + +android { + compileSdkVersion 30 + testOptions.unitTests.includeAndroidResources = true + + defaultConfig { + minSdkVersion 14 + targetSdkVersion 30 + versionCode 1 + versionName "3.0.0" + } + + buildTypes { + debug { + testCoverageEnabled true + } + release { + minifyEnabled false + consumerProguardFiles 'proguard-rules.pro' + } + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + +} + +dependencies { + api "androidx.appcompat:appcompat:1.1.0" + api "androidx.annotation:annotation:1.1.0" + api "androidx.core:core:1.3.0" + api "androidx.fragment:fragment:1.2.5" + + testImplementation 'junit:junit:4.13' + testImplementation 'com.google.truth:truth:0.42' + testImplementation 'org.robolectric:robolectric:4.1' + testImplementation 'androidx.test:core:1.3.0-rc01' + testImplementation 'androidx.fragment:fragment-testing:1.2.5' + testImplementation 'org.mockito:mockito-core:2.23.4' +} diff --git a/android/easypermissions/gradle.properties b/android/easypermissions/gradle.properties new file mode 100644 index 00000000..3e0ef642 --- /dev/null +++ b/android/easypermissions/gradle.properties @@ -0,0 +1,18 @@ +GROUP=pub.devrel +POM_ARTIFACT_ID=easypermissions +VERSION_NAME=3.0.0 + +POM_NAME=EasyPermissions +POM_PACKAGING=aar + +POM_DESCRIPTION=A wrapper library for basic Android M system permissions logic + +POM_URL=https://github.com/googlesamples/easypermissions +POM_SCM_URL=https://github.com/googlesamples/easypermissions +POM_SCM_CONNECTION=https://github.com/googlesamples/easypermissions.git + +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo + +POM_DEVELOPER_NAME=Google diff --git a/android/easypermissions/proguard-rules.pro b/android/easypermissions/proguard-rules.pro new file mode 100644 index 00000000..e0ba183b --- /dev/null +++ b/android/easypermissions/proguard-rules.pro @@ -0,0 +1,3 @@ +-keepclassmembers class * { + @pub.devrel.easypermissions.AfterPermissionGranted ; +} diff --git a/android/easypermissions/src/main/AndroidManifest.xml b/android/easypermissions/src/main/AndroidManifest.xml new file mode 100644 index 00000000..16a209f6 --- /dev/null +++ b/android/easypermissions/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/AfterPermissionGranted.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AfterPermissionGranted.java new file mode 100644 index 00000000..09202a22 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AfterPermissionGranted.java @@ -0,0 +1,29 @@ +/* + * Copyright Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pub.devrel.easypermissions; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface AfterPermissionGranted { + + int value(); + +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialog.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialog.java new file mode 100644 index 00000000..4407e4fc --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialog.java @@ -0,0 +1,356 @@ +package pub.devrel.easypermissions; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +/** + * Dialog to prompt the user to go to the app's settings screen and enable permissions. If the user + * clicks 'OK' on the dialog, they are sent to the settings screen. The result is returned to the + * Activity via {@see Activity#onActivityResult(int, int, Intent)}. + *

+ * Use the {@link Builder} to create and display a dialog. + */ +public class AppSettingsDialog implements Parcelable { + + private static final String TAG = "EasyPermissions"; + + public static final int DEFAULT_SETTINGS_REQ_CODE = 16061; + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public AppSettingsDialog createFromParcel(Parcel in) { + return new AppSettingsDialog(in); + } + + @Override + public AppSettingsDialog[] newArray(int size) { + return new AppSettingsDialog[size]; + } + }; + + static final String EXTRA_APP_SETTINGS = "extra_app_settings"; + + @StyleRes + private final int mThemeResId; + private final String mRationale; + private final String mTitle; + private final String mPositiveButtonText; + private final String mNegativeButtonText; + private final int mRequestCode; + private final int mIntentFlags; + + private Object mActivityOrFragment; + private Context mContext; + + private AppSettingsDialog(Parcel in) { + mThemeResId = in.readInt(); + mRationale = in.readString(); + mTitle = in.readString(); + mPositiveButtonText = in.readString(); + mNegativeButtonText = in.readString(); + mRequestCode = in.readInt(); + mIntentFlags = in.readInt(); + } + + private AppSettingsDialog(@NonNull final Object activityOrFragment, + @StyleRes int themeResId, + @Nullable String rationale, + @Nullable String title, + @Nullable String positiveButtonText, + @Nullable String negativeButtonText, + int requestCode, + int intentFlags) { + setActivityOrFragment(activityOrFragment); + mThemeResId = themeResId; + mRationale = rationale; + mTitle = title; + mPositiveButtonText = positiveButtonText; + mNegativeButtonText = negativeButtonText; + mRequestCode = requestCode; + mIntentFlags = intentFlags; + } + + static AppSettingsDialog fromIntent(Intent intent, Activity activity) { + AppSettingsDialog dialog = intent.getParcelableExtra(AppSettingsDialog.EXTRA_APP_SETTINGS); + + // It's not clear how this could happen, but in the case that it does we should try + // to avoid a runtime crash and just use the default dialog. + // https://github.com/googlesamples/easypermissions/issues/278 + if (dialog == null) { + Log.e(TAG, "Intent contains null value for EXTRA_APP_SETTINGS: " + + "intent=" + intent + + ", " + + "extras=" + intent.getExtras()); + + dialog = new AppSettingsDialog.Builder(activity).build(); + } + + dialog.setActivityOrFragment(activity); + return dialog; + } + + private void setActivityOrFragment(Object activityOrFragment) { + mActivityOrFragment = activityOrFragment; + + if (activityOrFragment instanceof Activity) { + mContext = (Activity) activityOrFragment; + } else if (activityOrFragment instanceof Fragment) { + mContext = ((Fragment) activityOrFragment).getContext(); + } else { + throw new IllegalStateException("Unknown object: " + activityOrFragment); + } + } + + private void startForResult(Intent intent) { + if (mActivityOrFragment instanceof Activity) { + ((Activity) mActivityOrFragment).startActivityForResult(intent, mRequestCode); + } else if (mActivityOrFragment instanceof Fragment) { + ((Fragment) mActivityOrFragment).startActivityForResult(intent, mRequestCode); + } + } + + /** + * Display the built dialog. + */ + public void show() { + startForResult(AppSettingsDialogHolderActivity.createShowDialogIntent(mContext, this)); + } + + /** + * Show the dialog. {@link #show()} is a wrapper to ensure backwards compatibility + */ + AlertDialog showDialog(DialogInterface.OnClickListener positiveListener, + DialogInterface.OnClickListener negativeListener) { + AlertDialog.Builder builder; + if (mThemeResId != -1) { + builder = new AlertDialog.Builder(mContext, mThemeResId); + } else { + builder = new AlertDialog.Builder(mContext); + } + return builder + .setCancelable(false) + .setTitle(mTitle) + .setMessage(mRationale) + .setPositiveButton(mPositiveButtonText, positiveListener) + .setNegativeButton(mNegativeButtonText, negativeListener) + .show(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mThemeResId); + dest.writeString(mRationale); + dest.writeString(mTitle); + dest.writeString(mPositiveButtonText); + dest.writeString(mNegativeButtonText); + dest.writeInt(mRequestCode); + dest.writeInt(mIntentFlags); + } + + int getIntentFlags() { + return mIntentFlags; + } + + /** + * Builder for an {@link AppSettingsDialog}. + */ + public static class Builder { + + private final Object mActivityOrFragment; + private final Context mContext; + @StyleRes + private int mThemeResId = -1; + private String mRationale; + private String mTitle; + private String mPositiveButtonText; + private String mNegativeButtonText; + private int mRequestCode = -1; + private boolean mOpenInNewTask = false; + + /** + * Create a new Builder for an {@link AppSettingsDialog}. + * + * @param activity the {@link Activity} in which to display the dialog. + */ + public Builder(@NonNull Activity activity) { + mActivityOrFragment = activity; + mContext = activity; + } + + /** + * Create a new Builder for an {@link AppSettingsDialog}. + * + * @param fragment the {@link Fragment} in which to display the dialog. + */ + public Builder(@NonNull Fragment fragment) { + mActivityOrFragment = fragment; + mContext = fragment.getContext(); + } + + /** + * Set the dialog theme. + */ + @NonNull + public Builder setThemeResId(@StyleRes int themeResId) { + mThemeResId = themeResId; + return this; + } + + /** + * Set the title dialog. Default is "Permissions Required". + */ + @NonNull + public Builder setTitle(@Nullable String title) { + mTitle = title; + return this; + } + + /** + * Set the title dialog. Default is "Permissions Required". + */ + @NonNull + public Builder setTitle(@StringRes int title) { + mTitle = mContext.getString(title); + return this; + } + + /** + * Set the rationale dialog. Default is + * "This app may not work correctly without the requested permissions. + * Open the app settings screen to modify app permissions." + */ + @NonNull + public Builder setRationale(@Nullable String rationale) { + mRationale = rationale; + return this; + } + + /** + * Set the rationale dialog. Default is + * "This app may not work correctly without the requested permissions. + * Open the app settings screen to modify app permissions." + */ + @NonNull + public Builder setRationale(@StringRes int rationale) { + mRationale = mContext.getString(rationale); + return this; + } + + /** + * Set the positive button text, default is {@link android.R.string#ok}. + */ + @NonNull + public Builder setPositiveButton(@Nullable String text) { + mPositiveButtonText = text; + return this; + } + + /** + * Set the positive button text, default is {@link android.R.string#ok}. + */ + @NonNull + public Builder setPositiveButton(@StringRes int textId) { + mPositiveButtonText = mContext.getString(textId); + return this; + } + + /** + * Set the negative button text, default is {@link android.R.string#cancel}. + *

+ * To know if a user cancelled the request, check if your permissions were given with {@link + * EasyPermissions#hasPermissions(Context, String...)} in {@see + * Activity#onActivityResult(int, int, Intent)}. If you still don't have the right + * permissions, then the request was cancelled. + */ + @NonNull + public Builder setNegativeButton(@Nullable String text) { + mNegativeButtonText = text; + return this; + } + + /** + * Set the negative button text, default is {@link android.R.string#cancel}. + */ + @NonNull + public Builder setNegativeButton(@StringRes int textId) { + mNegativeButtonText = mContext.getString(textId); + return this; + } + + /** + * Set the request code use when launching the Settings screen for result, can be retrieved + * in the calling Activity's {@see Activity#onActivityResult(int, int, Intent)} method. + * Default is {@link #DEFAULT_SETTINGS_REQ_CODE}. + */ + @NonNull + public Builder setRequestCode(int requestCode) { + mRequestCode = requestCode; + return this; + } + + /** + * Set whether the settings screen should be opened in a separate task. This is achieved by + * setting {@link android.content.Intent#FLAG_ACTIVITY_NEW_TASK#FLAG_ACTIVITY_NEW_TASK} on + * the Intent used to open the settings screen. + */ + @NonNull + public Builder setOpenInNewTask(boolean openInNewTask) { + mOpenInNewTask = openInNewTask; + return this; + } + + /** + * Build the {@link AppSettingsDialog} from the specified options. Generally followed by a + * call to {@link AppSettingsDialog#show()}. + */ + @NonNull + public AppSettingsDialog build() { + mRationale = TextUtils.isEmpty(mRationale) ? + mContext.getString(R.string.rationale_ask_again) : mRationale; + mTitle = TextUtils.isEmpty(mTitle) ? + mContext.getString(R.string.title_settings_dialog) : mTitle; + mPositiveButtonText = TextUtils.isEmpty(mPositiveButtonText) ? + mContext.getString(android.R.string.ok) : mPositiveButtonText; + mNegativeButtonText = TextUtils.isEmpty(mNegativeButtonText) ? + mContext.getString(android.R.string.cancel) : mNegativeButtonText; + mRequestCode = mRequestCode > 0 ? mRequestCode : DEFAULT_SETTINGS_REQ_CODE; + + int intentFlags = 0; + if (mOpenInNewTask) { + intentFlags |= Intent.FLAG_ACTIVITY_NEW_TASK; + } + + return new AppSettingsDialog( + mActivityOrFragment, + mThemeResId, + mRationale, + mTitle, + mPositiveButtonText, + mNegativeButtonText, + mRequestCode, + intentFlags); + } + + } + +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialogHolderActivity.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialogHolderActivity.java new file mode 100644 index 00000000..d996dd7f --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/AppSettingsDialogHolderActivity.java @@ -0,0 +1,65 @@ +package pub.devrel.easypermissions; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import androidx.annotation.RestrictTo; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class AppSettingsDialogHolderActivity extends AppCompatActivity implements DialogInterface.OnClickListener { + private static final int APP_SETTINGS_RC = 7534; + + private AlertDialog mDialog; + private int mIntentFlags; + + public static Intent createShowDialogIntent(Context context, AppSettingsDialog dialog) { + Intent intent = new Intent(context, AppSettingsDialogHolderActivity.class); + intent.putExtra(AppSettingsDialog.EXTRA_APP_SETTINGS, dialog); + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + AppSettingsDialog appSettingsDialog = AppSettingsDialog.fromIntent(getIntent(), this); + mIntentFlags = appSettingsDialog.getIntentFlags(); + mDialog = appSettingsDialog.showDialog(this, this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mDialog != null && mDialog.isShowing()) { + mDialog.dismiss(); + } + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == Dialog.BUTTON_POSITIVE) { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", getPackageName(), null)); + intent.addFlags(mIntentFlags); + startActivityForResult(intent, APP_SETTINGS_RC); + } else if (which == Dialog.BUTTON_NEGATIVE) { + setResult(Activity.RESULT_CANCELED); + finish(); + } else { + throw new IllegalStateException("Unknown button type: " + which); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + setResult(resultCode, data); + finish(); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/EasyPermissions.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/EasyPermissions.java new file mode 100644 index 00000000..dfc20d0e --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/EasyPermissions.java @@ -0,0 +1,358 @@ +/* + * Copyright Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package pub.devrel.easypermissions; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Size; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.core.content.ContextCompat; +import android.util.Log; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import pub.devrel.easypermissions.helper.PermissionHelper; + +/** + * Utility to request and check System permissions for apps targeting Android M (API >= 23). + */ +public class EasyPermissions { + + /** + * Callback interface to receive the results of {@code EasyPermissions.requestPermissions()} + * calls. + */ + public interface PermissionCallbacks extends ActivityCompat.OnRequestPermissionsResultCallback { + + void onPermissionsGranted(int requestCode, @NonNull List perms); + + void onPermissionsDenied(int requestCode, @NonNull List perms); + } + + /** + * Callback interface to receive button clicked events of the rationale dialog + */ + public interface RationaleCallbacks { + void onRationaleAccepted(int requestCode); + + void onRationaleDenied(int requestCode); + } + + private static final String TAG = "EasyPermissions"; + + /** + * Check if the calling context has a set of permissions. + * + * @param context the calling context. + * @param perms one ore more permissions, such as {@link Manifest.permission#CAMERA}. + * @return true if all permissions are already granted, false if at least one permission is not + * yet granted. + * @see Manifest.permission + */ + public static boolean hasPermissions(@NonNull Context context, + @Size(min = 1) @NonNull String... perms) { + // Always return true for SDK < M, let the system deal with the permissions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Log.w(TAG, "hasPermissions: API version < M, returning true by default"); + + // DANGER ZONE!!! Changing this will break the library. + return true; + } + + // Null context may be passed if we have detected Low API (less than M) so getting + // to this point with a null context should not be possible. + if (context == null) { + throw new IllegalArgumentException("Can't check permissions for null context"); + } + + for (String perm : perms) { + if (ContextCompat.checkSelfPermission(context, perm) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + + return true; + } + + /** + * Request a set of permissions, showing a rationale if the system requests it. + * + * @param host requesting context. + * @param rationale a message explaining why the application needs this set of permissions; + * will be displayed if the user rejects the request the first time. + * @param requestCode request code to track this request, must be < 256. + * @param perms a set of permissions to be requested. + * @see Manifest.permission + */ + public static void requestPermissions( + @NonNull Activity host, @NonNull String rationale, + @IntRange(from = 0, to = 255) int requestCode, @Size(min = 1) @NonNull String... perms) { + requestPermissions( + new PermissionRequest.Builder(host, requestCode, perms) + .setRationale(rationale) + .build()); + } + + /** + * Request permissions from a Support Fragment with standard OK/Cancel buttons. + * + * @see #requestPermissions(Activity, String, int, String...) + */ + public static void requestPermissions( + @NonNull Fragment host, @NonNull String rationale, + @IntRange(from = 0, to = 255) int requestCode, @Size(min = 1) @NonNull String... perms) { + requestPermissions( + new PermissionRequest.Builder(host, requestCode, perms) + .setRationale(rationale) + .build()); + } + + /** + * Request a set of permissions. + * + * @param request the permission request + * @see PermissionRequest + */ + public static void requestPermissions(PermissionRequest request) { + + // Check for permissions before dispatching the request + if (hasPermissions(request.getHelper().getContext(), request.getPerms())) { + notifyAlreadyHasPermissions( + request.getHelper().getHost(), request.getRequestCode(), request.getPerms()); + return; + } + + // Request permissions + request.getHelper().requestPermissions( + request.getRationale(), + request.getPositiveButtonText(), + request.getNegativeButtonText(), + request.getTheme(), + request.getRequestCode(), + request.getPerms()); + } + + /** + * Handle the result of a permission request, should be called from the calling {@link + * Activity}'s {@link ActivityCompat.OnRequestPermissionsResultCallback#onRequestPermissionsResult(int, + * String[], int[])} method. + *

+ * If any permissions were granted or denied, the {@code object} will receive the appropriate + * callbacks through {@link PermissionCallbacks} and methods annotated with {@link + * AfterPermissionGranted} will be run if appropriate. + * + * @param requestCode requestCode argument to permission result callback. + * @param permissions permissions argument to permission result callback. + * @param grantResults grantResults argument to permission result callback. + * @param receivers an array of objects that have a method annotated with {@link + * AfterPermissionGranted} or implement {@link PermissionCallbacks}. + */ + public static void onRequestPermissionsResult(@IntRange(from = 0, to = 255) int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults, + @NonNull Object... receivers) { + // Make a collection of granted and denied permissions from the request. + List granted = new ArrayList<>(); + List denied = new ArrayList<>(); + for (int i = 0; i < permissions.length; i++) { + String perm = permissions[i]; + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + granted.add(perm); + } else { + denied.add(perm); + } + } + + // iterate through all receivers + for (Object object : receivers) { + // Report granted permissions, if any. + if (!granted.isEmpty()) { + if (object instanceof PermissionCallbacks) { + ((PermissionCallbacks) object).onPermissionsGranted(requestCode, granted); + } + } + + // Report denied permissions, if any. + if (!denied.isEmpty()) { + if (object instanceof PermissionCallbacks) { + ((PermissionCallbacks) object).onPermissionsDenied(requestCode, denied); + } + } + + // If 100% successful, call annotated methods + if (!granted.isEmpty() && denied.isEmpty()) { + runAnnotatedMethods(object, requestCode); + } + } + } + + /** + * Check if at least one permission in the list of denied permissions has been permanently + * denied (user clicked "Never ask again"). + * + * Note: Due to a limitation in the information provided by the Android + * framework permissions API, this method only works after the permission + * has been denied and your app has received the onPermissionsDenied callback. + * Otherwise the library cannot distinguish permanent denial from the + * "not yet denied" case. + * + * @param host context requesting permissions. + * @param deniedPermissions list of denied permissions, usually from {@link + * PermissionCallbacks#onPermissionsDenied(int, List)} + * @return {@code true} if at least one permission in the list was permanently denied. + */ + public static boolean somePermissionPermanentlyDenied(@NonNull Activity host, + @NonNull List deniedPermissions) { + return PermissionHelper.newInstance(host) + .somePermissionPermanentlyDenied(deniedPermissions); + } + + /** + * @see #somePermissionPermanentlyDenied(Activity, List) + */ + public static boolean somePermissionPermanentlyDenied(@NonNull Fragment host, + @NonNull List deniedPermissions) { + return PermissionHelper.newInstance(host) + .somePermissionPermanentlyDenied(deniedPermissions); + } + + /** + * Check if a permission has been permanently denied (user clicked "Never ask again"). + * + * @param host context requesting permissions. + * @param deniedPermission denied permission. + * @return {@code true} if the permissions has been permanently denied. + */ + public static boolean permissionPermanentlyDenied(@NonNull Activity host, + @NonNull String deniedPermission) { + return PermissionHelper.newInstance(host).permissionPermanentlyDenied(deniedPermission); + } + + /** + * @see #permissionPermanentlyDenied(Activity, String) + */ + public static boolean permissionPermanentlyDenied(@NonNull Fragment host, + @NonNull String deniedPermission) { + return PermissionHelper.newInstance(host).permissionPermanentlyDenied(deniedPermission); + } + + /** + * See if some denied permission has been permanently denied. + * + * @param host requesting context. + * @param perms array of permissions. + * @return true if the user has previously denied any of the {@code perms} and we should show a + * rationale, false otherwise. + */ + public static boolean somePermissionDenied(@NonNull Activity host, + @NonNull String... perms) { + return PermissionHelper.newInstance(host).somePermissionDenied(perms); + } + + /** + * @see #somePermissionDenied(Activity, String...) + */ + public static boolean somePermissionDenied(@NonNull Fragment host, + @NonNull String... perms) { + return PermissionHelper.newInstance(host).somePermissionDenied(perms); + } + + /** + * Run permission callbacks on an object that requested permissions but already has them by + * simulating {@link PackageManager#PERMISSION_GRANTED}. + * + * @param object the object requesting permissions. + * @param requestCode the permission request code. + * @param perms a list of permissions requested. + */ + private static void notifyAlreadyHasPermissions(@NonNull Object object, + int requestCode, + @NonNull String[] perms) { + int[] grantResults = new int[perms.length]; + for (int i = 0; i < perms.length; i++) { + grantResults[i] = PackageManager.PERMISSION_GRANTED; + } + + onRequestPermissionsResult(requestCode, perms, grantResults, object); + } + + /** + * Find all methods annotated with {@link AfterPermissionGranted} on a given object with the + * correct requestCode argument. + * + * @param object the object with annotated methods. + * @param requestCode the requestCode passed to the annotation. + */ + private static void runAnnotatedMethods(@NonNull Object object, int requestCode) { + Class clazz = object.getClass(); + if (isUsingAndroidAnnotations(object)) { + clazz = clazz.getSuperclass(); + } + + while (clazz != null) { + for (Method method : clazz.getDeclaredMethods()) { + AfterPermissionGranted ann = method.getAnnotation(AfterPermissionGranted.class); + if (ann != null) { + // Check for annotated methods with matching request code. + if (ann.value() == requestCode) { + // Method must be void so that we can invoke it + if (method.getParameterTypes().length > 0) { + throw new RuntimeException( + "Cannot execute method " + method.getName() + " because it is non-void method and/or has input parameters."); + } + + try { + // Make method accessible if private + if (!method.isAccessible()) { + method.setAccessible(true); + } + method.invoke(object); + } catch (IllegalAccessException e) { + Log.e(TAG, "runDefaultMethod:IllegalAccessException", e); + } catch (InvocationTargetException e) { + Log.e(TAG, "runDefaultMethod:InvocationTargetException", e); + } + } + } + } + + clazz = clazz.getSuperclass(); + } + } + + /** + * Determine if the project is using the AndroidAnnotations library. + */ + private static boolean isUsingAndroidAnnotations(@NonNull Object object) { + if (!object.getClass().getSimpleName().endsWith("_")) { + return false; + } + try { + Class clazz = Class.forName("org.androidannotations.api.view.HasViews"); + return clazz.isInstance(object); + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/PermissionRequest.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/PermissionRequest.java new file mode 100644 index 00000000..02d2e6d1 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/PermissionRequest.java @@ -0,0 +1,260 @@ +package pub.devrel.easypermissions; + +import android.app.Activity; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.Size; +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; +import androidx.fragment.app.Fragment; + +import java.util.Arrays; + +import pub.devrel.easypermissions.helper.PermissionHelper; + +/** + * An immutable model object that holds all of the parameters associated with a permission request, + * such as the permissions, request code, and rationale. + * + * @see EasyPermissions#requestPermissions(PermissionRequest) + * @see PermissionRequest.Builder + */ +public final class PermissionRequest { + private final PermissionHelper mHelper; + private final String[] mPerms; + private final int mRequestCode; + private final String mRationale; + private final String mPositiveButtonText; + private final String mNegativeButtonText; + private final int mTheme; + + private PermissionRequest(PermissionHelper helper, + String[] perms, + int requestCode, + String rationale, + String positiveButtonText, + String negativeButtonText, + int theme) { + mHelper = helper; + mPerms = perms.clone(); + mRequestCode = requestCode; + mRationale = rationale; + mPositiveButtonText = positiveButtonText; + mNegativeButtonText = negativeButtonText; + mTheme = theme; + } + + @NonNull + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public PermissionHelper getHelper() { + return mHelper; + } + + @NonNull + public String[] getPerms() { + return mPerms.clone(); + } + + public int getRequestCode() { + return mRequestCode; + } + + @NonNull + public String getRationale() { + return mRationale; + } + + @NonNull + public String getPositiveButtonText() { + return mPositiveButtonText; + } + + @NonNull + public String getNegativeButtonText() { + return mNegativeButtonText; + } + + @StyleRes + public int getTheme() { + return mTheme; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PermissionRequest request = (PermissionRequest) o; + + return Arrays.equals(mPerms, request.mPerms) && mRequestCode == request.mRequestCode; + } + + @Override + public int hashCode() { + int result = Arrays.hashCode(mPerms); + result = 31 * result + mRequestCode; + return result; + } + + @Override + public String toString() { + return "PermissionRequest{" + + "mHelper=" + mHelper + + ", mPerms=" + Arrays.toString(mPerms) + + ", mRequestCode=" + mRequestCode + + ", mRationale='" + mRationale + '\'' + + ", mPositiveButtonText='" + mPositiveButtonText + '\'' + + ", mNegativeButtonText='" + mNegativeButtonText + '\'' + + ", mTheme=" + mTheme + + '}'; + } + + /** + * Builder to build a permission request with variable options. + * + * @see PermissionRequest + */ + public static final class Builder { + private final PermissionHelper mHelper; + private final int mRequestCode; + private final String[] mPerms; + + private String mRationale; + private String mPositiveButtonText; + private String mNegativeButtonText; + private int mTheme = -1; + + /** + * Construct a new permission request builder with a host, request code, and the requested + * permissions. + * + * @param activity the permission request host + * @param requestCode request code to track this request; must be < 256 + * @param perms the set of permissions to be requested + */ + public Builder(@NonNull Activity activity, int requestCode, + @NonNull @Size(min = 1) String... perms) { + mHelper = PermissionHelper.newInstance(activity); + mRequestCode = requestCode; + mPerms = perms; + } + + /** + * @see #Builder(Activity, int, String...) + */ + public Builder(@NonNull Fragment fragment, int requestCode, + @NonNull @Size(min = 1) String... perms) { + mHelper = PermissionHelper.newInstance(fragment); + mRequestCode = requestCode; + mPerms = perms; + } + + /** + * Set the rationale to display to the user if they don't allow your permissions on the + * first try. This rationale will be shown as long as the user has denied your permissions + * at least once, but has not yet permanently denied your permissions. Should the user + * permanently deny your permissions, use the {@link AppSettingsDialog} instead. + *

+ * The default rationale text is {@link R.string#rationale_ask}. + * + * @param rationale the rationale to be displayed to the user should they deny your + * permission at least once + */ + @NonNull + public Builder setRationale(@Nullable String rationale) { + mRationale = rationale; + return this; + } + + /** + * @param resId the string resource to be used as a rationale + * @see #setRationale(String) + */ + @NonNull + public Builder setRationale(@StringRes int resId) { + mRationale = mHelper.getContext().getString(resId); + return this; + } + + /** + * Set the positive button text for the rationale dialog should it be shown. + *

+ * The default is {@link android.R.string#ok} + */ + @NonNull + public Builder setPositiveButtonText(@Nullable String positiveButtonText) { + mPositiveButtonText = positiveButtonText; + return this; + } + + /** + * @see #setPositiveButtonText(String) + */ + @NonNull + public Builder setPositiveButtonText(@StringRes int resId) { + mPositiveButtonText = mHelper.getContext().getString(resId); + return this; + } + + /** + * Set the negative button text for the rationale dialog should it be shown. + *

+ * The default is {@link android.R.string#cancel} + */ + @NonNull + public Builder setNegativeButtonText(@Nullable String negativeButtonText) { + mNegativeButtonText = negativeButtonText; + return this; + } + + /** + * @see #setNegativeButtonText(String) + */ + @NonNull + public Builder setNegativeButtonText(@StringRes int resId) { + mNegativeButtonText = mHelper.getContext().getString(resId); + return this; + } + + /** + * Set the theme to be used for the rationale dialog should it be shown. + * + * @param theme a style resource + */ + @NonNull + public Builder setTheme(@StyleRes int theme) { + mTheme = theme; + return this; + } + + /** + * Build the permission request. + * + * @return the permission request + * @see EasyPermissions#requestPermissions(PermissionRequest) + * @see PermissionRequest + */ + @NonNull + public PermissionRequest build() { + if (mRationale == null) { + mRationale = mHelper.getContext().getString(R.string.rationale_ask); + } + if (mPositiveButtonText == null) { + mPositiveButtonText = mHelper.getContext().getString(android.R.string.ok); + } + if (mNegativeButtonText == null) { + mNegativeButtonText = mHelper.getContext().getString(android.R.string.cancel); + } + + return new PermissionRequest( + mHelper, + mPerms, + mRequestCode, + mRationale, + mPositiveButtonText, + mNegativeButtonText, + mTheme); + } + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogClickListener.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogClickListener.java new file mode 100644 index 00000000..d07afffa --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogClickListener.java @@ -0,0 +1,77 @@ +package pub.devrel.easypermissions; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import androidx.fragment.app.Fragment; + +import java.util.Arrays; + +import pub.devrel.easypermissions.helper.PermissionHelper; + +/** + * Click listener for either {@link RationaleDialogFragment} or {@link RationaleDialogFragmentCompat}. + */ +class RationaleDialogClickListener implements Dialog.OnClickListener { + + private Object mHost; + private RationaleDialogConfig mConfig; + private EasyPermissions.PermissionCallbacks mCallbacks; + private EasyPermissions.RationaleCallbacks mRationaleCallbacks; + + RationaleDialogClickListener(RationaleDialogFragmentCompat compatDialogFragment, + RationaleDialogConfig config, + EasyPermissions.PermissionCallbacks callbacks, + EasyPermissions.RationaleCallbacks rationaleCallbacks) { + + mHost = compatDialogFragment.getParentFragment() != null + ? compatDialogFragment.getParentFragment() + : compatDialogFragment.getActivity(); + + mConfig = config; + mCallbacks = callbacks; + mRationaleCallbacks = rationaleCallbacks; + + } + + RationaleDialogClickListener(RationaleDialogFragment dialogFragment, + RationaleDialogConfig config, + EasyPermissions.PermissionCallbacks callbacks, + EasyPermissions.RationaleCallbacks dialogCallback) { + + mHost = dialogFragment.getActivity(); + + mConfig = config; + mCallbacks = callbacks; + mRationaleCallbacks = dialogCallback; + } + + @Override + public void onClick(DialogInterface dialog, int which) { + int requestCode = mConfig.requestCode; + if (which == Dialog.BUTTON_POSITIVE) { + String[] permissions = mConfig.permissions; + if (mRationaleCallbacks != null) { + mRationaleCallbacks.onRationaleAccepted(requestCode); + } + if (mHost instanceof Fragment) { + PermissionHelper.newInstance((Fragment) mHost).directRequestPermissions(requestCode, permissions); + } else if (mHost instanceof Activity) { + PermissionHelper.newInstance((Activity) mHost).directRequestPermissions(requestCode, permissions); + } else { + throw new RuntimeException("Host must be an Activity or Fragment!"); + } + } else { + if (mRationaleCallbacks != null) { + mRationaleCallbacks.onRationaleDenied(requestCode); + } + notifyPermissionDenied(); + } + } + + private void notifyPermissionDenied() { + if (mCallbacks != null) { + mCallbacks.onPermissionsDenied(mConfig.requestCode, Arrays.asList(mConfig.permissions)); + } + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogConfig.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogConfig.java new file mode 100644 index 00000000..c64999c6 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogConfig.java @@ -0,0 +1,95 @@ +package pub.devrel.easypermissions; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AlertDialog; + +/** + * Configuration for either {@link RationaleDialogFragment} or {@link RationaleDialogFragmentCompat}. + */ +class RationaleDialogConfig { + + private static final String KEY_POSITIVE_BUTTON = "positiveButton"; + private static final String KEY_NEGATIVE_BUTTON = "negativeButton"; + private static final String KEY_RATIONALE_MESSAGE = "rationaleMsg"; + private static final String KEY_THEME = "theme"; + private static final String KEY_REQUEST_CODE = "requestCode"; + private static final String KEY_PERMISSIONS = "permissions"; + + String positiveButton; + String negativeButton; + int theme; + int requestCode; + String rationaleMsg; + String[] permissions; + + RationaleDialogConfig(@NonNull String positiveButton, + @NonNull String negativeButton, + @NonNull String rationaleMsg, + @StyleRes int theme, + int requestCode, + @NonNull String[] permissions) { + + this.positiveButton = positiveButton; + this.negativeButton = negativeButton; + this.rationaleMsg = rationaleMsg; + this.theme = theme; + this.requestCode = requestCode; + this.permissions = permissions; + } + + RationaleDialogConfig(Bundle bundle) { + positiveButton = bundle.getString(KEY_POSITIVE_BUTTON); + negativeButton = bundle.getString(KEY_NEGATIVE_BUTTON); + rationaleMsg = bundle.getString(KEY_RATIONALE_MESSAGE); + theme = bundle.getInt(KEY_THEME); + requestCode = bundle.getInt(KEY_REQUEST_CODE); + permissions = bundle.getStringArray(KEY_PERMISSIONS); + } + + Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putString(KEY_POSITIVE_BUTTON, positiveButton); + bundle.putString(KEY_NEGATIVE_BUTTON, negativeButton); + bundle.putString(KEY_RATIONALE_MESSAGE, rationaleMsg); + bundle.putInt(KEY_THEME, theme); + bundle.putInt(KEY_REQUEST_CODE, requestCode); + bundle.putStringArray(KEY_PERMISSIONS, permissions); + + return bundle; + } + + AlertDialog createSupportDialog(Context context, Dialog.OnClickListener listener) { + AlertDialog.Builder builder; + if (theme > 0) { + builder = new AlertDialog.Builder(context, theme); + } else { + builder = new AlertDialog.Builder(context); + } + return builder + .setCancelable(false) + .setPositiveButton(positiveButton, listener) + .setNegativeButton(negativeButton, listener) + .setMessage(rationaleMsg) + .create(); + } + + android.app.AlertDialog createFrameworkDialog(Context context, Dialog.OnClickListener listener) { + android.app.AlertDialog.Builder builder; + if (theme > 0) { + builder = new android.app.AlertDialog.Builder(context, theme); + } else { + builder = new android.app.AlertDialog.Builder(context); + } + return builder + .setCancelable(false) + .setPositiveButton(positiveButton, listener) + .setNegativeButton(negativeButton, listener) + .setMessage(rationaleMsg) + .create(); + } + +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragment.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragment.java new file mode 100644 index 00000000..c055a4f7 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragment.java @@ -0,0 +1,113 @@ +package pub.devrel.easypermissions; + +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.FragmentManager; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.annotation.StyleRes; + +/** + * {@link DialogFragment} to display rationale for permission requests when the request comes from + * a Fragment or Activity that can host a Fragment. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class RationaleDialogFragment extends DialogFragment { + + public static final String TAG = "RationaleDialogFragment"; + + private EasyPermissions.PermissionCallbacks mPermissionCallbacks; + private EasyPermissions.RationaleCallbacks mRationaleCallbacks; + private boolean mStateSaved = false; + + public static RationaleDialogFragment newInstance( + @NonNull String positiveButton, + @NonNull String negativeButton, + @NonNull String rationaleMsg, + @StyleRes int theme, + int requestCode, + @NonNull String[] permissions) { + + // Create new Fragment + RationaleDialogFragment dialogFragment = new RationaleDialogFragment(); + + // Initialize configuration as arguments + RationaleDialogConfig config = new RationaleDialogConfig( + positiveButton, negativeButton, rationaleMsg, theme, requestCode, permissions); + dialogFragment.setArguments(config.toBundle()); + + return dialogFragment; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && getParentFragment() != null) { + if (getParentFragment() instanceof EasyPermissions.PermissionCallbacks) { + mPermissionCallbacks = (EasyPermissions.PermissionCallbacks) getParentFragment(); + } + if (getParentFragment() instanceof EasyPermissions.RationaleCallbacks){ + mRationaleCallbacks = (EasyPermissions.RationaleCallbacks) getParentFragment(); + } + + } + + if (context instanceof EasyPermissions.PermissionCallbacks) { + mPermissionCallbacks = (EasyPermissions.PermissionCallbacks) context; + } + + if (context instanceof EasyPermissions.RationaleCallbacks) { + mRationaleCallbacks = (EasyPermissions.RationaleCallbacks) context; + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + mStateSaved = true; + super.onSaveInstanceState(outState); + } + + /** + * Version of {@link #show(FragmentManager, String)} that no-ops when an IllegalStateException + * would otherwise occur. + */ + public void showAllowingStateLoss(FragmentManager manager, String tag) { + // API 26 added this convenient method + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (manager.isStateSaved()) { + return; + } + } + + if (mStateSaved) { + return; + } + + show(manager, tag); + } + + @Override + public void onDetach() { + super.onDetach(); + mPermissionCallbacks = null; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // Rationale dialog should not be cancelable + setCancelable(false); + + // Get config from arguments, create click listener + RationaleDialogConfig config = new RationaleDialogConfig(getArguments()); + RationaleDialogClickListener clickListener = + new RationaleDialogClickListener(this, config, mPermissionCallbacks, mRationaleCallbacks); + + // Create an AlertDialog + return config.createFrameworkDialog(getActivity(), clickListener); + } + +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragmentCompat.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragmentCompat.java new file mode 100644 index 00000000..5a9306f4 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/RationaleDialogFragmentCompat.java @@ -0,0 +1,97 @@ +package pub.devrel.easypermissions; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; +import androidx.annotation.StyleRes; +import androidx.fragment.app.FragmentManager; +import androidx.appcompat.app.AppCompatDialogFragment; + +/** + * {@link AppCompatDialogFragment} to display rationale for permission requests when the request + * comes from a Fragment or Activity that can host a Fragment. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class RationaleDialogFragmentCompat extends AppCompatDialogFragment { + + public static final String TAG = "RationaleDialogFragmentCompat"; + + private EasyPermissions.PermissionCallbacks mPermissionCallbacks; + private EasyPermissions.RationaleCallbacks mRationaleCallbacks; + + public static RationaleDialogFragmentCompat newInstance( + @NonNull String rationaleMsg, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String[] permissions) { + + // Create new Fragment + RationaleDialogFragmentCompat dialogFragment = new RationaleDialogFragmentCompat(); + + // Initialize configuration as arguments + RationaleDialogConfig config = new RationaleDialogConfig( + positiveButton, negativeButton, rationaleMsg, theme, requestCode, permissions); + dialogFragment.setArguments(config.toBundle()); + + return dialogFragment; + } + + /** + * Version of {@link #show(FragmentManager, String)} that no-ops when an IllegalStateException + * would otherwise occur. + */ + public void showAllowingStateLoss(FragmentManager manager, String tag) { + if (manager.isStateSaved()) { + return; + } + + show(manager, tag); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (getParentFragment() != null) { + if (getParentFragment() instanceof EasyPermissions.PermissionCallbacks) { + mPermissionCallbacks = (EasyPermissions.PermissionCallbacks) getParentFragment(); + } + if (getParentFragment() instanceof EasyPermissions.RationaleCallbacks){ + mRationaleCallbacks = (EasyPermissions.RationaleCallbacks) getParentFragment(); + } + } + + if (context instanceof EasyPermissions.PermissionCallbacks) { + mPermissionCallbacks = (EasyPermissions.PermissionCallbacks) context; + } + + if (context instanceof EasyPermissions.RationaleCallbacks) { + mRationaleCallbacks = (EasyPermissions.RationaleCallbacks) context; + } + } + + @Override + public void onDetach() { + super.onDetach(); + mPermissionCallbacks = null; + mRationaleCallbacks = null; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // Rationale dialog should not be cancelable + setCancelable(false); + + // Get config from arguments, create click listener + RationaleDialogConfig config = new RationaleDialogConfig(getArguments()); + RationaleDialogClickListener clickListener = + new RationaleDialogClickListener(this, config, mPermissionCallbacks, mRationaleCallbacks); + + // Create an AlertDialog + return config.createSupportDialog(getContext(), clickListener); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/ActivityPermissionHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/ActivityPermissionHelper.java new file mode 100644 index 00000000..74aa4228 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/ActivityPermissionHelper.java @@ -0,0 +1,59 @@ +package pub.devrel.easypermissions.helper; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.core.app.ActivityCompat; +import android.util.Log; + +import pub.devrel.easypermissions.RationaleDialogFragment; + +/** + * Permissions helper for {@link Activity}. + */ +class ActivityPermissionHelper extends PermissionHelper { + private static final String TAG = "ActPermissionHelper"; + + public ActivityPermissionHelper(Activity host) { + super(host); + } + + @Override + public void directRequestPermissions(int requestCode, @NonNull String... perms) { + ActivityCompat.requestPermissions(getHost(), perms, requestCode); + } + + @Override + public boolean shouldShowRequestPermissionRationale(@NonNull String perm) { + return ActivityCompat.shouldShowRequestPermissionRationale(getHost(), perm); + } + + @Override + public Context getContext() { + return getHost(); + } + + @Override + public void showRequestPermissionRationale(@NonNull String rationale, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String... perms) { + FragmentManager fm = getHost().getFragmentManager(); + + // Check if fragment is already showing + Fragment fragment = fm.findFragmentByTag(RationaleDialogFragment.TAG); + if (fragment instanceof RationaleDialogFragment) { + Log.d(TAG, "Found existing fragment, not showing rationale."); + return; + } + + RationaleDialogFragment + .newInstance(positiveButton, negativeButton, rationale, theme, requestCode, perms) + .showAllowingStateLoss(fm, RationaleDialogFragment.TAG); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/AppCompatActivityPermissionsHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/AppCompatActivityPermissionsHelper.java new file mode 100644 index 00000000..0bc80a59 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/AppCompatActivityPermissionsHelper.java @@ -0,0 +1,37 @@ +package pub.devrel.easypermissions.helper; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.FragmentManager; +import androidx.appcompat.app.AppCompatActivity; + +/** + * Permissions helper for {@link AppCompatActivity}. + */ +class AppCompatActivityPermissionsHelper extends BaseSupportPermissionsHelper { + + public AppCompatActivityPermissionsHelper(AppCompatActivity host) { + super(host); + } + + @Override + public FragmentManager getSupportFragmentManager() { + return getHost().getSupportFragmentManager(); + } + + @Override + public void directRequestPermissions(int requestCode, @NonNull String... perms) { + ActivityCompat.requestPermissions(getHost(), perms, requestCode); + } + + @Override + public boolean shouldShowRequestPermissionRationale(@NonNull String perm) { + return ActivityCompat.shouldShowRequestPermissionRationale(getHost(), perm); + } + + @Override + public Context getContext() { + return getHost(); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/BaseSupportPermissionsHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/BaseSupportPermissionsHelper.java new file mode 100644 index 00000000..185da5a2 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/BaseSupportPermissionsHelper.java @@ -0,0 +1,45 @@ +package pub.devrel.easypermissions.helper; + +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import android.util.Log; + +import pub.devrel.easypermissions.RationaleDialogFragmentCompat; + +/** + * Implementation of {@link PermissionHelper} for Support Library host classes. + */ +public abstract class BaseSupportPermissionsHelper extends PermissionHelper { + + private static final String TAG = "BSPermissionsHelper"; + + public BaseSupportPermissionsHelper(@NonNull T host) { + super(host); + } + + public abstract FragmentManager getSupportFragmentManager(); + + @Override + public void showRequestPermissionRationale(@NonNull String rationale, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String... perms) { + + FragmentManager fm = getSupportFragmentManager(); + + // Check if fragment is already showing + Fragment fragment = fm.findFragmentByTag(RationaleDialogFragmentCompat.TAG); + if (fragment instanceof RationaleDialogFragmentCompat) { + Log.d(TAG, "Found existing fragment, not showing rationale."); + return; + } + + RationaleDialogFragmentCompat + .newInstance(rationale, positiveButton, negativeButton, theme, requestCode, perms) + .showAllowingStateLoss(fm, RationaleDialogFragmentCompat.TAG); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/LowApiPermissionsHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/LowApiPermissionsHelper.java new file mode 100644 index 00000000..6ec74376 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/LowApiPermissionsHelper.java @@ -0,0 +1,47 @@ +package pub.devrel.easypermissions.helper; + +import android.app.Activity; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.fragment.app.Fragment; + +/** + * Permissions helper for apps built against API < 23, which do not need runtime permissions. + */ +class LowApiPermissionsHelper extends PermissionHelper { + public LowApiPermissionsHelper(@NonNull T host) { + super(host); + } + + @Override + public void directRequestPermissions(int requestCode, @NonNull String... perms) { + throw new IllegalStateException("Should never be requesting permissions on API < 23!"); + } + + @Override + public boolean shouldShowRequestPermissionRationale(@NonNull String perm) { + return false; + } + + @Override + public void showRequestPermissionRationale(@NonNull String rationale, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String... perms) { + throw new IllegalStateException("Should never be requesting permissions on API < 23!"); + } + + @Override + public Context getContext() { + if (getHost() instanceof Activity) { + return (Context) getHost(); + } else if (getHost() instanceof Fragment) { + return ((Fragment) getHost()).getContext(); + } else { + throw new IllegalStateException("Unknown host: " + getHost()); + } + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/PermissionHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/PermissionHelper.java new file mode 100644 index 00000000..db95a350 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/PermissionHelper.java @@ -0,0 +1,113 @@ +package pub.devrel.easypermissions.helper; + +import android.app.Activity; +import android.content.Context; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.StyleRes; +import androidx.fragment.app.Fragment; +import androidx.appcompat.app.AppCompatActivity; + +import java.util.List; + +/** + * Delegate class to make permission calls based on the 'host' (Fragment, Activity, etc). + */ +public abstract class PermissionHelper { + + private T mHost; + + @NonNull + public static PermissionHelper newInstance(Activity host) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return new LowApiPermissionsHelper<>(host); + } + + if (host instanceof AppCompatActivity) + return new AppCompatActivityPermissionsHelper((AppCompatActivity) host); + else { + return new ActivityPermissionHelper(host); + } + } + + @NonNull + public static PermissionHelper newInstance(Fragment host) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return new LowApiPermissionsHelper<>(host); + } + + return new SupportFragmentPermissionHelper(host); + } + + // ============================================================================ + // Public concrete methods + // ============================================================================ + + public PermissionHelper(@NonNull T host) { + mHost = host; + } + + private boolean shouldShowRationale(@NonNull String... perms) { + for (String perm : perms) { + if (shouldShowRequestPermissionRationale(perm)) { + return true; + } + } + return false; + } + + public void requestPermissions(@NonNull String rationale, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String... perms) { + if (shouldShowRationale(perms)) { + showRequestPermissionRationale( + rationale, positiveButton, negativeButton, theme, requestCode, perms); + } else { + directRequestPermissions(requestCode, perms); + } + } + + public boolean somePermissionPermanentlyDenied(@NonNull List perms) { + for (String deniedPermission : perms) { + if (permissionPermanentlyDenied(deniedPermission)) { + return true; + } + } + + return false; + } + + public boolean permissionPermanentlyDenied(@NonNull String perms) { + return !shouldShowRequestPermissionRationale(perms); + } + + public boolean somePermissionDenied(@NonNull String... perms) { + return shouldShowRationale(perms); + } + + @NonNull + public T getHost() { + return mHost; + } + + // ============================================================================ + // Public abstract methods + // ============================================================================ + + public abstract void directRequestPermissions(int requestCode, @NonNull String... perms); + + public abstract boolean shouldShowRequestPermissionRationale(@NonNull String perm); + + public abstract void showRequestPermissionRationale(@NonNull String rationale, + @NonNull String positiveButton, + @NonNull String negativeButton, + @StyleRes int theme, + int requestCode, + @NonNull String... perms); + + public abstract Context getContext(); + +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/SupportFragmentPermissionHelper.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/SupportFragmentPermissionHelper.java new file mode 100644 index 00000000..cf72eeb3 --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/SupportFragmentPermissionHelper.java @@ -0,0 +1,36 @@ +package pub.devrel.easypermissions.helper; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +/** + * Permissions helper for {@link Fragment} from the support library. + */ +class SupportFragmentPermissionHelper extends BaseSupportPermissionsHelper { + + public SupportFragmentPermissionHelper(@NonNull Fragment host) { + super(host); + } + + @Override + public FragmentManager getSupportFragmentManager() { + return getHost().getChildFragmentManager(); + } + + @Override + public void directRequestPermissions(int requestCode, @NonNull String... perms) { + getHost().requestPermissions(perms, requestCode); + } + + @Override + public boolean shouldShowRequestPermissionRationale(@NonNull String perm) { + return getHost().shouldShowRequestPermissionRationale(perm); + } + + @Override + public Context getContext() { + return getHost().getActivity(); + } +} diff --git a/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/package-info.java b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/package-info.java new file mode 100644 index 00000000..61617bce --- /dev/null +++ b/android/easypermissions/src/main/java/pub/devrel/easypermissions/helper/package-info.java @@ -0,0 +1,4 @@ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +package pub.devrel.easypermissions.helper; + +import androidx.annotation.RestrictTo; diff --git a/android/easypermissions/src/main/res/values/colors.xml b/android/easypermissions/src/main/res/values/colors.xml new file mode 100644 index 00000000..2ca30829 --- /dev/null +++ b/android/easypermissions/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #ff212121 + @android:color/black + #ff80cbc4 + diff --git a/android/easypermissions/src/main/res/values/strings.xml b/android/easypermissions/src/main/res/values/strings.xml new file mode 100644 index 00000000..2ceab867 --- /dev/null +++ b/android/easypermissions/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + This app may not work correctly without the requested permissions. + + This app may not work correctly without the requested permissions. + Open the app settings screen to modify app permissions. + + Permissions Required + diff --git a/android/easypermissions/src/main/res/values/styles.xml b/android/easypermissions/src/main/res/values/styles.xml new file mode 100644 index 00000000..91239929 --- /dev/null +++ b/android/easypermissions/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/AppSettingsDialogTest.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/AppSettingsDialogTest.java new file mode 100644 index 00000000..2b353846 --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/AppSettingsDialogTest.java @@ -0,0 +1,186 @@ +package pub.devrel.easypermissions; + +import android.app.Application; +import android.content.DialogInterface; +import android.content.Intent; +import android.widget.Button; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowApplication; +import org.robolectric.shadows.ShadowIntent; + +import java.util.Objects; + +import androidx.appcompat.app.AlertDialog; +import androidx.test.core.app.ApplicationProvider; +import pub.devrel.easypermissions.testhelper.ActivityController; +import pub.devrel.easypermissions.testhelper.FragmentController; +import pub.devrel.easypermissions.testhelper.TestActivity; +import pub.devrel.easypermissions.testhelper.TestFragment; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; +import static pub.devrel.easypermissions.AppSettingsDialog.DEFAULT_SETTINGS_REQ_CODE; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 23) +public class AppSettingsDialogTest { + + private static final String TITLE = "TITLE"; + private static final String RATIONALE = "RATIONALE"; + private static final String NEGATIVE = "NEGATIVE"; + private static final String POSITIVE = "POSITIVE"; + private ShadowApplication shadowApp; + private TestActivity spyActivity; + private TestFragment spyFragment; + private FragmentController fragmentController; + private ActivityController activityController; + @Mock + private DialogInterface.OnClickListener positiveListener; + @Mock + private DialogInterface.OnClickListener negativeListener; + @Captor + private ArgumentCaptor integerCaptor; + @Captor + private ArgumentCaptor intentCaptor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + shadowApp = shadowOf((Application) ApplicationProvider.getApplicationContext()); + + activityController = new ActivityController<>(TestActivity.class); + fragmentController = new FragmentController<>(TestFragment.class); + + spyActivity = Mockito.spy(activityController.resume()); + spyFragment = Mockito.spy(fragmentController.resume()); + } + + // ------ From Activity ------ + + @Test + public void shouldShowExpectedSettingsDialog_whenBuildingFromActivity() { + new AppSettingsDialog.Builder(spyActivity) + .setTitle(android.R.string.dialog_alert_title) + .setRationale(android.R.string.unknownName) + .setPositiveButton(android.R.string.ok) + .setNegativeButton(android.R.string.cancel) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .show(); + + verify(spyActivity, times(1)) + .startActivityForResult(intentCaptor.capture(), integerCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(DEFAULT_SETTINGS_REQ_CODE); + assertThat(Objects.requireNonNull(intentCaptor.getValue().getComponent()).getClassName()) + .isEqualTo(AppSettingsDialogHolderActivity.class.getName()); + + Intent startedIntent = shadowApp.getNextStartedActivity(); + ShadowIntent shadowIntent = shadowOf(startedIntent); + assertThat(shadowIntent.getIntentClass()).isEqualTo(AppSettingsDialogHolderActivity.class); + } + + @Test + public void shouldPositiveListener_whenClickingPositiveButtonFromActivity() { + AlertDialog alertDialog = new AppSettingsDialog.Builder(spyActivity) + .setTitle(TITLE) + .setRationale(RATIONALE) + .setPositiveButton(POSITIVE) + .setNegativeButton(NEGATIVE) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .showDialog(positiveListener, negativeListener); + Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); + positive.performClick(); + + verify(positiveListener, times(1)) + .onClick(any(DialogInterface.class), anyInt()); + } + + @Test + public void shouldNegativeListener_whenClickingPositiveButtonFromActivity() { + AlertDialog alertDialog = new AppSettingsDialog.Builder(spyActivity) + .setTitle(TITLE) + .setRationale(RATIONALE) + .setPositiveButton(POSITIVE) + .setNegativeButton(NEGATIVE) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .showDialog(positiveListener, negativeListener); + Button positive = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE); + positive.performClick(); + + verify(negativeListener, times(1)) + .onClick(any(DialogInterface.class), anyInt()); + } + + @Test + public void shouldShowExpectedSettingsDialog_whenBuildingFromSupportFragment() { + new AppSettingsDialog.Builder(spyFragment) + .setTitle(android.R.string.dialog_alert_title) + .setRationale(android.R.string.unknownName) + .setPositiveButton(android.R.string.ok) + .setNegativeButton(android.R.string.cancel) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .show(); + + verify(spyFragment, times(1)) + .startActivityForResult(intentCaptor.capture(), integerCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(DEFAULT_SETTINGS_REQ_CODE); + assertThat(Objects.requireNonNull(intentCaptor.getValue().getComponent()).getClassName()) + .isEqualTo(AppSettingsDialogHolderActivity.class.getName()); + + Intent startedIntent = shadowApp.getNextStartedActivity(); + ShadowIntent shadowIntent = shadowOf(startedIntent); + assertThat(shadowIntent.getIntentClass()).isEqualTo(AppSettingsDialogHolderActivity.class); + } + + @Test + public void shouldPositiveListener_whenClickingPositiveButtonFromSupportFragment() { + AlertDialog alertDialog = new AppSettingsDialog.Builder(spyFragment) + .setTitle(TITLE) + .setRationale(RATIONALE) + .setPositiveButton(POSITIVE) + .setNegativeButton(NEGATIVE) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .showDialog(positiveListener, negativeListener); + Button positive = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); + positive.performClick(); + + verify(positiveListener, times(1)) + .onClick(any(DialogInterface.class), anyInt()); + } + + @Test + public void shouldNegativeListener_whenClickingPositiveButtonFromSupportFragment() { + AlertDialog alertDialog = new AppSettingsDialog.Builder(spyFragment) + .setTitle(TITLE) + .setRationale(RATIONALE) + .setPositiveButton(POSITIVE) + .setNegativeButton(NEGATIVE) + .setThemeResId(R.style.Theme_AppCompat) + .build() + .showDialog(positiveListener, negativeListener); + Button positive = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE); + positive.performClick(); + + verify(negativeListener, times(1)) + .onClick(any(DialogInterface.class), anyInt()); + } + +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsLowApiTest.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsLowApiTest.java new file mode 100644 index 00000000..a6c6976b --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsLowApiTest.java @@ -0,0 +1,121 @@ +package pub.devrel.easypermissions; + +import android.Manifest; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; + +import androidx.test.core.app.ApplicationProvider; +import pub.devrel.easypermissions.testhelper.ActivityController; +import pub.devrel.easypermissions.testhelper.FragmentController; +import pub.devrel.easypermissions.testhelper.TestActivity; +import pub.devrel.easypermissions.testhelper.TestAppCompatActivity; +import pub.devrel.easypermissions.testhelper.TestFragment; +import pub.devrel.easypermissions.testhelper.TestSupportFragmentActivity; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Low-API (SDK = 19) tests for {@link pub.devrel.easypermissions.EasyPermissions}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 19) +public class EasyPermissionsLowApiTest { + + private static final String RATIONALE = "RATIONALE"; + private static final String[] ALL_PERMS = new String[]{ + Manifest.permission.READ_SMS, Manifest.permission.ACCESS_FINE_LOCATION}; + + private TestActivity spyActivity; + private TestSupportFragmentActivity spySupportFragmentActivity; + private TestAppCompatActivity spyAppCompatActivity; + private TestFragment spyFragment; + private FragmentController fragmentController; + private ActivityController activityController; + private ActivityController supportFragmentActivityController; + private ActivityController appCompatActivityController; + @Captor + private ArgumentCaptor integerCaptor; + @Captor + private ArgumentCaptor> listCaptor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + activityController = new ActivityController<>(TestActivity.class); + supportFragmentActivityController = new ActivityController<>(TestSupportFragmentActivity.class); + appCompatActivityController = new ActivityController<>(TestAppCompatActivity.class); + fragmentController = new FragmentController<>(TestFragment.class); + + spyActivity = Mockito.spy(activityController.resume()); + spySupportFragmentActivity = Mockito.spy(supportFragmentActivityController.resume()); + spyAppCompatActivity = Mockito.spy(appCompatActivityController.resume()); + spyFragment = Mockito.spy(fragmentController.resume()); + } + + // ------ General tests ------ + + @Test + public void shouldHavePermission_whenHasPermissionsBeforeMarshmallow() { + assertThat(EasyPermissions.hasPermissions(ApplicationProvider.getApplicationContext(), + Manifest.permission.ACCESS_COARSE_LOCATION)).isTrue(); + } + + // ------ From Activity ------ + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestFromActivity() { + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + // ------ From Support Activity ------ + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestFromSupportFragmentActivity() { + EasyPermissions.requestPermissions(spySupportFragmentActivity, RATIONALE, TestSupportFragmentActivity.REQUEST_CODE, ALL_PERMS); + + verify(spySupportFragmentActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestSupportFragmentActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestFromAppCompatActivity() { + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyAppCompatActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestAppCompatActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestFromFragment() { + EasyPermissions.requestPermissions(spyFragment, RATIONALE, TestFragment.REQUEST_CODE, ALL_PERMS); + + verify(spyFragment, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestFragment.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsTest.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsTest.java new file mode 100644 index 00000000..06ce723c --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/EasyPermissionsTest.java @@ -0,0 +1,611 @@ +package pub.devrel.easypermissions; + +import android.Manifest; +import android.app.Application; +import android.app.Dialog; +import android.app.Fragment; +import android.content.pm.PackageManager; +import android.widget.TextView; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowApplication; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +import androidx.test.core.app.ApplicationProvider; +import pub.devrel.easypermissions.testhelper.ActivityController; +import pub.devrel.easypermissions.testhelper.FragmentController; +import pub.devrel.easypermissions.testhelper.TestActivity; +import pub.devrel.easypermissions.testhelper.TestAppCompatActivity; +import pub.devrel.easypermissions.testhelper.TestFragment; +import pub.devrel.easypermissions.testhelper.TestSupportFragmentActivity; + +import static com.google.common.truth.Truth.assertThat; +import static junit.framework.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +/** + * Basic Robolectric tests for {@link pub.devrel.easypermissions.EasyPermissions}. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 23) +public class EasyPermissionsTest { + + private static final String RATIONALE = "RATIONALE"; + private static final String POSITIVE = "POSITIVE"; + private static final String NEGATIVE = "NEGATIVE"; + private static final String[] ONE_PERM = new String[]{Manifest.permission.READ_SMS}; + private static final String[] ALL_PERMS = new String[]{ + Manifest.permission.READ_SMS, Manifest.permission.ACCESS_FINE_LOCATION}; + private static final int[] SMS_DENIED_RESULT = new int[]{ + PackageManager.PERMISSION_DENIED, PackageManager.PERMISSION_GRANTED}; + + private ShadowApplication shadowApp; + private Application app; + private TestActivity spyActivity; + private TestSupportFragmentActivity spySupportFragmentActivity; + private TestAppCompatActivity spyAppCompatActivity; + private TestFragment spyFragment; + private FragmentController fragmentController; + private ActivityController activityController; + private ActivityController supportFragmentActivityController; + private ActivityController appCompatActivityController; + @Captor + private ArgumentCaptor integerCaptor; + @Captor + private ArgumentCaptor> listCaptor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + app = ApplicationProvider.getApplicationContext(); + shadowApp = shadowOf(app); + + activityController = new ActivityController<>(TestActivity.class); + supportFragmentActivityController = new ActivityController<>(TestSupportFragmentActivity.class); + appCompatActivityController = new ActivityController<>(TestAppCompatActivity.class); + fragmentController = new FragmentController<>(TestFragment.class); + + spyActivity = Mockito.spy(activityController.resume()); + spySupportFragmentActivity = Mockito.spy(supportFragmentActivityController.resume()); + spyAppCompatActivity = Mockito.spy(appCompatActivityController.resume()); + spyFragment = Mockito.spy(fragmentController.resume()); + } + + // ------ General tests ------ + + @Test + public void shouldNotHavePermissions_whenNoPermissionsGranted() { + assertThat(EasyPermissions.hasPermissions(app, ALL_PERMS)).isFalse(); + } + + @Test + public void shouldNotHavePermissions_whenNotAllPermissionsGranted() { + shadowApp.grantPermissions(ONE_PERM); + assertThat(EasyPermissions.hasPermissions(app, ALL_PERMS)).isFalse(); + } + + @Test + public void shouldHavePermissions_whenAllPermissionsGranted() { + shadowApp.grantPermissions(ALL_PERMS); + assertThat(EasyPermissions.hasPermissions(app, ALL_PERMS)).isTrue(); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldThrowException_whenHasPermissionsWithNullContext() { + try { + EasyPermissions.hasPermissions(null, ALL_PERMS); + fail("IllegalStateException expected because of null context."); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat() + .isEqualTo("Can't check permissions for null context"); + } + } + + // ------ From Activity ------ + + @Test + public void shouldCorrectlyCallback_whenOnRequestPermissionResultCalledFromActivity() { + EasyPermissions.onRequestPermissionsResult(TestActivity.REQUEST_CODE, ALL_PERMS, SMS_DENIED_RESULT, spyActivity); + + verify(spyActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.ACCESS_FINE_LOCATION))); + + verify(spyActivity, times(1)) + .onPermissionsDenied(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.READ_SMS))); + + verify(spyActivity, never()).afterPermissionGranted(); + } + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestAlreadyGrantedPermissionsFromActivity() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + verify(spyActivity, never()).requestPermissions(any(String[].class), anyInt()); + assertThat(integerCaptor.getValue()).isEqualTo(TestActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + @Test + public void shouldCallbackAfterPermissionGranted_whenRequestAlreadyGrantedPermissionsFromActivity() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + // Called 2 times because this is a spy and library implementation invokes super classes annotated methods as well + verify(spyActivity, times(2)).afterPermissionGranted(); + } + + @Test + public void shouldNotCallbackAfterPermissionGranted_whenRequestNotGrantedPermissionsFromActivity() { + grantPermissions(ONE_PERM); + + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyActivity, never()).afterPermissionGranted(); + } + + @Test + public void shouldRequestPermissions_whenMissingPermissionAndNotShowRationaleFromActivity() { + grantPermissions(ONE_PERM); + showRationale(false, ALL_PERMS); + + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyActivity, times(1)) + .requestPermissions(ALL_PERMS, TestActivity.REQUEST_CODE); + } + + @Test + public void shouldShowCorrectDialog_whenMissingPermissionsAndShowRationaleFromActivity() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + EasyPermissions.requestPermissions(spyActivity, RATIONALE, TestActivity.REQUEST_CODE, ALL_PERMS); + + Fragment dialogFragment = spyActivity.getFragmentManager() + .findFragmentByTag(RationaleDialogFragment.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragment.class); + + Dialog dialog = ((RationaleDialogFragment) dialogFragment).getDialog(); + assertThatHasExpectedRationale(dialog, RATIONALE); + } + + @Test + public void shouldShowCorrectDialogUsingRequest_whenMissingPermissionsAndShowRationaleFromActivity() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + PermissionRequest request = new PermissionRequest.Builder(spyActivity, TestActivity.REQUEST_CODE, ALL_PERMS) + .setPositiveButtonText(android.R.string.ok) + .setNegativeButtonText(android.R.string.cancel) + .setRationale(android.R.string.unknownName) + .setTheme(R.style.Theme_AppCompat) + .build(); + EasyPermissions.requestPermissions(request); + + Fragment dialogFragment = spyActivity.getFragmentManager() + .findFragmentByTag(RationaleDialogFragment.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragment.class); + + Dialog dialog = ((RationaleDialogFragment) dialogFragment).getDialog(); + assertThatHasExpectedButtonsAndRationale(dialog, android.R.string.unknownName, + android.R.string.ok, android.R.string.cancel); + } + + @Test + public void shouldHaveSomePermissionDenied_whenShowRationaleFromActivity() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyActivity, ALL_PERMS)).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionDenied_whenNotShowRationaleFromActivity() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyActivity, ALL_PERMS)).isFalse(); + } + + @Test + public void shouldHaveSomePermissionPermanentlyDenied_whenNotShowRationaleFromActivity() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyActivity, Arrays.asList(ALL_PERMS))).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionPermanentlyDenied_whenShowRationaleFromActivity() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyActivity, Arrays.asList(ALL_PERMS))).isFalse(); + } + + @Test + public void shouldHavePermissionPermanentlyDenied_whenNotShowRationaleFromActivity() { + showRationale(false, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyActivity, Manifest.permission.READ_SMS)).isTrue(); + } + + @Test + public void shouldNotHavePermissionPermanentlyDenied_whenShowRationaleFromActivity() { + showRationale(true, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyActivity, Manifest.permission.READ_SMS)).isFalse(); + } + + @Test + public void shouldCorrectlyCallback_whenOnRequestPermissionResultCalledFromAppCompatActivity() { + EasyPermissions.onRequestPermissionsResult(TestAppCompatActivity.REQUEST_CODE, ALL_PERMS, SMS_DENIED_RESULT, spyAppCompatActivity); + + verify(spyAppCompatActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestAppCompatActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.ACCESS_FINE_LOCATION))); + + verify(spyAppCompatActivity, times(1)) + .onPermissionsDenied(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestAppCompatActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.READ_SMS))); + + verify(spyAppCompatActivity, never()).afterPermissionGranted(); + } + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestAlreadyGrantedPermissionsFromAppCompatActivity() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyAppCompatActivity, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + verify(spyAppCompatActivity, never()).requestPermissions(any(String[].class), anyInt()); + assertThat(integerCaptor.getValue()).isEqualTo(TestAppCompatActivity.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + @Test + public void shouldCallbackAfterPermissionGranted_whenRequestAlreadyGrantedPermissionsFromAppCompatActivity() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + // Called 2 times because this is a spy and library implementation invokes super classes annotated methods as well + verify(spyAppCompatActivity, times(2)).afterPermissionGranted(); + } + + @Test + public void shouldNotCallbackAfterPermissionGranted_whenRequestNotGrantedPermissionsFromAppCompatActivity() { + grantPermissions(ONE_PERM); + + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyAppCompatActivity, never()).afterPermissionGranted(); + } + + @Test + public void shouldRequestPermissions_whenMissingPermissionAndNotShowRationaleFromAppCompatActivity() { + grantPermissions(ONE_PERM); + showRationale(false, ALL_PERMS); + + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + verify(spyAppCompatActivity, times(1)) + .requestPermissions(ALL_PERMS, TestAppCompatActivity.REQUEST_CODE); + } + + @Test + public void shouldShowCorrectDialog_whenMissingPermissionsAndShowRationaleFromAppCompatActivity() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + EasyPermissions.requestPermissions(spyAppCompatActivity, RATIONALE, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS); + + androidx.fragment.app.Fragment dialogFragment = spyAppCompatActivity.getSupportFragmentManager() + .findFragmentByTag(RationaleDialogFragmentCompat.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragmentCompat.class); + + Dialog dialog = ((RationaleDialogFragmentCompat) dialogFragment).getDialog(); + assertThatHasExpectedRationale(dialog, RATIONALE); + } + + @Test + public void shouldShowCorrectDialog_whenMissingPermissionsAndShowRationaleFromSupportFragmentActivity() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + EasyPermissions.requestPermissions(spySupportFragmentActivity, RATIONALE, TestSupportFragmentActivity.REQUEST_CODE, ALL_PERMS); + + Fragment dialogFragment = spySupportFragmentActivity.getFragmentManager() + .findFragmentByTag(RationaleDialogFragment.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragment.class); + + Dialog dialog = ((RationaleDialogFragment) dialogFragment).getDialog(); + assertThatHasExpectedRationale(dialog, RATIONALE); + } + + @Test + public void shouldShowCorrectDialogUsingRequest_whenMissingPermissionsAndShowRationaleFromAppCompatActivity() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + PermissionRequest request = new PermissionRequest.Builder(spyAppCompatActivity, TestAppCompatActivity.REQUEST_CODE, ALL_PERMS) + .setPositiveButtonText(android.R.string.ok) + .setNegativeButtonText(android.R.string.cancel) + .setRationale(android.R.string.unknownName) + .setTheme(R.style.Theme_AppCompat) + .build(); + EasyPermissions.requestPermissions(request); + + androidx.fragment.app.Fragment dialogFragment = spyAppCompatActivity.getSupportFragmentManager() + .findFragmentByTag(RationaleDialogFragmentCompat.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragmentCompat.class); + + Dialog dialog = ((RationaleDialogFragmentCompat) dialogFragment).getDialog(); + assertThatHasExpectedButtonsAndRationale(dialog, android.R.string.unknownName, + android.R.string.ok, android.R.string.cancel); + } + + @Test + public void shouldHaveSomePermissionDenied_whenShowRationaleFromAppCompatActivity() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyAppCompatActivity, ALL_PERMS)).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionDenied_whenNotShowRationaleFromAppCompatActivity() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyAppCompatActivity, ALL_PERMS)).isFalse(); + } + + @Test + public void shouldHaveSomePermissionPermanentlyDenied_whenNotShowRationaleFromAppCompatActivity() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyAppCompatActivity, Arrays.asList(ALL_PERMS))).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionPermanentlyDenied_whenShowRationaleFromAppCompatActivity() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyAppCompatActivity, Arrays.asList(ALL_PERMS))).isFalse(); + } + + @Test + public void shouldHavePermissionPermanentlyDenied_whenNotShowRationaleFromAppCompatActivity() { + showRationale(false, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyAppCompatActivity, Manifest.permission.READ_SMS)).isTrue(); + } + + @Test + public void shouldNotHavePermissionPermanentlyDenied_whenShowRationaleFromAppCompatActivity() { + showRationale(true, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyAppCompatActivity, Manifest.permission.READ_SMS)).isFalse(); + } + + @Test + public void shouldCorrectlyCallback_whenOnRequestPermissionResultCalledFromFragment() { + EasyPermissions.onRequestPermissionsResult(TestFragment.REQUEST_CODE, ALL_PERMS, SMS_DENIED_RESULT, + spyFragment); + + verify(spyFragment, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestFragment.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.ACCESS_FINE_LOCATION))); + + verify(spyFragment, times(1)) + .onPermissionsDenied(integerCaptor.capture(), listCaptor.capture()); + assertThat(integerCaptor.getValue()).isEqualTo(TestFragment.REQUEST_CODE); + assertThat(listCaptor.getValue()) + .containsAllIn(new ArrayList<>(Collections.singletonList(Manifest.permission.READ_SMS))); + + verify(spyFragment, never()).afterPermissionGranted(); + } + + @Test + public void shouldCallbackOnPermissionGranted_whenRequestAlreadyGrantedPermissionsFromFragment() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyFragment, RATIONALE, + TestFragment.REQUEST_CODE, ALL_PERMS); + + verify(spyFragment, times(1)) + .onPermissionsGranted(integerCaptor.capture(), listCaptor.capture()); + verify(spyFragment, never()).requestPermissions(any(String[].class), anyInt()); + assertThat(integerCaptor.getValue()).isEqualTo(TestFragment.REQUEST_CODE); + assertThat(listCaptor.getValue()).containsAllIn(ALL_PERMS); + } + + @Test + public void shouldCallbackAfterPermissionGranted_whenRequestAlreadyGrantedPermissionsFragment() { + grantPermissions(ALL_PERMS); + + EasyPermissions.requestPermissions(spyFragment, RATIONALE, TestFragment.REQUEST_CODE, ALL_PERMS); + + // Called 2 times because this is a spy and library implementation invokes super classes annotated methods as well + verify(spyFragment, times(2)).afterPermissionGranted(); + } + + @Test + public void shouldNotCallbackAfterPermissionGranted_whenRequestNotGrantedPermissionsFromFragment() { + grantPermissions(ONE_PERM); + + EasyPermissions.requestPermissions(spyFragment, RATIONALE, TestFragment.REQUEST_CODE, ALL_PERMS); + + verify(spyFragment, never()).afterPermissionGranted(); + } + + @Test + public void shouldRequestPermissions_whenMissingPermissionsAndNotShowRationaleFromFragment() { + grantPermissions(ONE_PERM); + showRationale(false, ALL_PERMS); + + EasyPermissions.requestPermissions(spyFragment, RATIONALE, TestFragment.REQUEST_CODE, ALL_PERMS); + + verify(spyFragment, times(1)) + .requestPermissions(ALL_PERMS, TestFragment.REQUEST_CODE); + } + + @Test + public void shouldShowCorrectDialog_whenMissingPermissionsAndShowRationaleFromFragment() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + EasyPermissions.requestPermissions(spyFragment, RATIONALE, TestFragment.REQUEST_CODE, ALL_PERMS); + + androidx.fragment.app.Fragment dialogFragment = spyFragment.getChildFragmentManager() + .findFragmentByTag(RationaleDialogFragmentCompat.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragmentCompat.class); + + Dialog dialog = ((RationaleDialogFragmentCompat) dialogFragment).getDialog(); + assertThatHasExpectedRationale(dialog, RATIONALE); + } + + @Test + public void shouldShowCorrectDialogUsingRequest_whenMissingPermissionsAndShowRationaleFromFragment() { + grantPermissions(ONE_PERM); + showRationale(true, ALL_PERMS); + + PermissionRequest request = new PermissionRequest.Builder(spyFragment, TestFragment.REQUEST_CODE, ALL_PERMS) + .setPositiveButtonText(POSITIVE) + .setNegativeButtonText(NEGATIVE) + .setRationale(RATIONALE) + .setTheme(R.style.Theme_AppCompat) + .build(); + EasyPermissions.requestPermissions(request); + + androidx.fragment.app.Fragment dialogFragment = spyFragment.getChildFragmentManager() + .findFragmentByTag(RationaleDialogFragmentCompat.TAG); + assertThat(dialogFragment).isInstanceOf(RationaleDialogFragmentCompat.class); + + Dialog dialog = ((RationaleDialogFragmentCompat) dialogFragment).getDialog(); + assertThatHasExpectedButtonsAndRationale(dialog, RATIONALE, POSITIVE, NEGATIVE); + } + + @Test + public void shouldHaveSomePermissionDenied_whenShowRationaleFromFragment() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyFragment, ALL_PERMS)).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionDenied_whenNotShowRationaleFromFragment() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionDenied(spyFragment, ALL_PERMS)).isFalse(); + } + + @Test + public void shouldHaveSomePermissionPermanentlyDenied_whenNotShowRationaleFromFragment() { + showRationale(false, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyFragment, Arrays.asList(ALL_PERMS))).isTrue(); + } + + @Test + public void shouldNotHaveSomePermissionPermanentlyDenied_whenShowRationaleFromFragment() { + showRationale(true, ALL_PERMS); + + assertThat(EasyPermissions.somePermissionPermanentlyDenied(spyFragment, Arrays.asList(ALL_PERMS))).isFalse(); + } + + + @Test + public void shouldHavePermissionPermanentlyDenied_whenNotShowRationaleFromFragment() { + showRationale(false, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyFragment, Manifest.permission.READ_SMS)).isTrue(); + } + + @Test + public void shouldNotHavePermissionPermanentlyDenied_whenShowRationaleFromFragment() { + showRationale(true, Manifest.permission.READ_SMS); + + assertThat(EasyPermissions.permissionPermanentlyDenied(spyFragment, Manifest.permission.READ_SMS)).isFalse(); + } + + private void assertThatHasExpectedButtonsAndRationale(Dialog dialog, int rationale, + int positive, int negative) { + TextView dialogMessage = dialog.findViewById(android.R.id.message); + assertThat(dialogMessage.getText().toString()).isEqualTo(app.getString(rationale)); + TextView positiveMessage = dialog.findViewById(android.R.id.button1); + assertThat(positiveMessage.getText().toString()).isEqualTo(app.getString(positive)); + TextView negativeMessage = dialog.findViewById(android.R.id.button2); + assertThat(negativeMessage.getText().toString()).isEqualTo(app.getString(negative)); + } + + private void assertThatHasExpectedButtonsAndRationale(Dialog dialog, String rationale, + int positive, int negative) { + TextView dialogMessage = dialog.findViewById(android.R.id.message); + assertThat(dialogMessage.getText().toString()).isEqualTo(rationale); + TextView positiveMessage = dialog.findViewById(android.R.id.button1); + assertThat(positiveMessage.getText().toString()).isEqualTo(app.getString(positive)); + TextView negativeMessage = dialog.findViewById(android.R.id.button2); + assertThat(negativeMessage.getText().toString()).isEqualTo(app.getString(negative)); + } + + private void assertThatHasExpectedButtonsAndRationale(Dialog dialog, String rationale, + String positive, String negative) { + TextView dialogMessage = dialog.findViewById(android.R.id.message); + assertThat(dialogMessage.getText().toString()).isEqualTo(rationale); + TextView positiveMessage = dialog.findViewById(android.R.id.button1); + assertThat(positiveMessage.getText().toString()).isEqualTo(positive); + TextView negativeMessage = dialog.findViewById(android.R.id.button2); + assertThat(negativeMessage.getText().toString()).isEqualTo(negative); + } + + private void assertThatHasExpectedRationale(Dialog dialog, String rationale) { + TextView dialogMessage = dialog.findViewById(android.R.id.message); + assertThat(dialogMessage.getText().toString()).isEqualTo(rationale); + } + + private void grantPermissions(String[] perms) { + shadowApp.grantPermissions(perms); + } + + private void showRationale(boolean show, String... perms) { + for (String perm : perms) { + when(spyActivity.shouldShowRequestPermissionRationale(perm)).thenReturn(show); + when(spySupportFragmentActivity.shouldShowRequestPermissionRationale(perm)).thenReturn(show); + when(spyAppCompatActivity.shouldShowRequestPermissionRationale(perm)).thenReturn(show); + when(spyFragment.shouldShowRequestPermissionRationale(perm)).thenReturn(show); + } + } +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/RationaleDialogClickListenerTest.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/RationaleDialogClickListenerTest.java new file mode 100644 index 00000000..c05f99b1 --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/RationaleDialogClickListenerTest.java @@ -0,0 +1,134 @@ +package pub.devrel.easypermissions; + +import android.Manifest; +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; + +import androidx.fragment.app.Fragment; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Arrays; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 23) +public class RationaleDialogClickListenerTest { + + private static final int REQUEST_CODE = 5; + private static final String[] PERMS = new String[]{ + Manifest.permission.READ_SMS, Manifest.permission.ACCESS_FINE_LOCATION}; + @Mock + private RationaleDialogFragment dialogFragment; + @Mock + private RationaleDialogFragmentCompat dialogFragmentCompat; + @Mock + private RationaleDialogConfig dialogConfig; + @Mock + private EasyPermissions.PermissionCallbacks permissionCallbacks; + @Mock + private EasyPermissions.RationaleCallbacks rationaleCallbacks; + @Mock + private DialogInterface dialogInterface; + @Mock + private Activity activity; + @Mock + private Fragment fragment; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + when(dialogFragment.getActivity()).thenReturn(activity); + dialogConfig.requestCode = REQUEST_CODE; + dialogConfig.permissions = PERMS; + } + + @Test + public void shouldOnRationaleAccepted_whenPositiveButtonWithRationaleCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_POSITIVE); + + verify(rationaleCallbacks, times(1)).onRationaleAccepted(REQUEST_CODE); + } + + @Test + public void shouldNotOnRationaleAccepted_whenPositiveButtonWithoutRationaleCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, null); + listener.onClick(dialogInterface, Dialog.BUTTON_POSITIVE); + + verify(rationaleCallbacks, never()).onRationaleAccepted(anyInt()); + } + + @Test + public void shouldRequestPermissions_whenPositiveButtonFromActivity() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_POSITIVE); + + verify(activity, times(1)).requestPermissions(PERMS, REQUEST_CODE); + } + + @Test + public void shouldRequestPermissions_whenPositiveButtonFromFragment() { + when(dialogFragmentCompat.getParentFragment()).thenReturn(fragment); + + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragmentCompat, dialogConfig, + permissionCallbacks, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_POSITIVE); + + verify(fragment, times(1)).requestPermissions(PERMS, REQUEST_CODE); + } + + @Test + public void shouldOnRationaleDenied_whenNegativeButtonWithRationaleCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_NEGATIVE); + + verify(rationaleCallbacks, times(1)).onRationaleDenied(REQUEST_CODE); + } + + @Test + public void shouldNotOnRationaleDenied_whenNegativeButtonWithoutRationaleCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, null); + listener.onClick(dialogInterface, Dialog.BUTTON_NEGATIVE); + + verify(rationaleCallbacks, never()).onRationaleDenied(anyInt()); + } + + @Test + public void shouldOnPermissionsDenied_whenNegativeButtonWithPermissionCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + permissionCallbacks, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_NEGATIVE); + + verify(permissionCallbacks, times(1)) + .onPermissionsDenied(REQUEST_CODE, Arrays.asList(PERMS)); + } + + @Test + public void shouldNotOnPermissionsDenied_whenNegativeButtonWithoutPermissionCallbacks() { + RationaleDialogClickListener listener = new RationaleDialogClickListener(dialogFragment, dialogConfig, + null, rationaleCallbacks); + listener.onClick(dialogInterface, Dialog.BUTTON_NEGATIVE); + + verify(permissionCallbacks, never()).onPermissionsDenied(anyInt(), ArgumentMatchers.anyList()); + } +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/ActivityController.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/ActivityController.java new file mode 100644 index 00000000..fc44a2ec --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/ActivityController.java @@ -0,0 +1,45 @@ +package pub.devrel.easypermissions.testhelper; + +import android.app.Activity; + +import androidx.annotation.NonNull; +import androidx.test.core.app.ActivityScenario; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * Helper class to allow starting Activity, similar to the Robolectric ActivityConroller. + */ +public class ActivityController { + + private ActivityScenario scenario; + + public ActivityController(Class clazz) { + scenario = ActivityScenario.launch(clazz); + } + + public synchronized T resume() { + final CompletableFuture ActivityFuture = new CompletableFuture<>(); + + scenario.onActivity(new ActivityScenario.ActivityAction() { + @Override + public void perform(@NonNull T activity) { + ActivityFuture.complete(activity); + } + }); + + try { + return ActivityFuture.get(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + public void reset() { + scenario.recreate(); + } + +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/FragmentController.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/FragmentController.java new file mode 100644 index 00000000..60447a65 --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/FragmentController.java @@ -0,0 +1,44 @@ +package pub.devrel.easypermissions.testhelper; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.testing.FragmentScenario; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * Helper class to allow starting Fragments, similar to the old SupportFragmentController. + */ +public class FragmentController { + + private FragmentScenario scenario; + + public FragmentController(Class clazz) { + scenario = FragmentScenario.launch(clazz); + } + + public synchronized T resume() { + final CompletableFuture fragmentFuture = new CompletableFuture<>(); + + scenario.onFragment(new FragmentScenario.FragmentAction() { + @Override + public void perform(@NonNull T fragment) { + fragmentFuture.complete(fragment); + } + }); + + try { + return fragmentFuture.get(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + public void reset() { + scenario.recreate(); + } + +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestActivity.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestActivity.java new file mode 100644 index 00000000..cc1b14c0 --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestActivity.java @@ -0,0 +1,40 @@ +package pub.devrel.easypermissions.testhelper; + +import android.app.Activity; + +import java.util.List; + +import androidx.annotation.NonNull; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + +public class TestActivity extends Activity + implements EasyPermissions.PermissionCallbacks, EasyPermissions.RationaleCallbacks { + + public static final int REQUEST_CODE = 1; + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + + } + + @AfterPermissionGranted(REQUEST_CODE) + public void afterPermissionGranted() { + + } + + @Override + public void onRationaleAccepted(int requestCode) { + + } + + @Override + public void onRationaleDenied(int requestCode) { + + } +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestAppCompatActivity.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestAppCompatActivity.java new file mode 100644 index 00000000..3ca8accc --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestAppCompatActivity.java @@ -0,0 +1,50 @@ +package pub.devrel.easypermissions.testhelper; + +import android.os.Bundle; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; +import pub.devrel.easypermissions.R; + +public class TestAppCompatActivity extends AppCompatActivity + implements EasyPermissions.PermissionCallbacks, EasyPermissions.RationaleCallbacks { + + public static final int REQUEST_CODE = 3; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + getTheme().applyStyle(R.style.Theme_AppCompat, true); + super.onCreate(savedInstanceState); + } + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + + } + + @AfterPermissionGranted(REQUEST_CODE) + public void afterPermissionGranted() { + + } + + @Override + public void onRationaleAccepted(int requestCode) { + + } + + @Override + public void onRationaleDenied(int requestCode) { + + } + +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestFragment.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestFragment.java new file mode 100644 index 00000000..96dbbcbe --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestFragment.java @@ -0,0 +1,55 @@ +package pub.devrel.easypermissions.testhelper; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; +import pub.devrel.easypermissions.R; + +public class TestFragment extends Fragment + implements EasyPermissions.PermissionCallbacks, EasyPermissions.RationaleCallbacks { + + public static final int REQUEST_CODE = 4; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + getContext().getTheme().applyStyle(R.style.Theme_AppCompat, true); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + + } + + @AfterPermissionGranted(REQUEST_CODE) + public void afterPermissionGranted() { + + } + + @Override + public void onRationaleAccepted(int requestCode) { + + } + + @Override + public void onRationaleDenied(int requestCode) { + + } +} diff --git a/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestSupportFragmentActivity.java b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestSupportFragmentActivity.java new file mode 100644 index 00000000..83187e00 --- /dev/null +++ b/android/easypermissions/src/test/java/pub/devrel/easypermissions/testhelper/TestSupportFragmentActivity.java @@ -0,0 +1,47 @@ +package pub.devrel.easypermissions.testhelper; + +import android.os.Bundle; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import pub.devrel.easypermissions.AfterPermissionGranted; +import pub.devrel.easypermissions.EasyPermissions; + +public class TestSupportFragmentActivity extends FragmentActivity + implements EasyPermissions.PermissionCallbacks, EasyPermissions.RationaleCallbacks { + + public static final int REQUEST_CODE = 5; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public void onPermissionsGranted(int requestCode, @NonNull List perms) { + + } + + @Override + public void onPermissionsDenied(int requestCode, @NonNull List perms) { + + } + + @AfterPermissionGranted(REQUEST_CODE) + public void afterPermissionGranted() { + + } + + @Override + public void onRationaleAccepted(int requestCode) { + + } + + @Override + public void onRationaleDenied(int requestCode) { + + } +} diff --git a/lib/widgets/patients/profile/profile_medical_info_widget_search.dart b/lib/widgets/patients/profile/profile_medical_info_widget_search.dart index d20a414a..464fb689 100644 --- a/lib/widgets/patients/profile/profile_medical_info_widget_search.dart +++ b/lib/widgets/patients/profile/profile_medical_info_widget_search.dart @@ -62,7 +62,9 @@ class _ProfileMedicalInfoWidgetSearchState extends State