From 5de57b6ff301b353bbae5030569474be534b7092 Mon Sep 17 00:00:00 2001 From: Sebastian Kaspari Date: Mon, 11 Jan 2016 12:05:08 +0100 Subject: [PATCH] Bug 1235347 - Add Permissions helper class for checking and requesting runtime permissions. r=nalexander --HG-- extra : commitid : LVJJOpNAzdc extra : rebase_source : 6e18b47ca930146c558ff199cfb84ce19938f985 --- .../gecko/permissions/PermissionBlock.java | 99 +++++++++ .../gecko/permissions/Permissions.java | 130 ++++++++++++ .../gecko/permissions/PermissionsHelper.java | 32 +++ mobile/android/base/moz.build | 3 + .../gecko/permissions/TestPermissions.java | 197 ++++++++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 mobile/android/base/java/org/mozilla/gecko/permissions/PermissionBlock.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/permissions/Permissions.java create mode 100644 mobile/android/base/java/org/mozilla/gecko/permissions/PermissionsHelper.java create mode 100644 mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java diff --git a/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionBlock.java b/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionBlock.java new file mode 100644 index 000000000000..bbddcea15b24 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionBlock.java @@ -0,0 +1,99 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.permissions; + +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.Context; +import android.support.annotation.NonNull; + +/** + * Helper class to run code blocks depending on whether a user has granted or denied certain runtime permissions. + */ +public class PermissionBlock { + private final PermissionsHelper helper; + + private Activity activity; + private String[] permissions; + private boolean onUIThread; + private Runnable onPermissionsGranted; + private Runnable onPermissionsDenied; + + /* package-private */ PermissionBlock(Activity activity, PermissionsHelper helper) { + this.activity = activity; + this.helper = helper; + } + + /** + * Determine whether the app has been granted the specified permissions. + */ + public PermissionBlock withPermissions(@NonNull String... permissions) { + this.permissions = permissions; + return this; + } + + /** + * Execute all callbacks on the UI thread. + */ + public PermissionBlock onUIThread() { + this.onUIThread = true; + return this; + } + + /** + * Execute the specified runnable if the app has been granted all permissions. Calling this method will prompt the + * user if needed. + */ + public void run(@NonNull Runnable onPermissionsGranted) { + this.onPermissionsGranted = onPermissionsGranted; + + if (hasPermissions(activity)) { + onPermissionsGranted(); + } else { + Permissions.prompt(activity, this); + } + + // This reference is no longer needed. Let's clear it now to avoid memory leaks. + activity = null; + } + + /** + * Execute this fallback if at least one permission has not been granted. + */ + public PermissionBlock andFallback(@NonNull Runnable onPermissionsDenied) { + this.onPermissionsDenied = onPermissionsDenied; + return this; + } + + /* package-private */ void onPermissionsGranted() { + executeRunnable(onPermissionsGranted); + } + + /* package-private */ void onPermissionsDenied() { + executeRunnable(onPermissionsDenied); + } + + private void executeRunnable(Runnable runnable) { + if (runnable == null) { + return; + } + + if (onUIThread) { + ThreadUtils.postToUiThread(runnable); + } else { + runnable.run(); + } + } + + /* package-private */ String[] getPermissions() { + return permissions; + } + + /* packacge-private */ boolean hasPermissions(Context context) { + return helper.hasPermissions(context, permissions); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/permissions/Permissions.java b/mobile/android/base/java/org/mozilla/gecko/permissions/Permissions.java new file mode 100644 index 000000000000..897680295158 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/permissions/Permissions.java @@ -0,0 +1,130 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.permissions; + +import android.app.Activity; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * Convenience class for checking and prompting for runtime permissions. + * + * Example: + * + * Permissions.from(activity) + * .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + * .onUiThread() + * .andFallback(onPermissionDenied()) + * .run(onPermissionGranted()) + * + * This example will run the runnable returned by onPermissionGranted() if the WRITE_EXTERNAL_STORAGE permission is + * already granted. Otherwise it will prompt the user and run the runnable returned by onPermissionGranted() or + * onPermissionDenied() depending on whether the user accepted or not. If onUiThread() is specified then all callbacks + * will be run on the UI thread. + */ +public class Permissions { + private static final Queue waiting = new LinkedList<>(); + private static final Queue prompt = new LinkedList<>(); + + private static PermissionsHelper permissionHelper = new PermissionsHelper(); + + /** + * Entry point for checking (and optionally prompting for) runtime permissions. + */ + public static PermissionBlock from(@NonNull Activity activity) { + return new PermissionBlock(activity, permissionHelper); + } + + /* package-private */ static void setPermissionHelper(PermissionsHelper permissionHelper) { + Permissions.permissionHelper = permissionHelper; + } + + /** + * Callback for Activity.onRequestPermissionsResult(). All activities that prompt for permissions using this class + * should implement onRequestPermissionsResult() and call this method. + */ + public static synchronized void onRequestPermissionsResult(@NonNull Activity activity, @NonNull String[] permissions, @NonNull int[] grantResults) { + processGrantResults(permissions, grantResults); + + processQueue(activity); + } + + /* package-private */ static synchronized void prompt(Activity activity, PermissionBlock block) { + if (prompt.isEmpty()) { + prompt.add(block); + showPrompt(activity); + } else { + waiting.add(block); + } + } + + private static synchronized void processGrantResults(@NonNull String[] permissions, @NonNull int[] grantResults) { + HashSet grantedPermissions = collectGrantedPermissions(permissions, grantResults); + + while (!prompt.isEmpty()) { + PermissionBlock block = prompt.poll(); + + if (allPermissionsGranted(block, grantedPermissions)) { + block.onPermissionsGranted(); + } else { + block.onPermissionsDenied(); + } + } + } + + private static synchronized void processQueue(Activity activity) { + while (!waiting.isEmpty()) { + PermissionBlock block = waiting.poll(); + + if (block.hasPermissions(activity)) { + block.onPermissionsGranted(); + } else { + prompt.add(block); + } + } + + if (!prompt.isEmpty()) { + showPrompt(activity); + } + } + + private static synchronized void showPrompt(Activity activity) { + HashSet permissions = new HashSet<>(); + + for (PermissionBlock block : prompt) { + Collections.addAll(permissions, block.getPermissions()); + } + + permissionHelper.prompt(activity, permissions.toArray(new String[permissions.size()])); + } + + private static HashSet collectGrantedPermissions(@NonNull String[] permissions, @NonNull int[] grantResults) { + HashSet grantedPermissions = new HashSet<>(permissions.length); + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) { + grantedPermissions.add(permissions[i]); + } + } + return grantedPermissions; + } + + private static boolean allPermissionsGranted(PermissionBlock block, HashSet grantedPermissions) { + for (String permission : block.getPermissions()) { + if (!grantedPermissions.contains(permission)) { + return false; + } + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionsHelper.java b/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionsHelper.java new file mode 100644 index 000000000000..945a81f43ab6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/permissions/PermissionsHelper.java @@ -0,0 +1,32 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.permissions; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; + +/* package-private */ class PermissionsHelper { + private static final int PERMISSIONS_REQUEST_CODE = 212; + + public boolean hasPermissions(Context context, String... permissions) { + for (String permission : permissions) { + final int permissionCheck = ContextCompat.checkSelfPermission(context, permission); + + if (permissionCheck != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + + return true; + } + + public void prompt(Activity activity, String[] permissions) { + ActivityCompat.requestPermissions(activity, permissions, PERMISSIONS_REQUEST_CODE); + } +} diff --git a/mobile/android/base/moz.build b/mobile/android/base/moz.build index cfe510e21bb0..613d13f536c6 100644 --- a/mobile/android/base/moz.build +++ b/mobile/android/base/moz.build @@ -455,6 +455,9 @@ gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [ 'overlays/ui/SendTabList.java', 'overlays/ui/SendTabTargetSelectedListener.java', 'overlays/ui/ShareDialog.java', + 'permissions/PermissionBlock.java', + 'permissions/Permissions.java', + 'permissions/PermissionsHelper.java', 'preferences/AlignRightLinkPreference.java', 'preferences/AndroidImport.java', 'preferences/AndroidImportPreference.java', diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java new file mode 100644 index 000000000000..b95af6bd9b82 --- /dev/null +++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/permissions/TestPermissions.java @@ -0,0 +1,197 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.permissions; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; + +import org.mozilla.gecko.background.testhelpers.TestRunner; + +import static org.mockito.Mockito.*; + +@RunWith(TestRunner.class) +public class TestPermissions { + @Test + public void testSuccessRunnableIsExecutedIfPermissionsAreGranted() { + Permissions.setPermissionHelper(mockGrantingHelper()); + + Runnable onPermissionsGranted = mock(Runnable.class); + Runnable onPermissionsDenied = mock(Runnable.class); + + Permissions.from(mockActivity()) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(onPermissionsDenied) + .run(onPermissionsGranted); + + verify(onPermissionsDenied, never()).run(); + verify(onPermissionsGranted).run(); + } + + @Test + public void testFallbackRunnableIsExecutedIfPermissionsAreDenied() { + Permissions.setPermissionHelper(mockDenyingHelper()); + + Runnable onPermissionsGranted = mock(Runnable.class); + Runnable onPermissionsDenied = mock(Runnable.class); + + Activity activity = mockActivity(); + + Permissions.from(activity) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(onPermissionsDenied) + .run(onPermissionsGranted); + + Permissions.onRequestPermissionsResult(activity, new String[]{ + Manifest.permission.WRITE_EXTERNAL_STORAGE + }, new int[]{ + PackageManager.PERMISSION_DENIED + }); + + verify(onPermissionsDenied).run(); + verify(onPermissionsGranted, never()).run(); + } + + @Test + public void testPromptingForNotGrantedPermissions() { + Activity activity = mockActivity(); + + PermissionsHelper helper = mockDenyingHelper(); + Permissions.setPermissionHelper(helper); + + Permissions.from(activity) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(mock(Runnable.class)) + .run(mock(Runnable.class)); + + verify(helper).prompt(anyActivity(), any(String[].class)); + + Permissions.onRequestPermissionsResult(activity, new String[0], new int[0]); + } + + @Test + public void testMultipleRequestsAreQueuedAndDispatchedSequentially() { + Activity activity = mockActivity(); + + PermissionsHelper helper = mockDenyingHelper(); + Permissions.setPermissionHelper(helper); + + Runnable onFirstPermissionGranted = mock(Runnable.class); + Runnable onSecondPermissionDenied = mock(Runnable.class); + + Permissions.from(activity) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(mock(Runnable.class)) + .run(onFirstPermissionGranted); + + Permissions.from(activity) + .withPermissions(Manifest.permission.CAMERA) + .andFallback(onSecondPermissionDenied) + .run(mock(Runnable.class)); + + + Permissions.onRequestPermissionsResult(activity, new String[] { + Manifest.permission.WRITE_EXTERNAL_STORAGE + }, new int[] { + PackageManager.PERMISSION_GRANTED + }); + + verify(onFirstPermissionGranted).run(); + verify(onSecondPermissionDenied, never()).run(); // Second request is queued but not executed yet + + Permissions.onRequestPermissionsResult(activity, new String[]{ + Manifest.permission.CAMERA + }, new int[]{ + PackageManager.PERMISSION_DENIED + }); + + verify(onFirstPermissionGranted).run(); + verify(onSecondPermissionDenied).run(); + + verify(helper, times(2)).prompt(anyActivity(), any(String[].class)); + } + + @Test + public void testSecondRequestWillNotPromptIfPermissionHasBeenGranted() { + Activity activity = mockActivity(); + + PermissionsHelper helper = mock(PermissionsHelper.class); + Permissions.setPermissionHelper(helper); + when(helper.hasPermissions(anyContext(), anyPermissions())) + .thenReturn(false) + .thenReturn(false) + .thenReturn(true); // Revaluation is successful + + Permissions.from(activity) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(mock(Runnable.class)) + .run(mock(Runnable.class)); + + Permissions.from(activity) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .andFallback(mock(Runnable.class)) + .run(mock(Runnable.class)); + + Permissions.onRequestPermissionsResult(activity, new String[]{ + Manifest.permission.WRITE_EXTERNAL_STORAGE + }, new int[]{ + PackageManager.PERMISSION_GRANTED + }); + + verify(helper, times(1)).prompt(anyActivity(), any(String[].class)); + } + + @Test + public void testEmptyPermissionsArrayWillExecuteRunnableAndNotTryToPrompt() { + PermissionsHelper helper = spy(new PermissionsHelper()); + Permissions.setPermissionHelper(helper); + + Runnable onPermissionGranted = mock(Runnable.class); + Runnable onPermissionDenied = mock(Runnable.class); + + Permissions.from(mockActivity()) + .withPermissions() + .andFallback(onPermissionDenied) + .run(onPermissionGranted); + + verify(onPermissionGranted).run(); + verify(onPermissionDenied, never()).run(); + verify(helper, never()).prompt(anyActivity(), any(String[].class)); + } + + private Activity mockActivity() { + return mock(Activity.class); + } + + private PermissionsHelper mockGrantingHelper() { + PermissionsHelper helper = mock(PermissionsHelper.class); + doReturn(true).when(helper).hasPermissions(any(Context.class), anyPermissions()); + return helper; + } + + private PermissionsHelper mockDenyingHelper() { + PermissionsHelper helper = mock(PermissionsHelper.class); + doReturn(false).when(helper).hasPermissions(any(Context.class), anyPermissions()); + return helper; + } + + private String anyPermissions() { + return Matchers.anyVararg(); + } + + private Activity anyActivity() { + return any(Activity.class); + } + + private Context anyContext() { + return any(Context.class); + } +}