package com.oasisfeng.hack; import android.support.annotation.CheckResult; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; import java.io.IOException; import java.lang.reflect.AccessibleObject; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.Arrays; /** * Java reflection helper optimized for hacking non-public APIs. * * It's suggested to declare all hacks in a centralized point, typically as static fields in a class. * Then call it during application initialization, thus they are verified all together in an early stage. * If any assertion failed, a fall-back strategy is suggested. * *

https://gist.github.com/oasisfeng/75d3774ca5441372f049818de4d52605 * * @see Demo * * @author Oasis */ @SuppressWarnings("Convert2Lambda") public class Hack { /** This exception is purposely defined as "protected" and not extending Exception to avoid * developers unconsciously catch it outside the centralized hacks declaration, which results * in potentially pre-checked usage of hacks. */ public static class AssertionException extends Throwable { private Class mClass; private Field mHackedField; private Method mHackedMethod; private String mHackedFieldName; private String mHackedMethodName; private Class[] mParamTypes; AssertionException(final String e) { super(e); } AssertionException(final Exception e) { super(e); } @Override public String toString() { return getCause() != null ? getClass().getName() + ": " + getCause() : super.toString(); } public String getDebugInfo() { final StringBuilder info = new StringBuilder(getCause() != null ? getCause().toString() : super.toString()); final Throwable cause = getCause(); if (cause instanceof NoSuchMethodException) { info.append(" Potential candidates:"); final int initial_length = info.length(); final String name = getHackedMethodName(); if (name != null) { for (final Method method : getHackedClass().getDeclaredMethods()) if (method.getName().equals(name)) // Exact name match info.append(' ').append(method); if (info.length() == initial_length) for (final Method method : getHackedClass().getDeclaredMethods()) if (method.getName().startsWith(name)) // Name prefix match info.append(' ').append(method); if (info.length() == initial_length) for (final Method method : getHackedClass().getDeclaredMethods()) if (! method.getName().startsWith("-")) // Dump all but generated methods info.append(' ').append(method); } else for (final Constructor constructor : getHackedClass().getDeclaredConstructors()) info.append(' ').append(constructor); } else if (cause instanceof NoSuchFieldException) { info.append(" Potential candidates:"); final int initial_length = info.length(); final String name = getHackedFieldName(); final Field[] fields = getHackedClass().getDeclaredFields(); for (final Field field : fields) if (field.getName().equals(name)) // Exact name match info.append(' ').append(field); if (info.length() == initial_length) for (final Field field : fields) if (field.getName().startsWith(name)) // Name prefix match info.append(' ').append(field); if (info.length() == initial_length) for (final Field field : fields) if (! field.getName().startsWith("$")) // Dump all but generated fields info.append(' ').append(field); } return info.toString(); } public Class getHackedClass() { return mClass; } AssertionException setHackedClass(final Class hacked_class) { mClass = hacked_class; return this; } public Method getHackedMethod() { return mHackedMethod; } AssertionException setHackedMethod(final Method method) { mHackedMethod = method; return this; } public String getHackedMethodName() { return mHackedMethodName; } AssertionException setHackedMethodName(final String method) { mHackedMethodName = method; return this; } public Class[] getParamTypes() { return mParamTypes; } AssertionException setParamTypes(final Class[] param_types) { mParamTypes = param_types; return this; } public Field getHackedField() { return mHackedField; } AssertionException setHackedField(final Field field) { mHackedField = field; return this; } public String getHackedFieldName() { return mHackedFieldName; } AssertionException setHackedFieldName(final String field) { mHackedFieldName = field; return this; } private static final long serialVersionUID = 1L; } /** Placeholder for unchecked exception */ public class Unchecked extends RuntimeException {} /** Use {@link Hack#setAssertionFailureHandler(AssertionFailureHandler) } to set the global handler */ public interface AssertionFailureHandler { void onAssertionFailure(AssertionException failure); } public static class HackedField { /** Assert the field type */ public HackedField ofType(final Class type) { if (mField != null && ! type.isAssignableFrom(mField.getType())) fail(new AssertionException(new ClassCastException(mField + " is not of type " + type)).setHackedField(mField)); @SuppressWarnings("unchecked") final HackedField casted = (HackedField) this; return casted; } public HackedField ofType(final String type_name) { try { @SuppressWarnings("unchecked") final HackedField casted = mField == null ? this : (HackedField) ofType(Class.forName(type_name, false, mField.getDeclaringClass().getClassLoader())); return casted; } catch (final ClassNotFoundException e) { fail(new AssertionException(e)); return this; } } /** Fallback to the given value if this field is unavailable at runtime */ public HackedField fallbackTo(final T value) { mFallbackValue = value; return this; } @SuppressWarnings("unchecked") public @Nullable Class getType() { return mField != null ? (Class) mField.getType() : null; } public HackedTargetField on(final C target) { if (target == null) throw new IllegalArgumentException("target is null"); return onTarget(target); } private HackedTargetField onTarget(final @Nullable C target) { return new HackedTargetField<>(mField, target); } /** Get current value of this field */ public T get(final C instance) { try { if (mField == null) return mFallbackValue; @SuppressWarnings("unchecked") final T value = (T) mField.get(instance); return value; } catch (final IllegalAccessException e) { return null; } // Should never happen } /** * Set value of this field * *

No type enforced here since most type mismatch can be easily tested and exposed early.

*/ public void set(final C instance,final Object value) { try { if (mField != null) mField.set(instance, value); } catch (final IllegalAccessException ignored) {} // Should never happen } /** @param modifiers the modifiers this field must have */ HackedField(final Class clazz, final String name, final int modifiers) { Field field = null; try { if (clazz == null) return; field = clazz.getDeclaredField(name); if (Modifier.isStatic(modifiers) != Modifier.isStatic(field.getModifiers())) fail(new AssertionException(field + (Modifier.isStatic(modifiers) ? " is not static" : "is static")).setHackedFieldName(name)); if (modifiers > 0 && (field.getModifiers() & modifiers) != modifiers) fail(new AssertionException(field + " does not match modifiers: " + modifiers).setHackedFieldName(name)); if (! field.isAccessible()) field.setAccessible(true); } catch (final NoSuchFieldException e) { final AssertionException hae = new AssertionException(e); hae.setHackedClass(clazz); hae.setHackedFieldName(name); fail(hae); } finally { mField = field; } } public @Nullable Field getField() { return mField; } private final @Nullable Field mField; private @Nullable T mFallbackValue; } public static class HackedTargetField { public T get() { try { @SuppressWarnings("unchecked") final T value = (T) mField.get(mInstance); return value; } catch (final IllegalAccessException e) { return null; } // Should never happen } public void set(final T value) { try { mField.set(mInstance, value); } catch (final IllegalAccessException ignored) {} // Should never happen } public HackedTargetField ofType(final Class type) { if (mField != null && ! type.isAssignableFrom(mField.getType())) fail(new AssertionException(new ClassCastException(mField + " is not of type " + type)).setHackedField(mField)); @SuppressWarnings("unchecked") final HackedTargetField casted = (HackedTargetField) this; return casted; } public HackedTargetField ofType(final String type_name) { try { @SuppressWarnings("unchecked") final HackedTargetField casted = (HackedTargetField) ofType(Class.forName(type_name, false, mField.getDeclaringClass().getClassLoader())); return casted; } catch (final ClassNotFoundException e) { fail(new AssertionException(e)); return this; } } HackedTargetField(final Field field, final @Nullable Object instance) { mField = field; mInstance = instance; } private final Field mField; private final Object mInstance; // Instance type is already checked } public interface HackedInvokable { @CheckResult HackedInvokable throwing(Class type); @CheckResult HackedInvokable throwing(Class type1, Class type2); @CheckResult HackedInvokable throwing(Class type1, Class type2, Class type3); @Nullable HackedMethod0 withoutParams(); @Nullable HackedMethod1 withParam(Class type); @Nullable HackedMethod2 withParams(Class type1, Class type2); @Nullable HackedMethod3 withParams(Class type1, Class type2, Class type3); @Nullable HackedMethod4 withParams(Class type1, Class type2, Class type3, Class type4); @Nullable HackedMethod5 withParams(Class type1, final Class type2, final Class type3, final Class type4, final Class type5); @Nullable HackedMethodN withParams(Class... types); } public interface HackedMethod extends HackedInvokable { /** Fallback to the given value if this field is unavailable at runtime. (Optional) */ @CheckResult NonNullHackedMethod fallbackReturning(R return_value); @CheckResult HackedMethod throwing(Class type); @CheckResult HackedMethod throwing(Class type1, Class type2); @CheckResult HackedMethod throwing(Class type1, Class type2, Class type3); @CheckResult HackedMethod throwing(Class... types); } @SuppressWarnings("NullableProblems") // Force to NonNull public interface NonNullHackedMethod extends HackedMethod { /** Optional */ @CheckResult HackedMethod returning(Class type); @NonNull HackedMethod0 withoutParams(); @NonNull HackedMethod1 withParam(Class type); @NonNull HackedMethod2 withParams(Class type1, Class type2); @NonNull HackedMethod3 withParams(Class type1, Class type2, Class type3); @NonNull HackedMethod4 withParams(Class type1, Class type2, Class type3, Class type4); @NonNull HackedMethod5 withParams(Class type1, final Class type2, final Class type3, final Class type4, final Class type5); @NonNull HackedMethodN withParams(Class... types); } public interface HackedMethod0 { @CheckResult HackInvocation invoke(); } public interface HackedMethod1 { @CheckResult HackInvocation invokeWithParam(A1 arg); } public interface HackedMethod2 { @CheckResult HackInvocation invokeWithParams(A1 arg1, A2 arg2); } public interface HackedMethod3 { @CheckResult HackInvocation invokeWithParams(A1 arg1, A2 arg2, A3 arg3); } public interface HackedMethod4 { @CheckResult HackInvocation invokeWithParams(A1 arg1, A2 arg2, A3 arg3, A4 arg4); } public interface HackedMethod5 { @CheckResult HackInvocation invokeWithParams(A1 arg1, A2 arg2, A3 arg3, A4 arg4, A5 arg5); } public interface HackedMethodN { @CheckResult HackInvocation invokeWithParams(Object... args); } public static class HackInvocation { HackInvocation(final Invokable invokable, final Object... args) { this.invokable = invokable; this.args = args; } public R on(final @NonNull C target) throws T1, T2, T3 { return onTarget(target); } public R statically() throws T1, T2, T3 { return onTarget(null); } private R onTarget(final C target) throws T1 { try { @SuppressWarnings("unchecked") final R result = (R) invokable.invoke(target, args); return result; } catch (final IllegalAccessException e) { throw new RuntimeException(e); // Should never happen } catch (final InstantiationException e) { throw new RuntimeException(e); } catch (final InvocationTargetException e) { final Throwable ex = e.getTargetException(); //noinspection unchecked throw (T1) ex; } } private final Invokable invokable; private final Object[] args; } interface Invokable { Object invoke(C target, Object[] args) throws InvocationTargetException, IllegalAccessException, InstantiationException; } private static class HackedMethodImpl implements NonNullHackedMethod { HackedMethodImpl(final Class clazz, @Nullable final String name, final int modifiers) { //noinspection unchecked, to be compatible with HackedClass.staticMethod() mClass = (Class) clazz; mName = name; mModifiers = modifiers; } @Override public HackedMethod returning(final Class type) { mReturnType = type; @SuppressWarnings("unchecked") final HackedMethod casted = (HackedMethod) this; return casted; } @Override public NonNullHackedMethod fallbackReturning(final R value) { mFallbackReturnValue = value; mHasFallback = true; return this; } @Override public HackedMethod throwing(final Class type) { mThrowTypes = new Class[] { type }; @SuppressWarnings("unchecked") final HackedMethod casted = (HackedMethod) this; return casted; } @Override public HackedMethod throwing(final Class type1, final Class type2) { mThrowTypes = new Class[] { type1, type2 }; Arrays.sort(mThrowTypes); @SuppressWarnings("unchecked") final HackedMethod cast = (HackedMethod) this; return cast; } @Override public HackedMethod throwing(final Class type1, final Class type2, final Class type3) { mThrowTypes = new Class[] { type1, type2, type3 }; Arrays.sort(mThrowTypes); @SuppressWarnings("unchecked") final HackedMethod cast = (HackedMethod) this; return cast; } @Override public HackedMethod throwing(final Class... types) { mThrowTypes = types; Arrays.sort(mThrowTypes); @SuppressWarnings("unchecked") final HackedMethod cast = (HackedMethod) this; return cast; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod0 withoutParams() { final Invokable method = findInvokable(); return method == null ? null : new HackedMethod0() { @Override public HackInvocation invoke() { return new HackInvocation<>(method); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod1 withParam(final Class type) { final Invokable method = findInvokable(type); return method == null ? null : new HackedMethod1() { @Override public HackInvocation invokeWithParam(final A1 arg) { return new HackInvocation<>(method, arg); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod2 withParams(final Class type1, final Class type2) { final Invokable method = findInvokable(type1, type2); return method == null ? null : new HackedMethod2() { @Override public HackInvocation invokeWithParams(final A1 arg1, final A2 arg2) { return new HackInvocation<>(method, arg1, arg2); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod3 withParams(final Class type1, final Class type2, final Class type3) { final Invokable method = findInvokable(type1, type2, type3); return method == null ? null : new HackedMethod3() { @Override public HackInvocation invokeWithParams(final A1 arg1, final A2 arg2, final A3 arg3) { return new HackInvocation<>(method, arg1, arg2, arg3); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod4 withParams(final Class type1, final Class type2, final Class type3, final Class type4) { final Invokable method = findInvokable(type1, type2, type3, type4); return method == null ? null : new HackedMethod4() { @Override public HackInvocation invokeWithParams(final A1 arg1, final A2 arg2, final A3 arg3, final A4 arg4) { return new HackInvocation<>(method, arg1, arg2, arg3, arg4); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethod5 withParams(final Class type1, final Class type2, final Class type3, final Class type4, final Class type5) { final Invokable method = findInvokable(type1, type2, type3, type4, type5); return method == null ? null : new HackedMethod5() { @Override public HackInvocation invokeWithParams(final A1 arg1, final A2 arg2, final A3 arg3, final A4 arg4, final A5 arg5) { return new HackInvocation<>(method, arg1, arg2, arg3, arg4, arg5); } }; } @NonNull @SuppressWarnings("ConstantConditions") @Override public HackedMethodN withParams(final Class... types) { final Invokable method = findInvokable(types); return method == null ? null : new HackedMethodN() { @Override public HackInvocation invokeWithParams(final Object... args) { return new HackInvocation<>(method, args); } }; } private @Nullable Invokable findInvokable(final Class... param_types) { final int modifiers; final Invokable invokable; final AccessibleObject accessible; final Class[] ex_types; try { if (mName != null) { final Method method = mClass.getDeclaredMethod(mName, param_types); modifiers = method.getModifiers(); invokable = new InvokableMethod(method); accessible = method; ex_types = method.getExceptionTypes(); if (Modifier.isStatic(mModifiers) != Modifier.isStatic(method.getModifiers())) fail(new AssertionException(method + (Modifier.isStatic(mModifiers) ? " is not static" : "is static")).setHackedMethod(method)); if (mReturnType != null && ! method.getReturnType().equals(mReturnType)) fail(new AssertionException("Return type mismatch: " + method)); } else { final Constructor ctor = mClass.getDeclaredConstructor(param_types); modifiers = ctor.getModifiers(); invokable = new InvokableConstructor<>(ctor); accessible = ctor; ex_types = ctor.getExceptionTypes(); } } catch (final NoSuchMethodException e) { fail(new AssertionException(e).setHackedClass(mClass).setHackedMethodName(mName).setParamTypes(param_types)); if (! mHasFallback) return null; return new FallbackInvokable(mFallbackReturnValue); } if (mModifiers > 0 && (modifiers & mModifiers) != mModifiers) fail(new AssertionException(invokable + " does not match modifiers: " + mModifiers).setHackedMethodName(mName)); if (mThrowTypes == null && ex_types.length > 0 || mThrowTypes != null && ex_types.length == 0) fail(new AssertionException("Checked exception(s) not match: " + invokable)); else if (mThrowTypes != null) { Arrays.sort(ex_types); if (! Arrays.equals(ex_types, mThrowTypes)) fail(new AssertionException("Checked exception(s) not match: " + invokable)); } if (! accessible.isAccessible()) accessible.setAccessible(true); return invokable; } private final Class mClass; private final @Nullable String mName; // Null for constructor private final int mModifiers; private Class mReturnType; private Class[] mThrowTypes; private R mFallbackReturnValue; private boolean mHasFallback = true; // Default to true for method returning void } private static class InvokableMethod implements Invokable { InvokableMethod(final Method method) { this.method = method; } public Object invoke(final C target, final Object[] args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException { return method.invoke(target, args); } @Override public String toString() { return method.toString(); } private final Method method; } private static class InvokableConstructor implements Invokable { InvokableConstructor(final Constructor method) { this.constructor = method; } public Object invoke(final C target, final Object[] args) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { return constructor.newInstance(args); } @Override public String toString() { return constructor.toString(); } private final Constructor constructor; } private static class FallbackInvokable implements Invokable { FallbackInvokable(final Object value) { mValue = value; } @Override public Object invoke(final C target, final Object[] args) throws InvocationTargetException, IllegalAccessException, InstantiationException { return mValue; } private final Object mValue; } public static class HackedClass { public HackedField field(final String name) { return new HackedField(mClass, name, 0) {}; // Anonymous derived class ensures } public HackedTargetField staticField(final String name) { return new HackedField(mClass, name, Modifier.STATIC).onTarget(null); } public @CheckResult NonNullHackedMethod method(final String name) { return new HackedMethodImpl<>(mClass, name, 0); } public @CheckResult NonNullHackedMethod staticMethod(final String name) { return new HackedMethodImpl<>(mClass, name, Modifier.STATIC); } public @CheckResult HackedInvokable constructor() { return new HackedMethodImpl<>(mClass, null, 0); } HackedClass(final Class clazz) { mClass = clazz; } private final Class mClass; } public static HackedClass into(final Class clazz) { return new HackedClass<>(clazz); } @SuppressWarnings({ "rawtypes", "unchecked" }) public static HackedClass into(final String class_name) { try { return new HackedClass(Class.forName(class_name)); } catch (final ClassNotFoundException e) { fail(new AssertionException(e)); return new HackedClass(null); // TODO: Better solution to avoid null? } } private static void fail(final AssertionException e) { if (sFailureHandler != null) sFailureHandler.onAssertionFailure(e); } /** Specify a handler to deal with assertion failure, and decide whether the failure should be thrown. */ public static AssertionFailureHandler setAssertionFailureHandler(final AssertionFailureHandler handler) { final AssertionFailureHandler old = sFailureHandler; sFailureHandler = handler; return old; } private Hack() {} private static AssertionFailureHandler sFailureHandler; /** This is a simple demo for the common usage of {@link Hack} */ @SuppressWarnings("unused") private static class Demo { @SuppressWarnings({"FieldCanBeLocal", "UnnecessarilyQualifiedStaticUsage"}) static class Hacks { /** Call this method before any hack is used, usually in your application initialization */ static void defineAndVerify() { Hack.setAssertionFailureHandler(new AssertionFailureHandler() { @Override public void onAssertionFailure(final AssertionException failure) { Log.w("Demo", "Partially incompatible: " + failure.getDebugInfo()); // Report the incompatibility silently. //... }}); Demo_ctor = Hack.into(Demo.class).constructor().withParam(int.class); Demo_methodThrows = Hack.into(Demo.class).method("methodThrows").returning(Void.class).throwing(InterruptedException.class, IOException.class).withoutParams(); Demo_staticMethod = Hack.into(Demo.class).staticMethod("methodWith2Params").returning(boolean.class).withParams(int.class, String.class); Demo_mField = Hack.into(Demo.class).field("mField").ofType(boolean.class).fallbackTo(false); Demo_sField = Hack.into(Demo.class).staticField("sField").ofType(String.class); } static HackedMethod1 Demo_ctor; static Hack.HackedMethod0 Demo_methodThrows; static Hack.HackedMethod2 Demo_staticMethod; static @Nullable HackedField Demo_mField; // Optional hack may be null if assertion failed static @Nullable HackedTargetField Demo_sField; } static void demo() { final Demo demo = Hacks.Demo_ctor.invokeWithParam(0).statically(); try { Hacks.Demo_methodThrows.invoke().on(demo); } catch (final InterruptedException | IOException e) { // The checked exceptions declared by throwing() in hack definition. e.printStackTrace(); } Hacks.Demo_staticMethod.invokeWithParams(1, "xx").statically(); } Demo(final int flags) {} private void methodThrows() throws InterruptedException, IOException {} static boolean staticMethod(final int a, final String c) { return false; } boolean mField; static String sField; } }