/* * Copyright 2015-present wequick.net * * 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 net.wequick.small; import android.app.Application; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Handler; import android.os.Message; import net.wequick.small.util.FileUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * This class consists exclusively of methods that operate on apk plugin. * * <p>All the <tt>bundles</tt> are loaded by <tt>bundle.json</tt>. * The <tt>bundle.json</tt> format and usage are in * <a href="https://github.com/wequick/Small/wiki/UI-route">UI Route</a>. * * <p>Each bundle is resolved by <tt>BundleLauncher</tt>. * * <p>If the <tt>pkg</tt> is specified in <tt>bundle.json</tt>, * the <tt>bundle</tt> is refer to a plugin file with file name in converter * {@code "lib" + pkg.replaceAll("\\.", "_") + ".so"} * and resolved by a <tt>SoBundleLauncher</tt>. * * @see BundleLauncher */ public class Bundle { //______________________________________________________________________________ // Fields private static final String BUNDLE_MANIFEST_NAME = "bundle.json"; private static final String VERSION_KEY = "version"; private static final String BUNDLES_KEY = "bundles"; private static final String HOST_PACKAGE = "main"; private static final String DEFAULT_ENTRANCE_PATH = ""; private static final String DEFAULT_ENTRANCE_ACTIVITY = "MainActivity"; private static final class Manifest { String version; List<Bundle> bundles; } private static List<BundleLauncher> sBundleLaunchers = null; private static List<Bundle> sPreloadBundles = null; private static List<Bundle> sUpdatingBundles = null; private static File sPatchManifestFile = null; private static String sUserBundlesPath = null; private static boolean sIs64bit = false; // Thread & Handler private static final int MSG_COMPLETE = 1; private static LoadBundleHandler sHandler; private static LoadBundleThread sThread; private String mPackageName; private String uriString; private Uri uri; private URL url; // for WebBundleLauncher private Intent mIntent; private String type; private String path; private String query; private HashMap<String, String> rules; private int versionCode; private String versionName; private BundleLauncher mApplicableLauncher = null; private String mBuiltinAssetName = null; private File mBuiltinFile = null; private File mPatchFile = null; private File mExtractPath; private boolean launchable = true; private boolean enabled = true; private boolean patching = false; private String entrance = null; // Main activity for `apk bundle', index page for `web bundle' private BundleParser parser; //______________________________________________________________________________ // Class methods /** * @deprecated Use {@link Small#getBundle} instead. * @param name * @return */ public static Bundle findByName(String name) { Bundle bundle = findBundle(name, sPreloadBundles); if (bundle != null) return bundle; return findBundle(name, sUpdatingBundles); } private static Bundle findBundle(String name, List<Bundle> bundles) { if (name == null) return null; if (bundles == null) return null; for (Bundle bundle : bundles) { if (bundle.mPackageName == null) continue; if (bundle.mPackageName.equals(name)) return bundle; } return null; } /** * Update bundle.json and apply settings * @param data the manifest JSON object * @param force <tt>true</tt> if force to update current bundles * @return <tt>true</tt> if successfully updated */ public static boolean updateManifest(JSONObject data, boolean force) { if (data == null) return false; Manifest manifest = parseManifest(data); if (manifest == null) return false; String manifestJson; try { manifestJson = data.toString(2); } catch (JSONException e) { e.printStackTrace(); return false; } if (force) { // Save to file File manifestFile = getPatchManifestFile(); try { PrintWriter pw = new PrintWriter(new FileOutputStream(manifestFile)); pw.print(manifestJson); pw.flush(); pw.close(); } catch (Exception e) { e.printStackTrace(); return false; } // Update bundles for (Bundle bundle : manifest.bundles) { Bundle preloadBundle = findBundle(bundle.getPackageName(), sPreloadBundles); if (preloadBundle != null) { // Update bundle preloadBundle.uriString = bundle.uriString; preloadBundle.uri = bundle.uri; preloadBundle.rules = bundle.rules; } } } else { // Temporary add bundle for (Bundle bundle : manifest.bundles) { Bundle preloadBundle = findBundle(bundle.getPackageName(), sPreloadBundles); if (preloadBundle == null) { if (sUpdatingBundles == null) { sUpdatingBundles = new ArrayList<Bundle>(); } sUpdatingBundles.add(bundle); } } // Save to `SharedPreference' setCacheManifest(manifestJson); } return true; } private static String getCacheManifest() { return Small.getSharedPreferences().getString(BUNDLE_MANIFEST_NAME, null); } private static void setCacheManifest(String text) { SharedPreferences small = Small.getSharedPreferences(); SharedPreferences.Editor editor = small.edit(); if (text == null) { editor.remove(BUNDLE_MANIFEST_NAME); } else { editor.putString(BUNDLE_MANIFEST_NAME, text); } editor.apply(); } public static boolean is64bit() { return sIs64bit; } /** * Load bundles from manifest */ protected static void loadLaunchableBundles(Small.OnCompleteListener listener) { Context context = Small.getContext(); boolean synchronous = (listener == null); if (synchronous) { loadBundles(context); return; } // Asynchronous if (sThread == null) { sThread = new LoadBundleThread(context); sHandler = new LoadBundleHandler(listener); sThread.start(); } } private static File getPatchManifestFile() { if (sPatchManifestFile == null) { sPatchManifestFile = new File(Small.getContext().getFilesDir(), BUNDLE_MANIFEST_NAME); } return sPatchManifestFile; } private static void loadBundles(Context context) { JSONObject manifestData; try { File patchManifestFile = getPatchManifestFile(); String manifestJson = getCacheManifest(); if (manifestJson != null) { // Load from cache and save as patch if (!patchManifestFile.exists()) patchManifestFile.createNewFile(); PrintWriter pw = new PrintWriter(new FileOutputStream(patchManifestFile)); pw.print(manifestJson); pw.flush(); pw.close(); // Clear cache setCacheManifest(null); } else if (patchManifestFile.exists()) { // Load from patch BufferedReader br = new BufferedReader(new FileReader(patchManifestFile)); StringBuilder sb = new StringBuilder(); String line; while ((line = br.readLine()) != null) { sb.append(line); } br.close(); manifestJson = sb.toString(); } else { // Load from built-in `assets/bundle.json' InputStream builtinManifestStream = context.getAssets().open(BUNDLE_MANIFEST_NAME); int builtinSize = builtinManifestStream.available(); byte[] buffer = new byte[builtinSize]; builtinManifestStream.read(buffer); builtinManifestStream.close(); manifestJson = new String(buffer, 0, builtinSize); } // Parse manifest file manifestData = new JSONObject(manifestJson); } catch (Exception e) { e.printStackTrace(); return; } Manifest manifest = parseManifest(manifestData); if (manifest == null) return; setupLaunchers(context); loadBundles(manifest.bundles); } private static Manifest parseManifest(JSONObject data) { try { String version = data.getString(VERSION_KEY); return parseManifest(version, data); } catch (JSONException e) { e.printStackTrace(); return null; } } private static Manifest parseManifest(String version, JSONObject data) { if (version.equals("1.0.0")) { try { JSONArray bundleDescs = data.getJSONArray(BUNDLES_KEY); int N = bundleDescs.length(); List<Bundle> bundles = new ArrayList<Bundle>(N); for (int i = 0; i < N; i++) { try { JSONObject object = bundleDescs.getJSONObject(i); Bundle bundle = new Bundle(object); bundles.add(bundle); } catch (JSONException e) { // Ignored } } Manifest manifest = new Manifest(); manifest.version = version; manifest.bundles = bundles; return manifest; } catch (JSONException e) { e.printStackTrace(); return null; } } throw new UnsupportedOperationException("Unknown version " + version); } protected static List<Bundle> getLaunchableBundles() { return sPreloadBundles; } protected static void registerLauncher(BundleLauncher launcher) { if (sBundleLaunchers == null) { sBundleLaunchers = new ArrayList<BundleLauncher>(); } sBundleLaunchers.add(launcher); } protected static void onCreateLaunchers(Application app) { if (sBundleLaunchers == null) return; for (BundleLauncher launcher : sBundleLaunchers) { launcher.onCreate(app); } } protected static void setupLaunchers(Context context) { if (sBundleLaunchers == null) return; for (BundleLauncher launcher : sBundleLaunchers) { launcher.setUp(context); } } protected static Bundle getLaunchableBundle(Uri uri) { if (sPreloadBundles != null) { for (Bundle bundle : sPreloadBundles) { if (bundle.matchesRule(uri)) { if (bundle.mApplicableLauncher == null) { break; } if (!bundle.enabled) return null; // Illegal bundle (invalid signature, etc.) return bundle; } } } // Downgrade to show webView if (uri.getScheme() != null) { Bundle bundle = new Bundle(); try { bundle.url = new URL(uri.toString()); } catch (MalformedURLException e) { e.printStackTrace(); } bundle.prepareForLaunch(); bundle.setQuery(uri.getEncodedQuery()); // Fix issue #6 from Spring-Xu. bundle.mApplicableLauncher = new WebBundleLauncher(); bundle.mApplicableLauncher.prelaunchBundle(bundle); return bundle; } return null; } private Boolean matchesRule(Uri uri) { /* e.g. * input * - uri: http://base/abc.html * - self.uri: http://base * - self.rules: abc.html -> AbcController * output * - target => AbcController */ String uriString = uri.toString(); if (this.uriString == null || !uriString.startsWith(this.uriString)) return false; String srcPath = uriString.substring(this.uriString.length()); String srcQuery = uri.getEncodedQuery(); if (srcQuery != null) { srcPath = srcPath.substring(0, srcPath.length() - srcQuery.length() - 1); } String dstPath = null; String dstQuery = srcQuery; for (String key : this.rules.keySet()) { // TODO: regex match and replace if (key.equals(srcPath)) dstPath = this.rules.get(key); if (dstPath != null) break; } if (dstPath == null) return false; int index = dstPath.indexOf("?"); if (index > 0) { if (dstQuery != null) { dstQuery = dstQuery + "&" + dstPath.substring(index + 1); } else { dstQuery = dstPath.substring(index + 1); } dstPath = dstPath.substring(0, index); } this.path = dstPath; this.query = dstQuery; return true; } //______________________________________________________________________________ // Instance methods public Bundle() { } public Bundle(JSONObject map) { try { this.initWithMap(map); } catch (JSONException e) { e.printStackTrace(); } } public void upgrade() { if (mApplicableLauncher == null) return; mApplicableLauncher.upgradeBundle(this); } private void extractBundle(String assetName, File outFile) throws IOException { InputStream in = Small.getContext().getAssets().open(assetName); FileOutputStream out; if (outFile.exists()) { // Compare the two input steams to see if needs re-extract. FileInputStream fin = new FileInputStream(outFile); int inSize = in.available(); long outSize = fin.available(); if (inSize == outSize) { // FIXME: What about the size is same but the content is different? return; // UP-TO-DATE } out = new FileOutputStream(outFile); } else { out = new FileOutputStream(outFile); } // Extract left data byte[] buffer = new byte[1024]; int read; while ((read = in.read(buffer)) != -1) { out.write(buffer, 0, read); } out.flush(); out.close(); in.close(); } private void initWithMap(JSONObject map) throws JSONException { if (sUserBundlesPath == null) { // Lazy init sUserBundlesPath = Small.getContext().getApplicationInfo().nativeLibraryDir; sIs64bit = sUserBundlesPath.contains("64"); } if (map.has("pkg")) { String pkg = map.getString("pkg"); if (pkg != null && !pkg.equals(HOST_PACKAGE)) { mPackageName = pkg; if (Small.isLoadFromAssets()) { mBuiltinAssetName = pkg + ".apk"; mBuiltinFile = new File(FileUtils.getInternalBundlePath(), mBuiltinAssetName); mPatchFile = new File(FileUtils.getDownloadBundlePath(), mBuiltinAssetName); // Extract from assets to files try { extractBundle(mBuiltinAssetName, mBuiltinFile); } catch (IOException e) { e.printStackTrace(); } } else { String soName = "lib" + pkg.replaceAll("\\.", "_") + ".so"; mBuiltinFile = new File(sUserBundlesPath, soName); mPatchFile = new File(FileUtils.getDownloadBundlePath(), soName); } } } if (map.has("uri")) { String uri = map.getString("uri"); if (!uri.startsWith("http") && Small.getBaseUri() != null) { uri = Small.getBaseUri() + uri; } this.uriString = uri; this.uri = Uri.parse(uriString); } if (map.has("type")) { this.type = map.getString("type"); } this.rules = new HashMap<String, String>(); String entrancePath = DEFAULT_ENTRANCE_PATH; if (map.has("rules")) { // User rules to visit other page of bundle JSONObject rulesObj = map.getJSONObject("rules"); Iterator<String> it = rulesObj.keys(); while (it.hasNext()) { String from = it.next(); String to = rulesObj.getString(from); if (from.equals(DEFAULT_ENTRANCE_PATH)) { entrancePath = to; } else { this.rules.put("/" + from, to); } } } // Default rules to visit entrance page of bundle this.rules.put(DEFAULT_ENTRANCE_PATH, entrancePath); this.rules.put(".html", entrancePath); this.rules.put("/index", entrancePath); this.rules.put("/index.html", entrancePath); } protected void prepareForLaunch() { if (mIntent != null) return; if (mApplicableLauncher == null && sBundleLaunchers != null) { for (BundleLauncher launcher : sBundleLaunchers) { if (launcher.resolveBundle(this)) { mApplicableLauncher = launcher; break; } } } } protected void launchFrom(Context context) { if (mApplicableLauncher != null) { mApplicableLauncher.launchBundle(this, context); } } protected Intent createIntent(Context context) { if (mApplicableLauncher == null) { prepareForLaunch(); } if (mApplicableLauncher != null) { mApplicableLauncher.prelaunchBundle(this); } return mIntent; } protected Intent getIntent() { return mIntent; } protected void setIntent(Intent intent) { mIntent = intent; } protected String getPackageName() { return mPackageName; } protected Uri getUri() { return uri; } protected void setURL(URL url) { this.url = url; } protected URL getURL() { return url; } protected File getBuiltinFile() { return mBuiltinFile; } public File getPatchFile() { return mPatchFile; } protected File getExtractPath() { return mExtractPath; } protected void setExtractPath(File path) { this.mExtractPath = path; } protected String getType() { return type; } protected void setType(String type) { this.type = type; } protected String getQuery() { return query; } protected void setQuery(String query) { this.query = query; } protected String getPath() { return path; } protected void setPath(String path) { this.path = path; } protected String getActivityName() { String activityName = path; if (activityName == null || activityName.equals(DEFAULT_ENTRANCE_PATH)) { if (entrance != null) { return entrance; } activityName = DEFAULT_ENTRANCE_ACTIVITY; } String pkg = mPackageName != null ? mPackageName : Small.getContext().getPackageName(); char c = activityName.charAt(0); if (c == '.') { activityName = pkg + activityName; } else if (c >= 'A' && c <= 'Z') { activityName = pkg + '.' + activityName; } return activityName; } protected void setVersionCode(int versionCode) { this.versionCode = versionCode; Small.setBundleVersionCode(this.mPackageName, versionCode); } public int getVersionCode() { return versionCode; } protected void setVersionName(String versionName) { this.versionName = versionName; } public String getVersionName() { return versionName; } protected boolean isLaunchable() { return launchable && enabled; } protected void setLaunchable(boolean flag) { this.launchable = flag; } protected String getEntrance() { return entrance; } protected void setEntrance(String entrance) { this.entrance = entrance; } protected <T> T createObject(Context context, String type) { if (mApplicableLauncher == null) { prepareForLaunch(); } if (mApplicableLauncher == null) return null; return mApplicableLauncher.createObject(this, context, type); } protected boolean isEnabled() { return enabled; } protected void setEnabled(boolean enabled) { this.enabled = enabled; } protected boolean isPatching() { return patching; } protected void setPatching(boolean patching) { this.patching = patching; } protected BundleParser getParser() { return parser; } protected void setParser(BundleParser parser) { this.parser = parser; } public String getBuiltinAssetName() { return mBuiltinAssetName; } //______________________________________________________________________________ // Internal class private static class LoadBundleThread extends Thread { Context mContext; public LoadBundleThread(Context context) { mContext = context; } @Override public void run() { loadBundles(mContext); sHandler.obtainMessage(MSG_COMPLETE).sendToTarget(); } } private static final int LOADING_TIMEOUT_MINUTES = 5; private static void loadBundles(List<Bundle> bundles) { sPreloadBundles = bundles; // Prepare bundle for (Bundle bundle : bundles) { bundle.prepareForLaunch(); } // Handle I/O if (sIOActions != null) { ExecutorService executor = Executors.newFixedThreadPool(sIOActions.size()); for (Runnable action : sIOActions) { executor.execute(action); } executor.shutdown(); try { if (!executor.awaitTermination(LOADING_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { throw new RuntimeException("Failed to load bundles! (TIMEOUT > " + LOADING_TIMEOUT_MINUTES + "minutes)"); } } catch (InterruptedException e) { e.printStackTrace(); } sIOActions = null; } // Wait for the things to be done on UI thread before `postSetUp`, // as on 7.0+ we should wait a WebView been initialized. (#347) while (sRunningUIActionCount != 0) { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } // Notify `postSetUp' to all launchers for (BundleLauncher launcher : sBundleLaunchers) { launcher.postSetUp(); } // Wait for the things to be done on UI thread after `postSetUp`, // like creating a bundle application. while (sRunningUIActionCount != 0) { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } // Free all unused temporary variables for (Bundle bundle : bundles) { if (bundle.parser != null) { bundle.parser.close(); bundle.parser = null; } bundle.mBuiltinFile = null; bundle.mExtractPath = null; } } private static List<Runnable> sIOActions; private static int sRunningUIActionCount; protected static void postIO(Runnable action) { if (sIOActions == null) { sIOActions = new ArrayList<Runnable>(); } sIOActions.add(action); } protected static void postUI(final Runnable action) { if (sHandler == null) { action.run(); return; } beginUI(); Message msg = Message.obtain(sHandler, new Runnable() { @Override public void run() { action.run(); commitUI(); } }); msg.sendToTarget(); } protected static synchronized void beginUI() { sRunningUIActionCount++; } protected static synchronized void commitUI() { sRunningUIActionCount--; } private static class LoadBundleHandler extends Handler { private Small.OnCompleteListener mListener; public LoadBundleHandler(Small.OnCompleteListener listener) { mListener = listener; } @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_COMPLETE: if (mListener != null) { mListener.onComplete(); } mListener = null; sThread = null; sHandler = null; break; } } } }