Android使用Hook完美解决Only fullscreen opaque activities can request orientation的crash问题

今天讲的是target升级为27后高频出现的crash,Only fullscreen opaque activities can request orientation的crash,当targetsdk>=27的时候,且手机Android系统是26,27的时候会crash(华为和小米8.1自己做了优化所以不会出现这个问题,其他品牌手机没有测试)。因为开发的是SDK,所以在适配上很是无奈,SDK提供给使用方,不能限制使用方的targetSdk。如果是APP开发,targetsdk随意设置的话,以下这个仅供参考建议,下面我们就开始分析和解决之路。

一、分析源码,寻找crash源头

step1:targetsdk为26 Activity的onCreate方法源码

if (getApplicationInfo().targetSdkVersion > O && mActivityInfo.isFixedOrientation()) {
	final TypedArray ta = obtainStyledAttributes(com.android.internal.R.styleable.Window);
    final boolean isTranslucentOrFloating = ActivityInfo.isTranslucentOrFloating(ta);
    ta.recycle();

    if (isTranslucentOrFloating) {
		throw new IllegalStateException(
      	 "Only fullscreen opaque activities can request orientation");
    }
}

从26的源码中可看出,当targetsdk>26,设置了屏幕方向,并且是透明的会报上述错误。证明在Android8.0手机上应用如果是targetsdk>26,设置了透明和屏幕方向则会挂掉

step2:targetsdk为27 Activity的onCreate方法源码

if (getApplicationInfo().targetSdkVersion >= O_MR1 && mActivityInfo.isFixedOrientation(){
	final TypedArray ta = obtainStyledAttributes(com.android.internal.R.styleable.Window);
	final boolean isTranslucentOrFloating = ActivityInfo.isTranslucentOrFloating(ta);
   	ta.recycle();

	if (isTranslucentOrFloating) {
   		throw new IllegalStateException(
       		"Only fullscreen opaque activities can request orientation");
 	}
}

从27的源码中可看出,当targetsdk>=27,设置了屏幕方向,并且是透明的会报上述错误。证明在Android8.1手机上应用如果是targetsdk>=27,设置了透明和屏幕方向则会挂掉

step3:targetsdk为28 时,去掉了设置方向和透明冲突crash代码

targetsdk为28 Activity的onCreate方法源码中去掉了上述代码,不做限制。

step4:解释透明条件和固定方向

具体的什么是透明呢,看源码isTranslucentOrFloating,符合以下条件:

/**
* Determines whether the {@link Activity} is considered translucent or floating.
* @hide
*/
public static boolean isTranslucentOrFloating(TypedArray attributes) {
	final boolean isTranslucent = attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsTranslucent,
                        false);
    final boolean isSwipeToDismiss = !attributes.hasValue( com.android.internal.R.styleable.Window_windowIsTranslucent)
                && attributes.getBoolean(com.android.internal.R.styleable.Window_windowSwipeToDismiss, false);
	final boolean isFloating =attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsFloating,
                        false);

	return isFloating || isTranslucent || isSwipeToDismiss;
}

什么是固定方向呢?

/**
* Returns true if the activity's orientation is fixed.
* @hide
*/
public boolean isFixedOrientation() {
    return isFixedOrientationLandscape() || isFixedOrientationPortrait()
            || screenOrientation == SCREEN_ORIENTATION_LOCKED;
}

满足以下条件,就是满足固定方向

 orientation == SCREEN_ORIENTATION_PORTRAIT1|| orientation == SCREEN_ORIENTATION_SENSOR_PORTRAIT7|| orientation == SCREEN_ORIENTATION_REVERSE_PORTRAIT9|| orientation == SCREEN_ORIENTATION_USER_PORTRAIT;12)
 orientation == SCREEN_ORIENTATION_LANDSCAPE0|| orientation == SCREEN_ORIENTATION_SENSOR_LANDSCAPE6|| orientation == SCREEN_ORIENTATION_REVERSE_LANDSCAPE8|| orientation == SCREEN_ORIENTATION_USER_LANDSCAPE;11)
screenOrientation == SCREEN_ORIENTATION_LOCKED14

step5:方法分析,找出入手点

crash分析表现

target/手机系统版本 crash
26/(Android8.0,Android8.1,Android9.0)
27/Android8.0
27/Android8.1
27/Android9.0
28/(Android8.0)
28/(Android8.1)
28/(Android9.0)

根据测试结果,当targetsdk>=27的时候,且手机Android系统是26,27的时候会crash 从上面的step源码中可以总结出我们下面的切入点:透明条件和方向

方法分析一:否定targetsdk条件:这里作为sdk不推荐使用,App请随意
方法分析二:否定透明条件,Theme中属性

1、windowIsTranslucent 是否透明
2、windowIsFloating 通常适用于Activity作为Dialog,或者Panel
3、windowSwipeToDismiss 目前常用的Theme中还没发现

方法分析三:否定固定方向条件

目前Android中使用的所有方向有:

    public static final int SCREEN_ORIENTATION_UNSET = -2;
    public static final int SCREEN_ORIENTATION_UNSPECIFIED = -1;
    public static final int SCREEN_ORIENTATION_LANDSCAPE = 0;
    public static final int SCREEN_ORIENTATION_PORTRAIT = 1;
    public static final int SCREEN_ORIENTATION_USER = 2;
    public static final int SCREEN_ORIENTATION_BEHIND = 3;
    public static final int SCREEN_ORIENTATION_SENSOR = 4;
    public static final int SCREEN_ORIENTATION_NOSENSOR = 5;
    public static final int SCREEN_ORIENTATION_SENSOR_LANDSCAPE = 6;
    public static final int SCREEN_ORIENTATION_SENSOR_PORTRAIT = 7;
    public static final int SCREEN_ORIENTATION_REVERSE_LANDSCAPE = 8;
    public static final int SCREEN_ORIENTATION_REVERSE_PORTRAIT = 9;
    public static final int SCREEN_ORIENTATION_FULL_SENSOR = 10;
    public static final int SCREEN_ORIENTATION_USER_LANDSCAPE = 11;
    public static final int SCREEN_ORIENTATION_USER_PORTRAIT = 12;
    public static final int SCREEN_ORIENTATION_FULL_USER = 13;
    public static final int SCREEN_ORIENTATION_LOCKED = 14;

只要不满足固定条件,下面剩下的都可以使用

 	public static final int SCREEN_ORIENTATION_UNSET = -2;
    public static final int SCREEN_ORIENTATION_UNSPECIFIED = -1;
    public static final int SCREEN_ORIENTATION_USER = 2;
    public static final int SCREEN_ORIENTATION_BEHIND = 3;
    public static final int SCREEN_ORIENTATION_SENSOR = 4;
    public static final int SCREEN_ORIENTATION_NOSENSOR = 5;
    public static final int SCREEN_ORIENTATION_FULL_SENSOR = 10;
    public static final int SCREEN_ORIENTATION_FULL_USER = 13;

二、问题解决-使用Hook修改属性

在hook前我们需要知道满足crash几种代码操作
1、配置文件中设置透明,代码动态设置固定方向;
2、配置文件AndroidManifest.xml中同时设置固定方向和透明theme;
3、配置文件中设置方向,代码中动态设置透明(使用setTheme);

目前上面3中都会出现crash,但是注意一种情况不会crash,不在配置文件中设置,只在代码中同时设置透明和固定方向。

1、解决crash1配置文件中设置透明,代码动态设置固定方向的方案

根据第一种crash,我们需要hook的是setRequestedOrientation方法,具体参见代码

public class StartActivityInvocationHandler2 implements InvocationHandler {
    private Object mBase;
    private Context mContext;

    public StartActivityInvocationHandler2(Context context, Object base){
        this.mContext = context;
        this.mBase = base;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if("setRequestedOrientation".equals(method.getName())){
            if(args!=null){
                //被设置的方向参数 args[1]
                int orient = (int) args[1];
                //因为crash环境,所以加个判断过滤下
                if (mContext.getApplicationInfo().targetSdkVersion >= 27 && (Build.VERSION.SDK_INT == 26 || Build.VERSION.SDK_INT == 27)){
                    args[1] = -1;//设置成默认
                }
            }
        }
        return method.invoke(mBase,args);
    }
}

这样其实不是完美的,因为如果activity没有设置透明属性,这么写也会改变人家Activity的方向,不友好,因此我们这里再优化一下,增加当前activity是否透明的条件判断。从上面的代码中可以看出我们目前能使用的就是proxy(执行方法的代理对象)和method(方法本身)以及args(执行方法的参数),如果不清楚他们的内容,建议自己打印一下。这里我们需要proxy来获取调用方的一些内容。
1、看源码,查看源码里它所有的方法(包括方法返回值,参数等),变量等;
2、如果看不了源码,就使用反射打印它所有的内容
3、筛选自己需要的东西

那么我们需要什么呢,那需要看透明条件需要什么了?

/**
* Determines whether the {@link Activity} is considered translucent or floating.
* @hide
*/
public static boolean isTranslucentOrFloating(TypedArray attributes) {
	final boolean isTranslucent = attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsTranslucent,
                        false);
    final boolean isSwipeToDismiss = !attributes.hasValue( com.android.internal.R.styleable.Window_windowIsTranslucent)
                && attributes.getBoolean(com.android.internal.R.styleable.Window_windowSwipeToDismiss, false);
	final boolean isFloating =attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsFloating,
                        false);

	return isFloating || isTranslucent || isSwipeToDismiss;
}

根据源码我尝试使用反射取得该方法的值,但是这个方法的取值跟传入的context有关系,就是你要获取哪个activity的透明情况,你就得传哪个,这个方法可以用来在activity中使用,不适合我们这里hook使用,因为我们没有当前的activity可以传。

public static boolean isTranslucentOrFloating3(Context context){
	boolean isTranslucentOrFloating = false;
	try {
		int [] styleableRes = (int[]) Class.forName("com.android.internal.R$styleable").getField("Window").get(null);
		Method obtainStyledAttributesM = Context.class.getDeclaredMethod("obtainStyledAttributes",int[].class);
		obtainStyledAttributesM.setAccessible(true);
		//activity是否透明,跟传入的对象有关
		TypedArray typedArray = (TypedArray) obtainStyledAttributesM.invoke(context,styleableRes);
		Method m = ActivityInfo.class.getMethod("isTranslucentOrFloating", TypedArray.class);
		m.setAccessible(true);
		isTranslucentOrFloating = (boolean)m.invoke(null, typedArray);
		m.setAccessible(false);
	} catch (Exception e) {
		e.printStackTrace();
	}
	return isTranslucentOrFloating;
}

经过各种尝试和测试,最终我找到了一个不需要当前context的正确有效的方法,话不多说,上代码,该方法亲测管用哦~~~

public static boolean isTranslucentOrFloating2(Context context,int themeId){
	boolean isTranslucent = isThemeFromTypedArray(context,android.R.attr.windowIsTranslucent,themeId);
	boolean isSwipeToDismiss = !isThemeHasTypedArray(context,android.R.attr.windowIsTranslucent,themeId)
			&& isThemeFromTypedArray(context,android.R.attr.windowSwipeToDismiss,themeId);
	boolean isFloating = isThemeFromTypedArray(context,android.R.attr.windowIsFloating,themeId);

	return isFloating || isTranslucent || isSwipeToDismiss;
}

private static boolean isThemeFromTypedArray(Context context,int attrId,int themeId){
	int[] attr = new int[]{attrId};
	TypedArray array = context.getTheme().obtainStyledAttributes(themeId, attr);
	boolean value = array.getBoolean(0,false );
	return value;
}

private static boolean isThemeHasTypedArray(Context context,int attrId,int themeId){
	int[] attr = new int[]{attrId};
	TypedArray array = context.getTheme().obtainStyledAttributes(themeId, attr);
	boolean has = array.hasValue(0 );
	return has;
}

确定了使用的方法,我们现在开始准备需要的参数context和themeId,context这个不指定,随意一点就行,themeId的话这里我没有快捷的方法,通过保存读取到的AndroidManifest.xml文件中的Activity和与之相对应的theme来获取当前的themeId,这里我们详细讲一下如何获取当前的themeId。

上面hook住setRequestedOrientation方法的时候,我们打印了proxy对象的所有方法包括参数和返回值,筛选后使用了getActivityClassForToken方法,它需要的参数interface android.os.IBinder,返回值是class android.content.ComponentName,ComponentName对象是我们需要的,我们可以通过这个对象获取到当前的class,这样就可以获取相对应的themeId

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
	if("setRequestedOrientation".equals(method.getName())){
		if(args!=null){
	        //arg[0]是获取activity的关键 token
	        IBinder token = (IBinder) args[0];
	        //被设置的方向参数 args[1]
	        int orient = (int) args[1];
			//因为crash环境,所以加个判断过滤下
			if (mContext.getApplicationInfo().targetSdkVersion >= 27 && (Build.VERSION.SDK_INT == 26 || Build.VERSION.SDK_INT == 27)){
				Object iActivityManager = proxy;
                try {
                    //获取当前对象
					Method getActivityClassForTokenM = iActivityManager.getClass().getDeclaredMethod("getActivityClassForToken",IBinder.class);
					getActivityClassForTokenM.setAccessible(true);
                    ComponentName cname = (ComponentName) getActivityClassForTokenM.invoke(iActivityManager,token);
                    //通过查找AndroidManifest.xml获取当前themeId
                    int themeId = GetActivity.getInstance().getTheme(mContext,Class.forName(cname.getClassName()));
                    //使用isTranslucentOrFloating2方法获取当前是否在配置文件中设置透明
                    boolean isTrans = ThemeUtils.isTranslucentOrFloating2(mContext,themeId);
                    if(isTrans){
                    	//如果是透明,就把屏幕方向设置
                        args[1] = -1;//设置成默认
                    }
                }catch (Exception e){
                }
           }

       }
   	}
	return method.invoke(mBase,args);
}

自此crash的第一种情况,完美解决,中间可能有一些需要优化的地方,如有不妥,请大家联系我~

2、解决crash2配置文件AndroidManifest.xml中同时设置固定方向和透明theme的方案

在启动Activity前获取到这些属性,然后并修改,这里就涉及到了hookLauncherActivity,在LAUNCH_ACTIVITY的时候处理。主要的就是反射关键方法isFixedOrientation来获取当前的透明条件,具体的hook方法摸索就靠大家自己了,这里直接给出代码:

@Override
public boolean handleMessage(Message msg) {
    //每发一个消息都会走一次这个方法
    if(msg.what == 100){//LAUNCH_ACTIVITY
        handleLaunchActivity(mContext,msg);
    }
    return false;
}
/**
 * 开始启动创建Activity拦截
 * @param msg
 */
private void handleLaunchActivity(Context context,Message msg) {
	//这里获取到的是ActivityRecord
    Object record = msg.obj;
	try {
		Field activityInfoField = record.getClass().getDeclaredField("activityInfo");
		activityInfoField.setAccessible(true);
		ActivityInfo orignInfo = (ActivityInfo) activityInfoField.get(record);

		if (context.getApplicationInfo().targetSdkVersion >= 27 && (Build.VERSION.SDK_INT == 26 || Build.VERSION.SDK_INT == 27)){
			//isFixedOrientation参数是否满足条件
			Method fixM = ActivityInfo.class.getDeclaredMethod("isFixedOrientation");
			boolean result = (boolean) fixM.invoke(orignInfo);
			if(result){
				//设置方向为默认或者非固定崩溃的方向
				orignInfo.screenOrientation = -1;
			}
		}
	} catch (Exception e) {
  		e.printStackTrace();
  	}
} 
3、未解决crash3配置文件中设置方向,代码中动态设置透明(使用setTheme)的方案

代码中动态设置透明,目前还没有发现有什么方法能hook住,Activity的setTheme方法在onCreate方法前调用,但是无意中发现,调用setTheme方法时会先去调用setTaskDescription方法,打印出的内容如下

TaskDescription Label: null Icon: null IconFilename: null colorPrimary: -1644826 colorBackground: -16777216 statusBarColor: -16777216 navigationBarColor: -1 

这个方法中目前只能拿到上面一些参数,还没有找到setTheme解决的方向,不过我尝试了一下网上各种在代码中设置透明的方法,setTheme的透明效果好像不怎么能生效,其他的设置透明的方法都不会导致crash。

三、总结

在解决上述3中crash的时候,因为没有找到拦截setTheme的方法,所以推荐大家使用前两种解决方法,拦截动态设置方向方法setRequestedOrientation和拦截LaunchActivity时修改方向。

这个crash是当targetsdk>=27的,且手机Android系统是26,27的时候会crash,虽然在28的源码上谷歌去掉了固定方向透明度限制问题,但是对于我们开发者来说适配也很重要,26,27的手机在市面上占比也不算小,希望这篇文章能帮到大家很好的解决问题,不想使用hook的可以直接从否定固定方向入手,比如在AndroidManifest.xml中将方向设置为android:screenOrientation=“behind”,总之条条大路通罗马,祝大家好运~~

你可能感兴趣的:(Hook,Android)