/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.antfortune.freeline.resources; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH; import static android.os.Build.VERSION_CODES.JELLY_BEAN; import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR2; import static android.os.Build.VERSION_CODES.KITKAT; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.M; import static android.os.Build.VERSION_CODES.N; import android.app.Activity; import android.app.Application; import android.content.Context; import android.content.res.AssetManager; import android.content.res.Resources; import android.os.Build; import android.util.ArrayMap; import android.util.Log; import android.util.LongSparseArray; import android.util.SparseArray; import android.view.ContextThemeWrapper; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; /** * A utility class which uses reflection hacks to replace the application instance and * the resource data for the current app. * This is based on the reflection parts of * com.google.devtools.build.android.incrementaldeployment.StubApplication, * plus changes to compile on JDK 6. * <p> * It now also has a lot of extra reflection machinery to do live resource swapping * in a running app (e.g. swiping through data structures, updating resource managers, * flushing cached theme entries, etc.) * <p> * The original is * https://github.com/google/bazel/blob/master/src/tools/android/java/com/google/devtools/build/android/incrementaldeployment/StubApplication.java * (May 11 revision, ca96e11) * <p> * (The code to handle resource loading etc is different; see FileManager.) * Furthermore, the resource patching was hacked on some more such that it can * handle live (activity-restart) changes, which allows us to for example patch * the theme and have existing activities have their themes updated! * <p> * Original comment for the StubApplication, which contained the reflection methods: * <p> * A stub application that patches the class loader, then replaces itself with the real application * by applying a liberal amount of reflection on Android internals. * <p> * <p>This is, of course, terribly error-prone. Most of this code was tested with API versions * 8, 10, 14, 15, 16, 17, 18, 19 and 21 on the Android emulator, a Nexus 5 running Lollipop LRX22C * and a Samsung GT-I5800 running Froyo XWJPE. The exception is {@code monkeyPatchAssetManagers}, * which only works on Kitkat and Lollipop. * <p> * <p>Note that due to a bug in Dalvik, this only works on Kitkat if ART is the Java runtime. * <p> * <p>Unfortunately, if this does not work, we don't have a fallback mechanism: as soon as we * build the APK with this class as the Application, we are committed to going through with it. * <p> * <p>This class should use as few other classes as possible before the class loader is patched * because any class loaded before it cannot be incrementally deployed. */ public class MonkeyPatcher { protected static final String LOG_TAG = "Freeline.ResPatcher"; @SuppressWarnings("unchecked") // Lots of conversions with generic types public static void monkeyPatchApplication(Context context, Application bootstrap, Application realApplication, String externalResourceFile) { /* The code seems to perform this: Application realApplication = the newly instantiated (in attachBaseContext) user app currentActivityThread = ActivityThread.currentActivityThread; Application initialApplication = currentActivityThread.mInitialApplication; if (initialApplication == BootstrapApplication.this) { currentActivityThread.mInitialApplication = realApplication; // Replace all instance of the stub application in ActivityThread#mAllApplications with the // real one List<Application> allApplications = currentActivityThread.mAllApplications; for (int i = 0; i < allApplications.size(); i++) { if (allApplications.get(i) == BootstrapApplication.this) { allApplications.set(i, realApplication); } } // Enumerate all LoadedApk (or PackageInfo) fields in ActivityThread#mPackages and // ActivityThread#mResourcePackages and do two things: // - Replace the Application instance in its mApplication field with the real one // - Replace mResDir to point to the external resource file instead of the .apk. This is // used as the asset path for new Resources objects. // - Set Application#mLoadedApk to the found LoadedApk instance ArrayMap<String, WeakReference<LoadedApk>> map1 = currentActivityThread.mPackages; for (Map.Entry<String, WeakReference<?>> entry : map1.entrySet()) { Object loadedApk = entry.getValue().get(); if (loadedApk == null) { continue; } if (loadedApk.mApplication == BootstrapApplication.this) { loadedApk.mApplication = realApplication; if (externalResourceFile != null) { loadedApk.mResDir = externalResourceFile; } realApplication.mLoadedApk = loadedApk; } } // Exactly the same as above, except done for mResourcePackages instead of mPackages ArrayMap<String, WeakReference<LoadedApk>> map2 = currentActivityThread.mResourcePackages; for (Map.Entry<String, WeakReference<?>> entry : map2.entrySet()) { Object loadedApk = entry.getValue().get(); if (loadedApk == null) { continue; } if (loadedApk.mApplication == BootstrapApplication.this) { loadedApk.mApplication = realApplication; if (externalResourceFile != null) { loadedApk.mResDir = externalResourceFile; } realApplication.mLoadedApk = loadedApk; } } */ // BootstrapApplication is created by reflection in Application#handleBindApplication() -> // LoadedApk#makeApplication(), and its return value is used to set the Application field in all // sorts of Android internals. // // Fortunately, Application#onCreate() is called quite soon after, so what we do is monkey // patch in the real Application instance in BootstrapApplication#onCreate(). // // A few places directly use the created Application instance (as opposed to the fields it is // eventually stored in). Fortunately, it's easy to forward those to the actual real // Application class. try { // Find the ActivityThread instance for the current thread Class<?> activityThread = Class.forName("android.app.ActivityThread"); Object currentActivityThread = getActivityThread(context, activityThread); // Find the mInitialApplication field of the ActivityThread to the real application Field mInitialApplication = activityThread.getDeclaredField("mInitialApplication"); mInitialApplication.setAccessible(true); Application initialApplication = (Application) mInitialApplication.get(currentActivityThread); if (realApplication != null && initialApplication == bootstrap) { mInitialApplication.set(currentActivityThread, realApplication); } // Replace all instance of the stub application in ActivityThread#mAllApplications with the // real one if (realApplication != null) { Field mAllApplications = activityThread.getDeclaredField("mAllApplications"); mAllApplications.setAccessible(true); List<Application> allApplications = (List<Application>) mAllApplications .get(currentActivityThread); for (int i = 0; i < allApplications.size(); i++) { if (allApplications.get(i) == bootstrap) { allApplications.set(i, realApplication); } } } // Figure out how loaded APKs are stored. // API version 8 has PackageInfo, 10 has LoadedApk. 9, I don't know. Class<?> loadedApkClass; try { loadedApkClass = Class.forName("android.app.LoadedApk"); } catch (ClassNotFoundException e) { loadedApkClass = Class.forName("android.app.ActivityThread$PackageInfo"); } Field mApplication = loadedApkClass.getDeclaredField("mApplication"); mApplication.setAccessible(true); Field mResDir = loadedApkClass.getDeclaredField("mResDir"); mResDir.setAccessible(true); // 10 doesn't have this field, 14 does. Fortunately, there are not many Honeycomb devices // floating around. Field mLoadedApk = null; try { mLoadedApk = Application.class.getDeclaredField("mLoadedApk"); } catch (NoSuchFieldException e) { // According to testing, it's okay to ignore this. } // Enumerate all LoadedApk (or PackageInfo) fields in ActivityThread#mPackages and // ActivityThread#mResourcePackages and do two things: // - Replace the Application instance in its mApplication field with the real one // - Replace mResDir to point to the external resource file instead of the .apk. This is // used as the asset path for new Resources objects. // - Set Application#mLoadedApk to the found LoadedApk instance for (String fieldName : new String[]{"mPackages", "mResourcePackages"}) { Field field = activityThread.getDeclaredField(fieldName); field.setAccessible(true); Object value = field.get(currentActivityThread); for (Map.Entry<String, WeakReference<?>> entry : ((Map<String, WeakReference<?>>) value).entrySet()) { Object loadedApk = entry.getValue().get(); if (loadedApk == null) { continue; } if (mApplication.get(loadedApk) == bootstrap) { if (realApplication != null) { mApplication.set(loadedApk, realApplication); } if (externalResourceFile != null) { mResDir.set(loadedApk, externalResourceFile); } if (realApplication != null && mLoadedApk != null) { mLoadedApk.set(realApplication, loadedApk); } } } } } catch (Throwable e) { throw new IllegalStateException(e); } } public static Object getActivityThread(Context context, Class<?> activityThread) { try { if (activityThread == null) { activityThread = Class.forName("android.app.ActivityThread"); } Method m = activityThread.getMethod("currentActivityThread"); m.setAccessible(true); Object currentActivityThread = m.invoke(null); if (currentActivityThread == null && context != null) { // In older versions of Android (prior to frameworks/base 66a017b63461a22842) // the currentActivityThread was built on thread locals, so we'll need to try // even harder Field mLoadedApk = context.getClass().getField("mLoadedApk"); mLoadedApk.setAccessible(true); Object apk = mLoadedApk.get(context); Field mActivityThreadField = apk.getClass().getDeclaredField("mActivityThread"); mActivityThreadField.setAccessible(true); currentActivityThread = mActivityThreadField.get(apk); } return currentActivityThread; } catch (Throwable ignore) { return null; } } public static void monkeyPatchExistingResources(Context context, String externalResourceFile, Collection<Activity> activities) { if (externalResourceFile == null) { return; } /* (Note: the resource directory is *also* inserted into the loadedApk in monkeyPatchApplication) The code seems to perform this: File externalResourceFile = <path to resources.ap_ or extracted directory> AssetManager newAssetManager = new AssetManager(); newAssetManager.addAssetPath(externalResourceFile) // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm // in L, so we do it unconditionally. newAssetManager.ensureStringBlocks(); // Find the singleton instance of ResourcesManager ResourcesManager resourcesManager = ResourcesManager.getInstance(); // Iterate over all known Resources objects if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { for (WeakReference<Resources> wr : resourcesManager.mActiveResources.values()) { Resources resources = wr.get(); // Set the AssetManager of the Resources instance to our brand new one resources.mAssets = newAssetManager; resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); } } // Also, for each context, call getTheme() to get the current theme; null out its // mTheme field, then invoke initializeTheme() to force it to be recreated (with the // new asset manager!) */ try { // Create a new AssetManager instance and point it to the resources installed under // /sdcard AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance(); Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class); mAddAssetPath.setAccessible(true); if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) { throw new IllegalStateException("Could not create new AssetManager"); } // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm // in L, so we do it unconditionally. Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks"); mEnsureStringBlocks.setAccessible(true); mEnsureStringBlocks.invoke(newAssetManager); if (activities != null) { for (Activity activity : activities) { Resources resources = activity.getResources(); try { Field mAssets = Resources.class.getDeclaredField("mAssets"); mAssets.setAccessible(true); mAssets.set(resources, newAssetManager); } catch (Throwable ignore) { Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl"); mResourcesImpl.setAccessible(true); Object resourceImpl = mResourcesImpl.get(resources); Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets"); implAssets.setAccessible(true); implAssets.set(resourceImpl, newAssetManager); } Resources.Theme theme = activity.getTheme(); try { try { Field ma = Resources.Theme.class.getDeclaredField("mAssets"); ma.setAccessible(true); ma.set(theme, newAssetManager); } catch (NoSuchFieldException ignore) { Field themeField = Resources.Theme.class.getDeclaredField("mThemeImpl"); themeField.setAccessible(true); Object impl = themeField.get(theme); Field ma = impl.getClass().getDeclaredField("mAssets"); ma.setAccessible(true); ma.set(impl, newAssetManager); } Field mt = ContextThemeWrapper.class.getDeclaredField("mTheme"); mt.setAccessible(true); mt.set(activity, null); Method mtm = ContextThemeWrapper.class.getDeclaredMethod("initializeTheme"); mtm.setAccessible(true); mtm.invoke(activity); if (SDK_INT < N) { Method mCreateTheme = AssetManager.class.getDeclaredMethod("createTheme"); mCreateTheme.setAccessible(true); Object internalTheme = mCreateTheme.invoke(newAssetManager); Field mTheme = Resources.Theme.class.getDeclaredField("mTheme"); mTheme.setAccessible(true); mTheme.set(theme, internalTheme); } } catch (Throwable e) { Log.e(LOG_TAG, "Failed to update existing theme for activity " + activity, e); } pruneResourceCaches(resources); } } // Iterate over all known Resources objects Collection<WeakReference<Resources>> references; if (SDK_INT >= KITKAT) { // Find the singleton instance of ResourcesManager Class<?> resourcesManagerClass = Class.forName("android.app.ResourcesManager"); Method mGetInstance = resourcesManagerClass.getDeclaredMethod("getInstance"); mGetInstance.setAccessible(true); Object resourcesManager = mGetInstance.invoke(null); try { Field fMActiveResources = resourcesManagerClass.getDeclaredField("mActiveResources"); fMActiveResources.setAccessible(true); @SuppressWarnings("unchecked") ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap<?, WeakReference<Resources>>) fMActiveResources.get(resourcesManager); references = arrayMap.values(); } catch (NoSuchFieldException ignore) { Field mResourceReferences = resourcesManagerClass.getDeclaredField("mResourceReferences"); mResourceReferences.setAccessible(true); //noinspection unchecked references = (Collection<WeakReference<Resources>>) mResourceReferences.get(resourcesManager); } } else { Class<?> activityThread = Class.forName("android.app.ActivityThread"); Field fMActiveResources = activityThread.getDeclaredField("mActiveResources"); fMActiveResources.setAccessible(true); Object thread = getActivityThread(context, activityThread); @SuppressWarnings("unchecked") HashMap<?, WeakReference<Resources>> map = (HashMap<?, WeakReference<Resources>>) fMActiveResources.get(thread); references = map.values(); } for (WeakReference<Resources> wr : references) { Resources resources = wr.get(); if (resources != null) { // Set the AssetManager of the Resources instance to our brand new one try { Field mAssets = Resources.class.getDeclaredField("mAssets"); mAssets.setAccessible(true); mAssets.set(resources, newAssetManager); } catch (Throwable ignore) { Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl"); mResourcesImpl.setAccessible(true); Object resourceImpl = mResourcesImpl.get(resources); Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets"); implAssets.setAccessible(true); implAssets.set(resourceImpl, newAssetManager); } resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics()); } } } catch (Throwable e) { throw new IllegalStateException(e); } } private static void pruneResourceCaches(Object resources) { // Drain TypedArray instances from the typed array pool since these can hold on // to stale asset data if (SDK_INT >= LOLLIPOP) { try { Field typedArrayPoolField = Resources.class.getDeclaredField("mTypedArrayPool"); typedArrayPoolField.setAccessible(true); Object pool = typedArrayPoolField.get(resources); Class<?> poolClass = pool.getClass(); Method acquireMethod = poolClass.getDeclaredMethod("acquire"); acquireMethod.setAccessible(true); while (true) { Object typedArray = acquireMethod.invoke(pool); if (typedArray == null) { break; } } } catch (Throwable ignore) { } } if (SDK_INT >= Build.VERSION_CODES.M) { // Really should only be N; fix this as soon as it has its own API level try { Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl"); mResourcesImpl.setAccessible(true); // For the remainder, use the ResourcesImpl instead, where all the fields // now live resources = mResourcesImpl.get(resources); } catch (Throwable ignore) { } } // Prune bitmap and color state lists etc caches Object lock = null; if (SDK_INT >= JELLY_BEAN_MR2) { try { Field field = resources.getClass().getDeclaredField("mAccessLock"); field.setAccessible(true); lock = field.get(resources); } catch (Throwable ignore) { } } else { try { Field field = Resources.class.getDeclaredField("mTmpValue"); field.setAccessible(true); lock = field.get(resources); } catch (Throwable ignore) { } } if (lock == null) { lock = MonkeyPatcher.class; } //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (lock) { // Prune bitmap and color caches pruneResourceCache(resources, "mDrawableCache"); pruneResourceCache(resources,"mColorDrawableCache"); pruneResourceCache(resources,"mColorStateListCache"); if (SDK_INT >= M) { pruneResourceCache(resources, "mAnimatorCache"); pruneResourceCache(resources, "mStateListAnimatorCache"); } } } private static boolean pruneResourceCache(Object resources, String fieldName) { try { Class<?> resourcesClass = resources.getClass(); Field cacheField; try { cacheField = resourcesClass.getDeclaredField(fieldName); } catch (NoSuchFieldException ignore) { cacheField = Resources.class.getDeclaredField(fieldName); } cacheField.setAccessible(true); Object cache = cacheField.get(resources); // Find the class which defines the onConfigurationChange method Class<?> type = cacheField.getType(); if (SDK_INT < JELLY_BEAN) { if (cache instanceof SparseArray) { ((SparseArray) cache).clear(); return true; } else if (SDK_INT >= ICE_CREAM_SANDWICH && cache instanceof LongSparseArray) { // LongSparseArray has API level 16 but was private (and available inside // the framework) in 15 and is used for this cache. //noinspection AndroidLintNewApi ((LongSparseArray) cache).clear(); return true; } } else if (SDK_INT < M) { // JellyBean, KitKat, Lollipop if ("mColorStateListCache".equals(fieldName)) { // For some reason framework doesn't call clearDrawableCachesLocked on // this field if (cache instanceof LongSparseArray) { //noinspection AndroidLintNewApi ((LongSparseArray)cache).clear(); } } else if (type.isAssignableFrom(ArrayMap.class)) { Method clearArrayMap = Resources.class.getDeclaredMethod( "clearDrawableCachesLocked", ArrayMap.class, Integer.TYPE); clearArrayMap.setAccessible(true); clearArrayMap.invoke(resources, cache, -1); return true; } else if (type.isAssignableFrom(LongSparseArray.class)) { Method clearSparseMap = Resources.class.getDeclaredMethod( "clearDrawableCachesLocked", LongSparseArray.class, Integer.TYPE); clearSparseMap.setAccessible(true); clearSparseMap.invoke(resources, cache, -1); return true; } } else { // Marshmallow: DrawableCache class while (type != null) { try { Method configChangeMethod = type.getDeclaredMethod( "onConfigurationChange", Integer.TYPE); configChangeMethod.setAccessible(true); configChangeMethod.invoke(cache, -1); return true; } catch (Throwable ignore) { } type = type.getSuperclass(); } } } catch (Throwable ignore) { // Not logging these; while there is some checking of SDK_INT here to avoid // doing a lot of unnecessary field lookups, it's not entirely accurate and // errs on the side of caution (since different devices may have picked up // different snapshots of the framework); therefore, it's normal for this // to attempt to look up a field for a cache that isn't there; only if it's // really there will it continue to flush that particular cache. } return false; } }