之前调研了动态权限的申请方法,可是国内rom千奇百怪,真正实施起来的时候却不见得那么顺利,以前我们在Application里面承载了太多了业务逻辑,其中包含了需要READ_PHONE_STATE和WRITE_EXTERNAL_STORAGE权限的操作,所以这样的逻辑存在于Application里变得不合理,当然本来存放耗时逻辑在Application的onCreate里面就会影响APP启动速度,权限申请的方法在源码里面,存在于ActivityCompat里面的,所以Activity都没创建的时候是不可能完成权限申请的,所以权限动态申请的难点存在于对以往的逻辑进行搬家方面比较多,本文就传统App如何变成动态申请权限APP做一个探究。
在我们APP里面目前在Activity尚未启动起来就需要权限的有两个地方:
权限 | 用途 |
---|---|
READ_PHONE_STATE | 友盟统计里面appsecurity需要deviceId、osName |
READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE | api请求缓存和插件下载都需要读写权限 |
目前其实还有地图定位的sdk需要定位权限,但是这个倒是可以往后放一放,但是插件化和统计需要的权限无法后移,必须一上来就有这两个权限,不然接口请求之类的都无法进行。就现在这个情况,有如下三种场景:
场景 | 周期方法 |
---|---|
正常启动 | Application onCreate ----> splashActivity —> 首页 |
尝试中偶尔有手机这样 | Application onCreate -----> 其他页面 |
权限手动回收 | 无固定页面 |
总结一下上面的三个情况,其实有一个共同特点,就是启动都经过Activity的生命周期方法。对上述三个场景做公共部分提取:onCreate—>BaseActivity(权限申请和必要初始化)—>相关页面,该方案还需要做到在BaseActivity页面承载一个顶层view,实现和Splash一样的展示效果,对于真正的splash页面我们不做改动,只需要继承BaseActivity页面即可。
对于正常的rom,直接调用google api来检测是否有权限,但是vivo和oppo就不行,所以对于这两个品牌单独取出来,做敲击鉴权,随代码一份,测试敲击时间根据权限不同在虚拟机上耗时1-20ms不等
package com.ymt360.app.mass.permissionUtil.utils;
import android.annotation.SuppressLint;
import android.content.Context;
import android.database.Cursor;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.location.LocationManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.ContactsContract;
import android.support.v4.content.PermissionChecker;
import android.telephony.TelephonyManager;
import android.Manifest;
import android.hardware.Camera;
import java.lang.reflect.Field;
import com.ymt360.app.mass.YMTApp;
import com.ymt360.app.mass.util.OSUtil;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import static android.content.Context.TELEPHONY_SERVICE;
/**
* 对所有危险权限分组做权限判断
* 所属权限组 权限名 权限等级 解释
* 日历 READ_CALENDAR 危险 允许应用程序读取用户的日历数据
* 日历 WRITE_CALENDAR 危险 允许应用程序写入用户的日历数据
* 相机 CAMERA 危险 使用摄像头做相关工作
* 联系人 READ_CONTACTS 危险 读取联系人
* 联系人 WRITE_CONTACTS 危险 写入联系人
* 联系人 GET_ACCOUNTS 危险 允许访问帐户服务中的帐户列表
* 位置 ACCESS_FINE_LOCATION 危险 允许应用访问精确位置
* 位置 ACCESS_COARSE_LOCATION 危险 允许应用访问大致位置
* 麦克风 RECORD_AUDIO 危险 麦克风的使用
* 电话 READ_PHONE_STATE 危险 允许对电话状态进行只读访问,包括设备的电话号码,当前蜂窝网络信息,任何正在进行的呼叫的状态以及设备上注册的任何PhoneAccounts列表
* 电话 CALL_PHONE 危险 允许应用程序在不通过拨号器用户界面的情况下发起电话呼叫,以便用户确认呼叫
* 电话 READ_CALL_LOG 危险 允许应用程序读取用户的通话记录
* 电话 WRITE_CALL_LOG 危险 允许应用程序写入(但不读取)用户的呼叫日志数据
* 电话 ADD_VOICEMAIL 危险 允许应用程序将语音邮件添加到系统中
* 电话 USE_SIP 危险 允许应用程序使用SIP服务
* 电话 PROCESS_OUTGOING_CALLS 危险 允许应用程序查看拨出呼叫期间拨打的号码,并选择将呼叫重定向到其他号码或完全中止呼叫
* 传感器 BODY_SENSORS 危险 允许应用程序访问来自传感器的数据
* 短信 SEND_SMS 危险 允许应用程序发送SMS消息
* 短信 RECEIVE_SMS 危险 允许应用程序接收SMS消息
* 短信 READ_SMS 危险 允许应用程序读取SMS消息
* 短信 RECEIVE_WAP_PUSH 危险 允许应用程序接收WAP推送消息
* 短信 RECEIVE_MMS 危险 允许应用程序监视传入的MMS消息(彩信)
* 存储 READ_EXTERNAL_STORAGE 危险 允许应用程序从外部存储读取
* 存储 WRITE_EXTERNAL_STORAGE 危险 允许应用程序写入外部存储
*
*
* 防止权限判断错误,使用尝试性敲击权限检查,对分组的权限做哥敲击就性
*
*
*/
public class YMTPermissionCheckUtil {
/**
* 权限查询对外公布方法
* @param permission
* @return
*/
public static boolean checkSelfPermission(String permission) {
// 对于非oppo和vivo手机,做正常的权限申请
if (permissionIsNormal()){
return selfPermissionGranted(permission);
}
// 对于国内部分机型做非正常权限申请
switch (permission){
case Manifest.permission.CAMERA:
return checkCameraPermissions();
case Manifest.permission.RECORD_AUDIO:
return checkAudioPermission();
case Manifest.permission.BODY_SENSORS:
return checkSensorsPermission();
case Manifest.permission.READ_CALENDAR:
case Manifest.permission.WRITE_CALENDAR:
return checkCalanderPermission(permission);
case Manifest.permission.READ_CONTACTS:
case Manifest.permission.WRITE_CONTACTS:
case Manifest.permission.GET_ACCOUNTS:
return checkContactsPermission(permission);
case Manifest.permission.ACCESS_FINE_LOCATION:
case Manifest.permission.ACCESS_COARSE_LOCATION:
return checkLocationsPermission(permission);
case Manifest.permission.READ_PHONE_STATE:
case Manifest.permission.CALL_PHONE:
case Manifest.permission.READ_CALL_LOG:
case Manifest.permission.WRITE_CALL_LOG:
case Manifest.permission.ADD_VOICEMAIL:
case Manifest.permission.USE_SIP:
case Manifest.permission.PROCESS_OUTGOING_CALLS:
return checkReadPhoneStatePermission(permission);
case Manifest.permission.SEND_SMS:
case Manifest.permission.RECEIVE_SMS:
case Manifest.permission.READ_SMS:
case Manifest.permission.RECEIVE_WAP_PUSH:
case Manifest.permission.RECEIVE_MMS:
return checkSMSPermission(permission);
case Manifest.permission.READ_EXTERNAL_STORAGE:
case Manifest.permission.WRITE_EXTERNAL_STORAGE:
return checkWritePermission(permission);
default:
return selfPermissionGranted(permission);
}
}
/**
* READ_PHONE_STATE
* @return 是否有读取手机状态的权限
* 电话 READ_PHONE_STATE 危险 允许对电话状态进行只读访问,包括设备的电话号码,当前蜂窝网络信息,任何正在进行的呼叫的状态以及设备上注册的任何PhoneAccounts列表
* 电话 CALL_PHONE 危险 允许应用程序在不通过拨号器用户界面的情况下发起电话呼叫,以便用户确认呼叫
* 电话 READ_CALL_LOG 危险 允许应用程序读取用户的通话记录
* 电话 WRITE_CALL_LOG 危险 允许应用程序写入(但不读取)用户的呼叫日志数据
* 电话 ADD_VOICEMAIL 危险 允许应用程序将语音邮件添加到系统中
* 电话 USE_SIP 危险 允许应用程序使用SIP服务
* 电话 PROCESS_OUTGOING_CALLS 危险 允许应用程序查看拨出呼叫期间拨打的号码,并选择将呼叫重定向到其他号码或完全中止呼叫
* 耗时3ms左右
*/
@SuppressLint({"HardwareIds", "MissingPermission"})
public static boolean checkReadPhoneStatePermission(String permission) {
TelephonyManager tm = (TelephonyManager) YMTApp.getContext().getSystemService(TELEPHONY_SERVICE);
try {
tm.getDeviceId();
return selfPermissionGranted(permission);
}catch (Exception e){
return false;
}
}
/**
* 检查是否拥有读写权限
* WRITE_EXTERNAL_STORAGE
* 耗时 1-12ms
* @return
*/
public static boolean checkWritePermission(String permission) {
File file = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "permission.ymt");
try {
FileOutputStream outputStream = new FileOutputStream(file);
outputStream.flush();
outputStream.close();
file.delete();
} catch (FileNotFoundException e) {
return false;
} catch (IOException e) {
e.printStackTrace();
}
return selfPermissionGranted(permission);
}
/**
* 日历 READ_CALENDAR 危险 允许应用程序读取用户的日历数据
* 日历 WRITE_CALENDAR 危险 允许应用程序写入用户的日历数据
* 检查日历权限
* 耗时 30ms左右
* @return
*/
public static boolean checkCalanderPermission(String permission){
Cursor cursor = null;
try{
String CALANDER_EVENT_URL = "content://com.android.calendar/events";
Uri uri = Uri.parse(CALANDER_EVENT_URL);
cursor = YMTApp.getContext().getContentResolver().query(uri, null, null, null, null);
}catch (Exception e){
return false;
}finally {
if (cursor != null) {
cursor.close();
}
}
return selfPermissionGranted(permission);
}
/**
* 检测相机权限,这个因为直接拿camera还不一定行,当前测试机型:oppo 魅族 小米
* * 相机 CAMERA 危险 使用摄像头做相关工作
* @return
*/
public static boolean checkCameraPermissions() {
Camera mCamera = null;
try {
// 大众路线 过了基本就是有权限
mCamera = Camera.open();
Camera.Parameters mParameters = mCamera.getParameters();
mCamera.setParameters(mParameters);
// 特殊路线,oppo和vivo得根据rom去反射mHasPermission
if (OSUtil.getInstance().isColorOS() || OSUtil.getInstance().isFuntouchOS()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Field fieldPassword;
if(mCamera == null) mCamera = Camera.open();
//通过反射去拿相机是否获得了权限
fieldPassword = mCamera.getClass().getDeclaredField("mHasPermission");
fieldPassword.setAccessible(true);
Boolean result = (Boolean) fieldPassword.get(mCamera);
if (mCamera != null) {
mCamera.release();
}
mCamera = null;
return result;
}
}
} catch (Exception e) {
if (mCamera != null) {
mCamera.release();
}
mCamera = null;
}
return selfPermissionGranted(Manifest.permission.CAMERA);
}
/**
* 联系人 READ_CONTACTS 危险 读取联系人
* 联系人 WRITE_CONTACTS 危险 写入联系人
* 联系人 GET_ACCOUNTS 危险 允许访问帐户服务中的帐户列表
* @return
*/
public static boolean checkContactsPermission(String permission){
Cursor cursor = null;
try {
cursor = YMTApp.getContext().getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
if (cursor != null) {
cursor.close();
}
}
return selfPermissionGranted(permission);
}
/**
*
* 位置 ACCESS_FINE_LOCATION 危险 允许应用访问精确位置
* 位置 ACCESS_COARSE_LOCATION 危险 允许应用访问大致位置
* @return
*/
public static boolean checkLocationsPermission(String permission){
try {
LocationManager lm = (LocationManager) YMTApp.getContext().getSystemService(Context.LOCATION_SERVICE);
// 无用操作
lm.getAllProviders();
}catch (Exception e){
return false;
}
return selfPermissionGranted(permission);
}
/**
*
* 麦克风 RECORD_AUDIO 危险 麦克风的使用
* @return
*/
public static boolean checkAudioPermission(){
try {
AudioManager audioManager = (AudioManager)YMTApp.getContext().getSystemService(Context.AUDIO_SERVICE);
// 无用操作
audioManager.getMode();
}catch (Exception e){
return false;
}
return selfPermissionGranted(Manifest.permission.RECORD_AUDIO);
}
/**
*
* 传感器 BODY_SENSORS 危险 允许应用程序访问来自传感器的数据
* @return
*/
public static boolean checkSensorsPermission(){
try {
SensorManager sorMgr = (SensorManager) YMTApp.getContext().getSystemService(Context.SENSOR_SERVICE);
// 无用操作
sorMgr.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
}catch (Exception e){
return false;
}
return selfPermissionGranted(Manifest.permission.BODY_SENSORS);
}
/**
*
* 短信 SEND_SMS 危险 允许应用程序发送SMS消息
* 短信 RECEIVE_SMS 危险 允许应用程序接收SMS消息
* 短信 READ_SMS 危险 允许应用程序读取SMS消息
* 短信 RECEIVE_WAP_PUSH 危险 允许应用程序接收WAP推送消息
* 短信 RECEIVE_MMS 危险 允许应用程序监视传入的MMS消息(彩信)
* * @return
*/
public static boolean checkSMSPermission(String permission){
Cursor cursor = null;
try {
Uri uri = Uri.parse("content://sms/failed");
String[] projection = new String[] { "_id", "address", "person",
"body", "date", "type", };
cursor = YMTApp.getContext().getContentResolver().query(uri, projection, null,
null, "date desc");
} catch (Exception e){
return false;
} finally {
if (cursor != null) {
cursor.close();
}
}
return selfPermissionGranted(permission);
}
/**
* api级别权限查询
* @param permission
* @return
*/
public static boolean selfPermissionGranted(String permission) {
return PermissionChecker.checkSelfPermission(YMTApp.getContext(), permission) == PermissionChecker.PERMISSION_GRANTED;
}
public static boolean permissionIsNormal(){
try {
if (OSUtil.getInstance().isColorOS() || OSUtil.getInstance().isFuntouchOS()){
return false;
}
} catch (IOException e) {
e.printStackTrace();
return false;
}
return true;
}
}
上述方法在小米、华为、vivo、oppo、meizu、谷歌上测试均无问题,后期项目上线如有问题更新文档。
判断是否需要必要的初始化,需要的时候就弹一个view于顶层,这样跟欢迎页一样,尝试下PopupWindow,看是否可以实现。
这个部分看起来很简单,但其实有很多弊端,首先面临的问题是初始化操作是耗时的操作(比如下载插件),虽然说这些都是开线程来解决问题的,但是诸如语音或者异步图片都可能存在于之前的onCreate里面,然后初始化未完成时根本不可能做这些操作,所以如果不暂停onCreate方法等待初始化结果是不太可能的,但是又不可能去手动调用Activity的生命周期方法,所以这里的问题都基本暴露出来了:
问题:onCreate存在异步耗时操作,而且有场景存在依赖于耗时操作结果的地方,且不可以或者不建议手动调用生命周期方法
思路:获取当前页面intent,这样可以得到intent所带有的数据,因为这种情况只存在未初始化的过程,所以对别的页面也没有影响,通过intent来等待初始化完成然后做reload操作,在BaseActivity里面做一个popupWindows,为伪欢迎页
大体思路代码:
public abstract class BaseActivity extends AppCompatActivity {
private String TAG = getClass().getSimpleName();
// 模拟参数:表示是否需要初始化
private boolean needInit = (System.currentTimeMillis()%2 == 0);
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_base);
if (needInit){
initConfig();
return;
}
initView();
}
public void reload() {
Intent intent = getIntent();
overridePendingTransition(0, 0);
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
finish();
overridePendingTransition(0, 0);
startActivity(intent);
}
/**
* 模拟 初始化操作
*/
private void initConfig() {
new Handler().postDelayed(new Thread(new Runnable() {
@Override
public void run() {
runOnUiThread(new Runnable() {
@Override
public void run() {
// 操作完成后reload当前页面
reload();
}
});
}
}),5000);
}
@Override
protected void onResume() {
super.onResume();
}
private void splash() {
SplashPopupWindow popupWindow = new SplashPopupWindow( this);
popupWindow.setTouchable(true);
popupWindow.showAtLocation(getWindow().getDecorView(), Gravity.CENTER, 0, 0);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
// TODO Auto-generated method stub
super.onWindowFocusChanged(hasFocus);
if(hasFocus && needInit){
splash();
}
}
protected abstract void initView();
}
顺便提一下PopupWindow全屏显示的问题,只需要设置
全屏宽高已经设置 this.setClippingEnabled(false);
public class SplashPopupWindow extends PopupWindow {
private View mMenuView;
private LayoutInflater inflater;
public SplashPopupWindow(final Context context) {
super(context);
inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mMenuView = inflater.inflate(R.layout.popup_splash, null);
this.setContentView(mMenuView);
//sdk > 21 解决 标题栏没有办法遮罩的问题
this.setClippingEnabled(false);
this.setWidth(WindowManager.LayoutParams.MATCH_PARENT);
this.setHeight(getScreenWidth(context));//屏幕的高
this.setFocusable(true);
// 实例化一个ColorDrawable颜色为半透明
ColorDrawable dw = new ColorDrawable(0x80000000);
this.setBackgroundDrawable(dw);
}
public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager) context
.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics outMetrics = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(outMetrics);
return outMetrics.heightPixels;
}
就此,解决了前面提到的第二步。
权限申请的api依赖于Activity,如果在非Activity的场景(dialog或者说service)所以在申请权限的时候先坚定权限,需要申请的时候新建一个无感知的Activity来完成操作,完成之后返回结果就行,其实有RxPermission的实现方式,但这个也强依赖于Activity,所以就直接使用系统api吧,这个方案只依赖于Intent跳转的上下文,而这个Application就可以满足。
/**
* 为了防止权限申请带来参数传递的问题,引入无感Activity,简单的好处是:
* 1,不用再关心传递Activity进来
* 2,任何地方都可以申请权限,Service,View,Fragment...
* 3, 使用便捷
*/
public class YMTPermissionActivity extends Activity {
private static final String PARAM_PERMISSION = "param_permission";
private static final String PARAM_REQUEST_CODE = "param_request_code";
private static IPermission permissionListener;
private String[] mPermissions;
private int mRequestCode;
public static void permissionRequest(Context context, String[] permissions, int requestCode, IPermission iPermission) {
permissionListener = iPermission;
Intent intent = new Intent(context, YMTPermissionActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Bundle bundle = new Bundle();
bundle.putStringArray(PARAM_PERMISSION, permissions);
bundle.putInt(PARAM_REQUEST_CODE, requestCode);
intent.putExtras(bundle);
context.startActivity(intent);
if (context instanceof Activity) {
((Activity) context).overridePendingTransition(0, 0);
}
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.mPermissions = getIntent().getStringArrayExtra(PARAM_PERMISSION);
this.mRequestCode = getIntent().getIntExtra(PARAM_REQUEST_CODE, 0);
if (this.mPermissions == null || this.mPermissions.length <= 0) {
finish();
return;
}
// 检测是否已经授权
if (YMTPermissionHelper.getInstance().hasSelfPermissions(this, this.mPermissions)) {
if (permissionListener != null) {
permissionListener.ymtGanted();
}
finish();
} else {
// 申请授权
ActivityCompat.requestPermissions(this, this.mPermissions, this.mRequestCode);
}
}
/**
* grantResults对应于申请的结果,这里的数组对应于申请时的第二个权限字符串数组。
* 如果你同时申请两个权限,那么grantResults的length就为2,分别记录你两个权限的申请结果
*
*/
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[], @NonNull int[] grantResults) {
if (requestCode == this.mRequestCode) {
//验证权限返回值是否授权
if (YMTPermissionHelper.getInstance().verifyPermissions(grantResults)) {
if (permissionListener != null) {
permissionListener.ymtGanted();
}
} else if (!YMTPermissionHelper.getInstance().shouldShowRequestPermissionRationale(this, permissions)) {
if (permissions.length != grantResults.length) {
return;
}
List deniedList = new ArrayList<>();
for (int i = 0; i < grantResults.length; ++i) {
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
deniedList.add(permissions[i]);
}
}
if (permissionListener != null) {
permissionListener.ymtDenied(requestCode, deniedList);
}
} else {
if (permissionListener != null) {
permissionListener.ymtCanceled(requestCode);
}
}
}
finish();
}
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}
}
其中:
这样的权限申请从视觉上是完全无感知有Activity跳转的。而且只需要在封装一个Util类,持有一个Application的上下文,或者使用的时候直接去获取Application的上下文就行。
目前版本项目还在开发中,因为权限更改牵扯到的初始化的东西更改太多,工作量较大,如果有期有改动更新该blog,但方案应该不会太做改动。
该方案在测试的时候真的还算可以,至少表现在预期内,但是在测试的时候出现问题。
所谓的敲击鉴权其实是直接操作看他会不会crash,然后try-catch捕获这个异常为没有权限,但是最好测试的结果不是很满意,因为国内ROM同一个厂商的不通版本也不相同,我做这个测试和方案的时候是挑选手里的手机和云测上的主流机型,但是测试的结果是偶尔有手机在敲击鉴权的时候系统会自动弹框申请权限,这样如果用户给了权限还好,一旦拒绝就可能弹了2次系统申请权限的弹框,如果你权限回来还判断,系统会再次弹起,这个用户体验实在是不太乐观,毕竟Android手机机型太多了,建议使用系统级判断,哪怕结果不对,也有系统申请权限来保底,当然可以建立系统白名单的模式,因为上述方法在某些机型上是好用的,我手里是OPPO A57,然后在云测上找了一些手机,很巧合的是我测试的手机都好用,但是测试有一部Vivo Y85A 就会弹框2次。