PermissionCheckpoint.java

  1. package net.zer0bandwidth.android.lib.security;

  2. import android.app.Activity;
  3. import android.content.Context;
  4. import android.content.DialogInterface;
  5. import android.content.Intent;
  6. import android.content.pm.PackageManager;
  7. import android.net.Uri;
  8. import android.os.Build;
  9. import android.provider.Settings;
  10. import android.support.annotation.NonNull;
  11. import android.support.annotation.RequiresApi;
  12. import android.support.v4.app.ActivityCompat;
  13. import android.support.v4.content.ContextCompat;
  14. import android.support.v7.app.AppCompatActivity;
  15. import android.util.Log;

  16. import net.zer0bandwidth.android.lib.R;

  17. import java.util.ArrayList;
  18. import java.util.HashMap;

  19. /**
  20.  * This class checks a specified list of "dangerous" permissions, forcing the
  21.  * user to grant each one at runtime.
  22.  *
  23.  * <h3>Using this Class</h3>
  24.  *
  25.  * <h4>Defining the List of Permissions</h4>
  26.  *
  27.  * <p>By default, the list of dangerous permissions is drawn from the string
  28.  * array resource named by {@link R.array#asDangerousPermissionsRequired} in
  29.  * this library. By default, the array is empty; the app consuming this library
  30.  * may override the resource with its own values.</p>
  31.  *
  32.  * <p>The consumer of this class may, alternatively, supply a different resource
  33.  * ID to one of the longer constructors. This will <i>replace</i> the default
  34.  * array resource as the definitive list of required permissions.</p>
  35.  *
  36.  * <h4>Maintain a Persistent Instance in the Activity</h4>
  37.  *
  38.  * <p>The main activity of the app should construct an instance of this class in
  39.  * its {@code onCreate} method, using itself as the operational context, and
  40.  * optionally specifying an alternative list of permissions.</p>
  41.  *
  42.  * <pre>
  43.  *     protected PermissionCheckpoint m_perms = null ;
  44.  *
  45.  *    {@literal @Override}
  46.  *     protected void onCreate( Bundle bndlState )
  47.  *     {
  48.  *         super.onCreate(bndlState) ;
  49.  *         this.setContentView( R.layout.whatever ) ;
  50.  *         if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M )
  51.  *             m_perms = new PermissionCheckpoint(this) ;
  52.  *         // ...
  53.  *     }
  54.  * </pre>
  55.  *
  56.  * <h4>Refresh Permission State on Each {@code onResume}</h4>
  57.  *
  58.  * <p>The activity's {@code onResume} method must include a call to this class's
  59.  * {@link #performChecks()} method, which re-evaluates the app's permissions
  60.  * each time it is invoked.</p>
  61.  *
  62.  * <pre>
  63.  *    {@literal @Override}
  64.  *     protected void onResume()
  65.  *     {
  66.  *         super.onResume() ;
  67.  *         if( m_perms != null ) m_perms.performChecks() ;
  68.  *     }
  69.  * </pre>
  70.  *
  71.  * <h4>Implement the Permission Request Callback</h4>
  72.  *
  73.  * <p>Finally, the activity must provide a callback for the Android OS in
  74.  * response to a permission request.</p>
  75.  *
  76.  * <h5>{@link Activity}</h5>
  77.  *
  78.  * <p>Modern activities include their own callback method, which must be
  79.  * overridden in your activity.</p>
  80.  *
  81.  * <pre>
  82.  *     public class MyActivity extends Activity
  83.  *     {
  84.  *         protected PermissionCheckpoint m_perms = null ; // init in onCreate()
  85.  *
  86.  *         // etc
  87.  *
  88.  *        {@literal @RequiresApi( api = Build.VERSION_CODES.M )}
  89.  *        {@literal @Override}
  90.  *         public void onRequestPermissionsResult( int zRequestCode,
  91.  *            {@literal @NonNull} String[] asPermissions,{@literal @NonNull} int[] azStatus )
  92.  *         {
  93.  *             super.onRequestPermissionsResult( zRequestCode, asPermissions, azStatus ) ;
  94.  *             if( m_perms != null )
  95.  *                 m_perms.processRequestResults( zRequestCode, asPermissions, azStatus ) ;
  96.  *         }
  97.  *     }
  98.  * </pre>
  99.  *
  100.  * <h5>{@link AppCompatActivity}</h5>
  101.  *
  102.  * <p>An activity from the compatibility library should implement the
  103.  * {@link ActivityCompat.OnRequestPermissionsResultCallback} interface.</p>
  104.  *
  105.  * <pre>
  106.  *     public class MyCompatActivity extends AppCompatActivity
  107.  *     implements ActivityCompat.OnRequestPermissionsResultCallback
  108.  *     {
  109.  *         protected PermissionCheckpoint m_perms = null ; // init in onCreate()
  110.  *
  111.  *         // etc
  112.  *
  113.  *        {@literal @RequiresApi( api = Build.VERSION_CODES.M )}
  114.  *        {@literal @Override}
  115.  *         public void onRequestPermissionsResult( int zRequestCode,
  116.  *            {@literal @NonNull} String[] asPermissions,{@literal @NonNull} int[] azStatus )
  117.  *         {
  118.  *             if( m_perms != null )
  119.  *                 m_perms.processRequestResults( zRequestCode, asPermissions, azStatus ) ;
  120.  *         }
  121.  *     }
  122.  * </pre>
  123.  *
  124.  * <h3>Acknowledgements</h3>
  125.  *
  126.  * Special thanks to {@code @dapayne1} for doing the heavy lifting on the
  127.  * research for Android 6 permissions management, and providing the original
  128.  * reference implementation from which this class was adapted.
  129.  *
  130.  * @since zer0bandwidth-net/android 0.0.3 (#10)
  131.  */
  132. @SuppressWarnings( "unused" )                              // This is a library.
  133. public class PermissionCheckpoint
  134. {
  135. public static final String LOG_TAG =
  136. PermissionCheckpoint.class.getSimpleName() ;

  137. /**
  138.  * An identifier used in permission requests.
  139.  */
  140. protected static final int DANGER_REQUEST_CODE = 4 ;

  141. protected static final String PERMISSION_WRITE_SETTINGS =
  142. "android.permission.WRITE_SETTINGS" ;

  143. /**
  144.  * Static method allowing any class to check the momentary status of a
  145.  * permission.
  146.  * @param ctx the context in which to perform the check
  147.  * @param sPermission the qualified name of the permission to check
  148.  * @return {@code true} iff the permission has been granted to the app
  149.  */
  150. public static boolean checkPermission( Context ctx, String sPermission )
  151. {
  152. boolean bGranted ;
  153. if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
  154.  && PERMISSION_WRITE_SETTINGS.equals(sPermission) )
  155. {
  156. bGranted = Settings.System.canWrite( ctx ) ;
  157. }
  158. else
  159. {
  160. final int zStatus =
  161. ContextCompat.checkSelfPermission( ctx, sPermission ) ;
  162. bGranted = ( zStatus == PackageManager.PERMISSION_GRANTED ) ;
  163. }
  164. Log.d( LOG_TAG, (new StringBuilder())
  165. .append( "Permission [" )
  166. .append( sPermission )
  167. .append(( bGranted ? "]   GRANTED" : "]   DENIED" ))
  168. .toString()
  169. );
  170. return bGranted ;
  171. }

  172. /**
  173.  * An {@link AppCompatActivity} which provides the context from which
  174.  * resources are resolved and additional dialogs and activities may be
  175.  * launched. Only one of {@code m_actCompatContext} and
  176.  * {@link #m_actContext} should be set for any given instance.
  177.  */
  178. protected AppCompatActivity m_actCompatContext = null ;

  179. /**
  180.  * An {@link Activity} which provides the context from which resources
  181.  * are resolved and additional dialogs and activities may be launched.
  182.  * Only one of {@link #m_actCompatContext} and {@code m_actContext} should
  183.  * be set for any given instance.
  184.  */
  185. protected Activity m_actContext = null ;

  186. /**
  187.  * Since both {@link Activity} and {@link AppCompatActivity} are
  188.  * descendants of {@link Context}, this field allows whichever one we end up
  189.  * with to represent the operational context of the instance, where we can
  190.  * use {@code Context} for resource resolution, etc.
  191.  */
  192. protected Context m_ctx = null ;

  193. /**
  194.  * The default string array resource in which dangerous permissions are
  195.  * defined. Apps that use this library may choose to override this
  196.  * {@link R.array#asDangerousPermissionsRequired} resource, or have their
  197.  * own resource which is passed into the constructor.
  198.  */
  199. protected static final int DEFAULT_DANGER_LIST_RESOURCE =
  200. R.array.asDangerousPermissionsRequired ;
  201. /**
  202.  * The resource ID of the string array that defines the list of dangerous
  203.  * permissions that should be requested. This defaults to the value of
  204.  * {@link #DEFAULT_DANGER_LIST_RESOURCE}, which is
  205.  * {@link R.array#asDangerousPermissionsRequired}.
  206.  */
  207. protected int m_resDangerList = DEFAULT_DANGER_LIST_RESOURCE ;

  208. /**
  209.  * Tracks the list of "dangerous" permissions that need to be granted to the
  210.  * app, and the current status of each.
  211.  */
  212. protected HashMap<String,Boolean> m_mapGranted = null ;

  213. /** Indicates that any permission dialog is currently being displayed. */
  214. protected boolean m_bDialogVisible = false ;

  215. /** Indicates the "build flavor" used to build the app, if any. */
  216. protected String m_sFlavor = null ;

  217. /**
  218.  * Initializes the instance with an {@link Activity} as the context.
  219.  * The default string array resource,
  220.  * {@link R.array#asDangerousPermissionsRequired}, will be used to define
  221.  * the list of dangerous permissions required by the app.
  222.  * @param act the activity which provides operational context
  223.  */
  224. public PermissionCheckpoint( Activity act )
  225. { this( act, DEFAULT_DANGER_LIST_RESOURCE ) ; }

  226. /**
  227.  * Initializes the instance with an {@link Activity} as the context.
  228.  * @param act the activity which provides operational context
  229.  * @param resDangerList the resource ID for the list of dangerous
  230.  *                      permissions required by the app
  231.  */
  232. public PermissionCheckpoint( Activity act, int resDangerList )
  233. {
  234. m_actCompatContext = null ;
  235. m_actContext = act ;
  236. this.init( resDangerList ) ;
  237. }

  238. /**
  239.  * Initializes the instance with an {@link AppCompatActivity} as the context.
  240.  * The default string array resource,
  241.  * {@link R.array#asDangerousPermissionsRequired}, will be used to define
  242.  * the list of dangerous permissions required by the app.
  243.  * @param act the activity which provides operational context
  244.  */
  245. public PermissionCheckpoint( AppCompatActivity act )
  246. { this( act, DEFAULT_DANGER_LIST_RESOURCE ) ; }

  247. /**
  248.  * Initializes the instance with an {@link AppCompatActivity} as the context.
  249.  * @param act the activity which provides operational context
  250.  * @param resDangerList the resource ID for the list of dangerous
  251.  *                      permissions required by the app
  252.  */
  253. public PermissionCheckpoint( AppCompatActivity act, int resDangerList )
  254. {
  255. m_actCompatContext = act ;
  256. m_actContext = null ;
  257. this.init( resDangerList ) ;
  258. }

  259. /**
  260.  * Initializes various fields within the instance, once context has been
  261.  * established.
  262.  * @param resDangerList the resource ID for the list of dangerous
  263.  *                      permissions required by the app
  264.  * @return (fluid)
  265.  */
  266. protected PermissionCheckpoint init( int resDangerList )
  267. {
  268. m_resDangerList = resDangerList ;
  269. m_ctx = ( this.isContextAppCompat() ? m_actCompatContext : m_actContext ) ;
  270. String[] asDangers =
  271. m_ctx.getResources().getStringArray( m_resDangerList ) ;
  272. m_mapGranted = new HashMap<>( asDangers.length ) ;
  273. for( String sDanger : asDangers )
  274. { // Add the status of that permission to the object's map.
  275. m_mapGranted.put( sDanger, checkPermission( m_ctx, sDanger ) ) ;
  276. }
  277. return this ;
  278. }

  279. /**
  280.  * Indicates whether the instance's operational context is an
  281.  * {@link AppCompatActivity}.
  282.  * @return {@code true} if an {@code AppCompatActivity} was used as the
  283.  *  initial context for the instance
  284.  */
  285. protected boolean isContextAppCompat()
  286. { return ( m_actCompatContext != null && m_actContext == null ) ; }

  287. /**
  288.  * This method performs all the checks necessary to determine whether the
  289.  * app has all of its required permissions. If any permission is still
  290.  * denied, then the method will trigger dialogs challenging the user to
  291.  * grant those permissions to the app.
  292.  * @return (fluid)
  293.  */
  294. public PermissionCheckpoint performChecks()
  295. {
  296. if( Build.VERSION.SDK_INT < Build.VERSION_CODES.M )
  297. return this ; // trivially

  298. if( ! this.hasGrantedAll() )
  299. this.promptForPermissions() ;

  300. return this ;
  301. }

  302. /**
  303.  * Evaluates whether all required "dangerous" permissions have been granted.
  304.  *
  305.  * For most permissions, we have cached the state via
  306.  * {@link #processRequestResults}, but for the {@code WRITE_SETTINGS}
  307.  * permission, we have to explicitly re-check it, because there's no way to
  308.  * listen for a state-change event from the Android OS's application
  309.  * manager.
  310.  *
  311.  * @return {@code true} if all permissions are granted.
  312.  */
  313. protected boolean hasGrantedAll()
  314. {
  315. //this.logPermissionState() ;        // Comment out this line for release!
  316. if( m_mapGranted.size() == 0 ) return true ; // No permissions to grant.
  317. if( m_mapGranted.containsKey( PERMISSION_WRITE_SETTINGS ) )
  318. { // Explicitly re-check whether we've been granted WRITE_SETTINGS.
  319. m_mapGranted.put( PERMISSION_WRITE_SETTINGS,
  320. checkPermission( m_ctx, PERMISSION_WRITE_SETTINGS ) ) ;
  321. }
  322. return( ! m_mapGranted.containsValue(false) ) ;    // All marked "true".
  323. }

  324. /**
  325.  * (debug only) Dumps the current state of the permission map to the Android
  326.  * logs.
  327.  * @return (fluid)
  328.  * @see #hasGrantedAll()
  329.  */
  330. protected PermissionCheckpoint logPermissionState()
  331. {
  332. StringBuilder sb = new StringBuilder() ;
  333. sb.append( "DEBUG Current permission state:\n" ) ;
  334. for( HashMap.Entry<String,Boolean> pair : m_mapGranted.entrySet() )
  335. {
  336. sb.append( "\t\t" )
  337.   .append( pair.getKey() )
  338.   .append( "\t" )
  339.   .append( pair.getValue() )
  340.   .append( "\n" )
  341.   ;
  342. }
  343. Log.d( LOG_TAG, sb.toString() ) ;
  344. return this ;
  345. }

  346. /**
  347.  * Prompts the user to accept all "dangerous" permission requests.
  348.  *
  349.  * The method tries to ask for just regularly-dangerous permissions first,
  350.  * then asks for the "write settings" permission if all others have been
  351.  * granted.
  352.  *
  353.  * @return (fluid)
  354.  */
  355. protected PermissionCheckpoint promptForPermissions()
  356. {
  357. ArrayList<String> asRequired = new ArrayList<>() ;
  358. boolean bWriteSettings = false ;
  359. for( HashMap.Entry<String,Boolean> pair : m_mapGranted.entrySet() )
  360. {
  361. if( ! pair.getValue() )
  362. { // Found a permission that has not yet been granted.
  363. if( pair.getKey().equals( PERMISSION_WRITE_SETTINGS ) )
  364. {
  365. Log.w( LOG_TAG,
  366. "App needs special WRITE_SETTINGS permission." ) ;
  367. bWriteSettings = true;
  368. }
  369. else
  370. {
  371. Log.w( LOG_TAG, (new StringBuilder())
  372. .append( "App needs " )
  373. .append( pair.getKey() )
  374. .append( " permission." )
  375. .toString()
  376. );
  377. asRequired.add( pair.getKey() );
  378. }
  379. }
  380. }
  381. if( asRequired.size() > 0 )
  382. this.promptForOtherDangers( asRequired ) ;
  383. else if( bWriteSettings && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M )
  384. this.promptForWriteSettingsPermission();

  385. return this ;
  386. }

  387. /**
  388.  * Prompts the user to enable the "write settings" permission, which can be
  389.  * administered only on a special Android OS dialog.
  390.  * @return (fluid)
  391.  */
  392. @RequiresApi( api = Build.VERSION_CODES.M )
  393. protected PermissionCheckpoint promptForWriteSettingsPermission()
  394. {
  395. if( ! m_bDialogVisible )
  396. {
  397. final WriteSettingsDialogClickListener listener =
  398. new WriteSettingsDialogClickListener() ;
  399. if( this.isContextAppCompat() )
  400. this.promptWithAppCompatDialog( listener ) ;
  401. else
  402. this.promptWithModernDialog( listener ) ;
  403. }
  404. return this ;
  405. }

  406. /**
  407.  * Defines a click listener for the dialog that prompts the user to grant
  408.  * the special {@code WRITE_SETTINGS} permission.
  409.  * @see PermissionCheckpoint#promptWithModernDialog
  410.  * @see PermissionCheckpoint#promptWithAppCompatDialog
  411.  * @since zer0bandwidth-net/android 0.0.3 (#10)
  412.  */
  413. protected class WriteSettingsDialogClickListener
  414. implements DialogInterface.OnClickListener
  415. {
  416. /** An alert from the modern library. */
  417. public android.app.AlertDialog m_diaModernParent = null ;

  418. /** An alert dialog from the compatibility library. */
  419. public android.support.v7.app.AlertDialog m_diaCompatParent = null ;

  420. /** Sets the parent dialog from the modern library. */
  421. public WriteSettingsDialogClickListener setParent( android.app.AlertDialog dia )
  422. { m_diaModernParent = dia ; m_diaCompatParent = null ; return this ; }

  423. /** Sets the parent dialog from the compatibility library. */
  424. public WriteSettingsDialogClickListener setParent( android.support.v7.app.AlertDialog dia )
  425. { m_diaModernParent = null ; m_diaCompatParent = dia ; return this ; }

  426. @RequiresApi( api = Build.VERSION_CODES.M )
  427. @Override
  428. public void onClick( DialogInterface dia, int zButtonID )
  429. {
  430. Intent sig = new Intent( Settings.ACTION_MANAGE_WRITE_SETTINGS ) ;
  431. sig.setData( Uri.parse( "package:" + m_ctx.getPackageName() ) ) ;
  432. m_ctx.startActivity( sig ) ;
  433. if( m_diaModernParent != null ) m_diaModernParent.dismiss() ;
  434. else if( m_diaCompatParent != null ) m_diaCompatParent.dismiss() ;
  435. }
  436. }

  437. /**
  438.  * If our parent activity is an {@link AppCompatActivity}, then we need to
  439.  * use the corresponding {@code AlertDialog} flavor to prompt for the
  440.  * permission.
  441.  * @param listener the click listener for the "OK" button
  442.  * @return (fluid)
  443.  * @see #promptForWriteSettingsPermission
  444.  */
  445. protected PermissionCheckpoint promptWithAppCompatDialog(
  446. WriteSettingsDialogClickListener listener )
  447. {
  448. final android.support.v7.app.AlertDialog diaAnnounce =
  449. new android.support.v7.app.AlertDialog.Builder( m_ctx )
  450. .setTitle( R.string.diaAnnounce_title )
  451. .setMessage( R.string.diaAnnounce_message )
  452. .setCancelable( false )
  453. .setPositiveButton( android.R.string.ok, listener )
  454. .create()
  455. ;
  456. listener.setParent( diaAnnounce ) ;
  457. diaAnnounce.show() ;
  458. return this ;
  459. }

  460. /**
  461.  * If our parent activity is an {@link Activity}, then we need to use the
  462.  * corresponding {@code AlertDialog} flavor to prompt for the permission.
  463.  * @param listener the click listener for the "OK" button
  464.  * @return (fluid)
  465.  * @see #promptForWriteSettingsPermission
  466.  */
  467. protected PermissionCheckpoint promptWithModernDialog(
  468. WriteSettingsDialogClickListener listener )
  469. {
  470. final android.app.AlertDialog diaAnnounce =
  471. new android.app.AlertDialog.Builder( m_ctx )
  472. .setTitle( R.string.diaAnnounce_title )
  473. .setMessage( R.string.diaAnnounce_message )
  474. .setCancelable( false )
  475. .setPositiveButton( android.R.string.ok, listener )
  476. .create()
  477. ;
  478. listener.setParent( diaAnnounce ) ;
  479. diaAnnounce.show() ;
  480. return this ;
  481. }

  482. /**
  483.  * Prompts the user to enable "dangerous" permissions.
  484.  * @param asDangers a list of dangerous permissions
  485.  * @return (fluid)
  486.  */
  487. protected PermissionCheckpoint promptForOtherDangers( ArrayList<String> asDangers )
  488. {
  489. m_bDialogVisible = true ;
  490. if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ! this.isContextAppCompat() )
  491. {
  492. m_actContext.requestPermissions(
  493. asDangers.toArray( new String[asDangers.size()] ),
  494. DANGER_REQUEST_CODE ) ;
  495. }
  496. else
  497. {
  498. ActivityCompat.requestPermissions( m_actCompatContext,
  499. asDangers.toArray( new String[asDangers.size()] ),
  500. DANGER_REQUEST_CODE ) ;
  501. }
  502. return this ;
  503. }

  504. /**
  505.  * Dialogs spawned by {@link ActivityCompat#requestPermissions} will call
  506.  * back to this method when the user chooses any option. We will use this
  507.  * callback to re-check the permission array and determine whether we need
  508.  * to try again.
  509.  * @param zCode the request code (we are interested in
  510.  *              {@link #DANGER_REQUEST_CODE})
  511.  * @param asDangers an array of permissions that were requested
  512.  * @param azStatus an array of result indicators
  513.  */
  514. @RequiresApi( api = Build.VERSION_CODES.M )
  515. public void processRequestResults( int zCode,
  516. @NonNull String[] asDangers, @NonNull int[] azStatus )
  517. {
  518. m_bDialogVisible = false ;
  519. Log.d( LOG_TAG, (new StringBuilder())
  520. .append( "Received new status for [" )
  521. .append( asDangers.length )
  522. .append(( asDangers.length == 1 ? "] permission." : "] permissions." ))
  523. .toString()
  524. );
  525. for( int i = 0 ; i < asDangers.length ; i++ )
  526. { // Update our map of permissions to grant status.
  527. if( azStatus[i] == PackageManager.PERMISSION_GRANTED )
  528. m_mapGranted.put( asDangers[i], true ) ;
  529. else if( azStatus[i] == PackageManager.PERMISSION_DENIED )
  530. m_mapGranted.put( asDangers[i], false ) ;
  531. }
  532. }
  533. }