PermissionCheckpoint.java

package net.zer0bandwidth.android.lib.security;

import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.support.annotation.NonNull;
import android.support.annotation.RequiresApi;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;

import net.zer0bandwidth.android.lib.R;

import java.util.ArrayList;
import java.util.HashMap;

/**
 * This class checks a specified list of "dangerous" permissions, forcing the
 * user to grant each one at runtime.
 *
 * <h3>Using this Class</h3>
 *
 * <h4>Defining the List of Permissions</h4>
 *
 * <p>By default, the list of dangerous permissions is drawn from the string
 * array resource named by {@link R.array#asDangerousPermissionsRequired} in
 * this library. By default, the array is empty; the app consuming this library
 * may override the resource with its own values.</p>
 *
 * <p>The consumer of this class may, alternatively, supply a different resource
 * ID to one of the longer constructors. This will <i>replace</i> the default
 * array resource as the definitive list of required permissions.</p>
 *
 * <h4>Maintain a Persistent Instance in the Activity</h4>
 *
 * <p>The main activity of the app should construct an instance of this class in
 * its {@code onCreate} method, using itself as the operational context, and
 * optionally specifying an alternative list of permissions.</p>
 *
 * <pre>
 *     protected PermissionCheckpoint m_perms = null ;
 *
 *    {@literal @Override}
 *     protected void onCreate( Bundle bndlState )
 *     {
 *         super.onCreate(bndlState) ;
 *         this.setContentView( R.layout.whatever ) ;
 *         if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M )
 *             m_perms = new PermissionCheckpoint(this) ;
 *         // ...
 *     }
 * </pre>
 *
 * <h4>Refresh Permission State on Each {@code onResume}</h4>
 *
 * <p>The activity's {@code onResume} method must include a call to this class's
 * {@link #performChecks()} method, which re-evaluates the app's permissions
 * each time it is invoked.</p>
 *
 * <pre>
 *    {@literal @Override}
 *     protected void onResume()
 *     {
 *         super.onResume() ;
 *         if( m_perms != null ) m_perms.performChecks() ;
 *     }
 * </pre>
 *
 * <h4>Implement the Permission Request Callback</h4>
 *
 * <p>Finally, the activity must provide a callback for the Android OS in
 * response to a permission request.</p>
 *
 * <h5>{@link Activity}</h5>
 *
 * <p>Modern activities include their own callback method, which must be
 * overridden in your activity.</p>
 *
 * <pre>
 *     public class MyActivity extends Activity
 *     {
 *         protected PermissionCheckpoint m_perms = null ; // init in onCreate()
 *
 *         // etc
 *
 *        {@literal @RequiresApi( api = Build.VERSION_CODES.M )}
 *        {@literal @Override}
 *         public void onRequestPermissionsResult( int zRequestCode,
 *            {@literal @NonNull} String[] asPermissions,{@literal @NonNull} int[] azStatus )
 *         {
 *             super.onRequestPermissionsResult( zRequestCode, asPermissions, azStatus ) ;
 *             if( m_perms != null )
 *                 m_perms.processRequestResults( zRequestCode, asPermissions, azStatus ) ;
 *         }
 *     }
 * </pre>
 *
 * <h5>{@link AppCompatActivity}</h5>
 *
 * <p>An activity from the compatibility library should implement the
 * {@link ActivityCompat.OnRequestPermissionsResultCallback} interface.</p>
 *
 * <pre>
 *     public class MyCompatActivity extends AppCompatActivity
 *     implements ActivityCompat.OnRequestPermissionsResultCallback
 *     {
 *         protected PermissionCheckpoint m_perms = null ; // init in onCreate()
 *
 *         // etc
 *
 *        {@literal @RequiresApi( api = Build.VERSION_CODES.M )}
 *        {@literal @Override}
 *         public void onRequestPermissionsResult( int zRequestCode,
 *            {@literal @NonNull} String[] asPermissions,{@literal @NonNull} int[] azStatus )
 *         {
 *             if( m_perms != null )
 *                 m_perms.processRequestResults( zRequestCode, asPermissions, azStatus ) ;
 *         }
 *     }
 * </pre>
 *
 * <h3>Acknowledgements</h3>
 *
 * Special thanks to {@code @dapayne1} for doing the heavy lifting on the
 * research for Android 6 permissions management, and providing the original
 * reference implementation from which this class was adapted.
 *
 * @since zer0bandwidth-net/android 0.0.3 (#10)
 */
@SuppressWarnings( "unused" )                              // This is a library.
public class PermissionCheckpoint
{
	public static final String LOG_TAG =
			PermissionCheckpoint.class.getSimpleName() ;

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

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

	/**
	 * Static method allowing any class to check the momentary status of a
	 * permission.
	 * @param ctx the context in which to perform the check
	 * @param sPermission the qualified name of the permission to check
	 * @return {@code true} iff the permission has been granted to the app
	 */
	public static boolean checkPermission( Context ctx, String sPermission )
	{
		boolean bGranted ;
		if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
		 && PERMISSION_WRITE_SETTINGS.equals(sPermission) )
		{
			bGranted = Settings.System.canWrite( ctx ) ;
		}
		else
		{
			final int zStatus =
					ContextCompat.checkSelfPermission( ctx, sPermission ) ;
			bGranted = ( zStatus == PackageManager.PERMISSION_GRANTED ) ;
		}
		Log.d( LOG_TAG, (new StringBuilder())
				.append( "Permission [" )
				.append( sPermission )
				.append(( bGranted ? "]   GRANTED" : "]   DENIED" ))
				.toString()
			);
		return bGranted ;
	}

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

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

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

	/**
	 * The default string array resource in which dangerous permissions are
	 * defined. Apps that use this library may choose to override this
	 * {@link R.array#asDangerousPermissionsRequired} resource, or have their
	 * own resource which is passed into the constructor.
	 */
	protected static final int DEFAULT_DANGER_LIST_RESOURCE =
			R.array.asDangerousPermissionsRequired ;
	/**
	 * The resource ID of the string array that defines the list of dangerous
	 * permissions that should be requested. This defaults to the value of
	 * {@link #DEFAULT_DANGER_LIST_RESOURCE}, which is
	 * {@link R.array#asDangerousPermissionsRequired}.
	 */
	protected int m_resDangerList = DEFAULT_DANGER_LIST_RESOURCE ;

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

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

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

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

	/**
	 * Initializes the instance with an {@link Activity} as the context.
	 * @param act the activity which provides operational context
	 * @param resDangerList the resource ID for the list of dangerous
	 *                      permissions required by the app
	 */
	public PermissionCheckpoint( Activity act, int resDangerList )
	{
		m_actCompatContext = null ;
		m_actContext = act ;
		this.init( resDangerList ) ;
	}

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

	/**
	 * Initializes the instance with an {@link AppCompatActivity} as the context.
	 * @param act the activity which provides operational context
	 * @param resDangerList the resource ID for the list of dangerous
	 *                      permissions required by the app
	 */
	public PermissionCheckpoint( AppCompatActivity act, int resDangerList )
	{
		m_actCompatContext = act ;
		m_actContext = null ;
		this.init( resDangerList ) ;
	}

	/**
	 * Initializes various fields within the instance, once context has been
	 * established.
	 * @param resDangerList the resource ID for the list of dangerous
	 *                      permissions required by the app
	 * @return (fluid)
	 */
	protected PermissionCheckpoint init( int resDangerList )
	{
		m_resDangerList = resDangerList ;
		m_ctx = ( this.isContextAppCompat() ? m_actCompatContext : m_actContext ) ;
		String[] asDangers =
				m_ctx.getResources().getStringArray( m_resDangerList ) ;
		m_mapGranted = new HashMap<>( asDangers.length ) ;
		for( String sDanger : asDangers )
		{ // Add the status of that permission to the object's map.
			m_mapGranted.put( sDanger, checkPermission( m_ctx, sDanger ) ) ;
		}
		return this ;
	}

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

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

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

		return this ;
	}

	/**
	 * Evaluates whether all required "dangerous" permissions have been granted.
	 *
	 * For most permissions, we have cached the state via
	 * {@link #processRequestResults}, but for the {@code WRITE_SETTINGS}
	 * permission, we have to explicitly re-check it, because there's no way to
	 * listen for a state-change event from the Android OS's application
	 * manager.
	 *
	 * @return {@code true} if all permissions are granted.
	 */
	protected boolean hasGrantedAll()
	{
//		this.logPermissionState() ;        // Comment out this line for release!
		if( m_mapGranted.size() == 0 ) return true ; // No permissions to grant.
		if( m_mapGranted.containsKey( PERMISSION_WRITE_SETTINGS ) )
		{ // Explicitly re-check whether we've been granted WRITE_SETTINGS.
			m_mapGranted.put( PERMISSION_WRITE_SETTINGS,
					checkPermission( m_ctx, PERMISSION_WRITE_SETTINGS ) ) ;
		}
		return( ! m_mapGranted.containsValue(false) ) ;    // All marked "true".
	}

	/**
	 * (debug only) Dumps the current state of the permission map to the Android
	 * logs.
	 * @return (fluid)
	 * @see #hasGrantedAll()
	 */
	protected PermissionCheckpoint logPermissionState()
	{
		StringBuilder sb = new StringBuilder() ;
		sb.append( "DEBUG Current permission state:\n" ) ;
		for( HashMap.Entry<String,Boolean> pair : m_mapGranted.entrySet() )
		{
			sb.append( "\t\t" )
			  .append( pair.getKey() )
			  .append( "\t" )
			  .append( pair.getValue() )
			  .append( "\n" )
			  ;
		}
		Log.d( LOG_TAG, sb.toString() ) ;
		return this ;
	}

	/**
	 * Prompts the user to accept all "dangerous" permission requests.
	 *
	 * The method tries to ask for just regularly-dangerous permissions first,
	 * then asks for the "write settings" permission if all others have been
	 * granted.
	 *
	 * @return (fluid)
	 */
	protected PermissionCheckpoint promptForPermissions()
	{
		ArrayList<String> asRequired = new ArrayList<>() ;
		boolean bWriteSettings = false ;
		for( HashMap.Entry<String,Boolean> pair : m_mapGranted.entrySet() )
		{
			if( ! pair.getValue() )
			{ // Found a permission that has not yet been granted.
				if( pair.getKey().equals( PERMISSION_WRITE_SETTINGS ) )
				{
					Log.w( LOG_TAG,
							"App needs special WRITE_SETTINGS permission." ) ;
					bWriteSettings = true;
				}
				else
				{
					Log.w( LOG_TAG, (new StringBuilder())
							.append( "App needs " )
							.append( pair.getKey() )
							.append( " permission." )
							.toString()
						);
					asRequired.add( pair.getKey() );
				}
			}
		}
		if( asRequired.size() > 0 )
			this.promptForOtherDangers( asRequired ) ;
		else if( bWriteSettings && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M )
			this.promptForWriteSettingsPermission();

		return this ;
	}

	/**
	 * Prompts the user to enable the "write settings" permission, which can be
	 * administered only on a special Android OS dialog.
	 * @return (fluid)
	 */
	@RequiresApi( api = Build.VERSION_CODES.M )
	protected PermissionCheckpoint promptForWriteSettingsPermission()
	{
		if( ! m_bDialogVisible )
		{
			final WriteSettingsDialogClickListener listener =
					new WriteSettingsDialogClickListener() ;
			if( this.isContextAppCompat() )
				this.promptWithAppCompatDialog( listener ) ;
			else
				this.promptWithModernDialog( listener ) ;
		}
		return this ;
	}

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

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

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

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

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

	/**
	 * If our parent activity is an {@link AppCompatActivity}, then we need to
	 * use the corresponding {@code AlertDialog} flavor to prompt for the
	 * permission.
	 * @param listener the click listener for the "OK" button
	 * @return (fluid)
	 * @see #promptForWriteSettingsPermission
	 */
	protected PermissionCheckpoint promptWithAppCompatDialog(
			WriteSettingsDialogClickListener listener )
	{
		final android.support.v7.app.AlertDialog diaAnnounce =
			new android.support.v7.app.AlertDialog.Builder( m_ctx )
				.setTitle( R.string.diaAnnounce_title )
				.setMessage( R.string.diaAnnounce_message )
				.setCancelable( false )
				.setPositiveButton( android.R.string.ok, listener )
				.create()
				;
		listener.setParent( diaAnnounce ) ;
		diaAnnounce.show() ;
		return this ;
	}

	/**
	 * If our parent activity is an {@link Activity}, then we need to use the
	 * corresponding {@code AlertDialog} flavor to prompt for the permission.
	 * @param listener the click listener for the "OK" button
	 * @return (fluid)
	 * @see #promptForWriteSettingsPermission
	 */
	protected PermissionCheckpoint promptWithModernDialog(
			WriteSettingsDialogClickListener listener )
	{
		final android.app.AlertDialog diaAnnounce =
			new android.app.AlertDialog.Builder( m_ctx )
				.setTitle( R.string.diaAnnounce_title )
				.setMessage( R.string.diaAnnounce_message )
				.setCancelable( false )
				.setPositiveButton( android.R.string.ok, listener )
				.create()
				;
		listener.setParent( diaAnnounce ) ;
		diaAnnounce.show() ;
		return this ;
	}

	/**
	 * Prompts the user to enable "dangerous" permissions.
	 * @param asDangers a list of dangerous permissions
	 * @return (fluid)
	 */
	protected PermissionCheckpoint promptForOtherDangers( ArrayList<String> asDangers )
	{
		m_bDialogVisible = true ;
		if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ! this.isContextAppCompat() )
		{
			m_actContext.requestPermissions(
					asDangers.toArray( new String[asDangers.size()] ),
					DANGER_REQUEST_CODE ) ;
		}
		else
		{
			ActivityCompat.requestPermissions( m_actCompatContext,
					asDangers.toArray( new String[asDangers.size()] ),
					DANGER_REQUEST_CODE ) ;
		}
		return this ;
	}

	/**
	 * Dialogs spawned by {@link ActivityCompat#requestPermissions} will call
	 * back to this method when the user chooses any option. We will use this
	 * callback to re-check the permission array and determine whether we need
	 * to try again.
	 * @param zCode the request code (we are interested in
	 *              {@link #DANGER_REQUEST_CODE})
	 * @param asDangers an array of permissions that were requested
	 * @param azStatus an array of result indicators
	 */
	@RequiresApi( api = Build.VERSION_CODES.M )
	public void processRequestResults( int zCode,
		@NonNull String[] asDangers, @NonNull int[] azStatus )
	{
		m_bDialogVisible = false ;
		Log.d( LOG_TAG, (new StringBuilder())
				.append( "Received new status for [" )
				.append( asDangers.length )
				.append(( asDangers.length == 1 ? "] permission." : "] permissions." ))
				.toString()
			);
		for( int i = 0 ; i < asDangers.length ; i++ )
		{ // Update our map of permissions to grant status.
			if( azStatus[i] == PackageManager.PERMISSION_GRANTED )
				m_mapGranted.put( asDangers[i], true ) ;
			else if( azStatus[i] == PackageManager.PERMISSION_DENIED )
				m_mapGranted.put( asDangers[i], false ) ;
		}
	}
}