/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.distribution; import java.io.BufferedInputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.jar.JarEntry; import java.util.jar.JarInputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import javax.net.ssl.SSLException; import ch.boye.httpclientandroidlib.protocol.HTTP; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.mozilla.gecko.annotation.RobocopTarget; import org.mozilla.gecko.AppConstants; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoSharedPrefs; import org.mozilla.gecko.Telemetry; import org.mozilla.gecko.annotation.JNITarget; import org.mozilla.gecko.util.FileUtils; import org.mozilla.gecko.util.HardwareUtils; import org.mozilla.gecko.util.ThreadUtils; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.os.SystemClock; import android.support.annotation.WorkerThread; import android.telephony.TelephonyManager; import android.util.Log; /** * Handles distribution file loading and fetching, * and the corresponding hand-offs to Gecko. */ @RobocopTarget public class Distribution { private static final String LOGTAG = "GeckoDistribution"; private static final int STATE_UNKNOWN = 0; private static final int STATE_NONE = 1; private static final int STATE_SET = 2; private static final String FETCH_PROTOCOL = "https"; private static final String FETCH_HOSTNAME = "mobile.cdn.mozilla.net"; private static final String FETCH_PATH = "/distributions/1/"; private static final String FETCH_EXTENSION = ".jar"; private static final String EXPECTED_CONTENT_TYPE = "application/java-archive"; private static final String DISTRIBUTION_PATH = "distribution/"; /** * Telemetry constants. */ private static final String HISTOGRAM_REFERRER_INVALID = "FENNEC_DISTRIBUTION_REFERRER_INVALID"; private static final String HISTOGRAM_DOWNLOAD_TIME_MS = "FENNEC_DISTRIBUTION_DOWNLOAD_TIME_MS"; private static final String HISTOGRAM_CODE_CATEGORY = "FENNEC_DISTRIBUTION_CODE_CATEGORY"; /** * Success/failure codes. Don't exceed the maximum listed in Histograms.json. */ private static final int CODE_CATEGORY_STATUS_OUT_OF_RANGE = 0; // HTTP status 'codes' run from 1 to 5. private static final int CODE_CATEGORY_OFFLINE = 6; private static final int CODE_CATEGORY_FETCH_EXCEPTION = 7; // It's a post-fetch exception if we were able to download, but not // able to extract. private static final int CODE_CATEGORY_POST_FETCH_EXCEPTION = 8; private static final int CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION = 9; // It's a malformed distribution if we could extract, but couldn't // process the contents. private static final int CODE_CATEGORY_MALFORMED_DISTRIBUTION = 10; // Specific fetch errors. private static final int CODE_CATEGORY_FETCH_SOCKET_ERROR = 11; private static final int CODE_CATEGORY_FETCH_SSL_ERROR = 12; private static final int CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE = 13; private static final int CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE = 14; // Corresponds to the high value in Histograms.json. private static final long MAX_DOWNLOAD_TIME_MSEC = 40000; // 40 seconds. // If this is true, ready callbacks that arrive after our state is initially determined // will be queued for delayed running. // This should only be the case on first run, when we're in STATE_NONE. // Implicitly accessed from any non-UI threads via Distribution.doInit, but in practice only one // will actually perform initialization, and "non-UI thread" really means "background thread". private volatile boolean shouldDelayLateCallbacks = false; /** * These tasks can be queued to run when a distribution is available. * * If distributionFound is called, it will be the only call. * If distributionNotFound is called, it might be followed by * a call to distributionArrivedLate. * * When distributionNotFound is called, * {@link org.mozilla.gecko.distribution.Distribution#exists()} will return * false. In the other two callbacks, it will return true. */ public interface ReadyCallback { @WorkerThread void distributionNotFound(); @WorkerThread void distributionFound(Distribution distribution); @WorkerThread void distributionArrivedLate(Distribution distribution); } /** * Used as a drop-off point for ReferrerReceiver. Checked when we process * first-run distribution. * * This is `protected` so that test code can clear it between runs. */ @RobocopTarget protected static volatile ReferrerDescriptor referrer; private static Distribution instance; private final Context context; private final String packagePath; private final String prefsBranch; volatile int state = STATE_UNKNOWN; private File distributionDir; private final Queue onDistributionReady = new ConcurrentLinkedQueue<>(); // Callbacks in this queue have been invoked once as distributionNotFound. // If they're invoked again, it'll be with distributionArrivedLate. private final Queue onLateReady = new ConcurrentLinkedQueue<>(); /** * This is a little bit of a bad singleton, because in principle a Distribution * can be created with arbitrary paths. So we only have one path to get here, and * it uses the default arguments. Watch out if you're creating your own instances! */ public static synchronized Distribution getInstance(Context context) { if (instance == null) { instance = new Distribution(context); } return instance; } @RobocopTarget public static class DistributionDescriptor { public final boolean valid; public final String id; public final String version; // Example uses a float, but that's a crazy idea. // Default UI-visible description of the distribution. public final String about; // Each distribution file can include multiple localized versions of // the 'about' string. These are represented as, e.g., "about.en-US" // keys in the Global object. // Here we map locale to description. public final Map localizedAbout; @SuppressWarnings("unchecked") public DistributionDescriptor(JSONObject obj) { this.id = obj.optString("id"); this.version = obj.optString("version"); this.about = obj.optString("about"); Map loc = new HashMap(); try { Iterator keys = obj.keys(); while (keys.hasNext()) { String key = keys.next(); if (key.startsWith("about.")) { String locale = key.substring(6); if (!obj.isNull(locale)) { loc.put(locale, obj.getString(key)); } } } } catch (JSONException ex) { Log.w(LOGTAG, "Unable to completely process distribution JSON.", ex); } this.localizedAbout = Collections.unmodifiableMap(loc); this.valid = (null != this.id) && (null != this.version) && (null != this.about); } } private static Distribution init(final Distribution distribution) { // Read/write preferences and files on the background thread. ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { boolean distributionSet = distribution.doInit(); if (distributionSet) { String preferencesJSON = ""; try { final File descFile = distribution.getDistributionFile("preferences.json"); preferencesJSON = FileUtils.readStringFromFile(descFile); } catch (IOException e) { Log.e(LOGTAG, "Error getting distribution descriptor file.", e); } GeckoAppShell.notifyObservers("Distribution:Set", preferencesJSON); } } }); return distribution; } /** * Initializes distribution if it hasn't already been initialized. Sends * messages to Gecko as appropriate. * * @param packagePath where to look for the distribution directory. */ @RobocopTarget public static Distribution init(final Context context, final String packagePath, final String prefsPath) { return init(new Distribution(context, packagePath, prefsPath)); } /** * Use Context.getPackageResourcePath to find an implicit * package path. Reuses the existing Distribution if one exists. */ @RobocopTarget public static Distribution init(final Context context) { return init(Distribution.getInstance(context)); } /** * Returns parsed contents of bookmarks.json. * This method should only be called from a background thread. */ public static JSONArray getBookmarks(final Context context) { Distribution dist = new Distribution(context); return dist.getBookmarks(); } /** * @param packagePath where to look for the distribution directory. */ public Distribution(final Context context, final String packagePath, final String prefsBranch) { this.context = context; this.packagePath = packagePath; this.prefsBranch = prefsBranch; } public Distribution(final Context context) { this(context, context.getPackageResourcePath(), null); } /** * This method is called by ReferrerReceiver when we receive a post-install * notification from Google Play. * * @param ref a parsed referrer value from the store-supplied intent. */ public static void onReceivedReferrer(final Context context, final ReferrerDescriptor ref) { // Track the referrer object for distribution handling. referrer = ref; ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { final Distribution distribution = Distribution.getInstance(context); // This will bail if we aren't delayed, or we already have a distribution. distribution.processDelayedReferrer(ref); // On Android 5+ we might receive the referrer intent // and never actually launch the browser, which is the usual signal // for the distribution init process to complete. // Attempt to init here to handle that case. // Profile setup that relies on the distribution will occur // when the browser is eventually launched, via `addOnDistributionReadyCallback`. distribution.doInit(); } }); } /** * Handle a referrer intent that arrives after first use of the distribution. */ private void processDelayedReferrer(final ReferrerDescriptor ref) { ThreadUtils.assertOnBackgroundThread(); if (state != STATE_NONE) { return; } Log.i(LOGTAG, "Processing delayed referrer."); if (!checkIntentDistribution(ref)) { // Oh well. No sense keeping these tasks around. this.onLateReady.clear(); return; } // Persist our new state. this.state = STATE_SET; getSharedPreferences().edit().putInt(getKeyName(), this.state).apply(); // Just in case this isn't empty but doInit has finished. runReadyQueue(); // Now process any tasks that already ran while we were in STATE_NONE // to tell them of our good news. runLateReadyQueue(); // Make sure that changes to search defaults are applied immediately. GeckoAppShell.notifyObservers("Distribution:Changed", ""); } /** * Helper to grab a file in the distribution directory. * * Returns null if there is no distribution directory or the file * doesn't exist. Ensures init first. */ public File getDistributionFile(String name) { Log.d(LOGTAG, "Getting file from distribution."); if (this.state == STATE_UNKNOWN) { if (!this.doInit()) { return null; } } File dist = ensureDistributionDir(); if (dist == null) { return null; } File descFile = new File(dist, name); if (!descFile.exists()) { Log.e(LOGTAG, "Distribution directory exists, but no file named " + name); return null; } return descFile; } public DistributionDescriptor getDescriptor() { File descFile = getDistributionFile("preferences.json"); if (descFile == null) { // Logging and existence checks are handled in getDistributionFile. return null; } try { JSONObject all = FileUtils.readJSONObjectFromFile(descFile); if (!all.has("Global")) { Log.e(LOGTAG, "Distribution preferences.json has no Global entry!"); return null; } return new DistributionDescriptor(all.getJSONObject("Global")); } catch (IOException e) { Log.e(LOGTAG, "Error getting distribution descriptor file.", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return null; } catch (JSONException e) { Log.e(LOGTAG, "Error parsing preferences.json", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return null; } } /** * Get the Android preferences from the preferences.json file, if any exist. * @return The preferences in a JSONObject, or an empty JSONObject if no preferences are defined. */ public JSONObject getAndroidPreferences() { final File descFile = getDistributionFile("preferences.json"); if (descFile == null) { // Logging and existence checks are handled in getDistributionFile. return new JSONObject(); } try { final JSONObject all = FileUtils.readJSONObjectFromFile(descFile); if (!all.has("AndroidPreferences")) { return new JSONObject(); } return all.getJSONObject("AndroidPreferences"); } catch (IOException e) { Log.e(LOGTAG, "Error getting distribution descriptor file.", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return new JSONObject(); } catch (JSONException e) { Log.e(LOGTAG, "Error parsing preferences.json", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return new JSONObject(); } } public JSONArray getBookmarks() { File bookmarks = getDistributionFile("bookmarks.json"); if (bookmarks == null) { // Logging and existence checks are handled in getDistributionFile. return null; } try { return new JSONArray(FileUtils.readStringFromFile(bookmarks)); } catch (IOException e) { Log.e(LOGTAG, "Error getting bookmarks", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return null; } catch (JSONException e) { Log.e(LOGTAG, "Error parsing bookmarks.json", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); return null; } } /** * Don't call from the main thread. * * Postcondition: if this returns true, distributionDir will have been * set and populated. * * This method is *only* protected for use from testDistribution. * * @return true if we've set a distribution. */ @RobocopTarget protected boolean doInit() { ThreadUtils.assertNotOnUiThread(); // Bail if we've already tried to initialize the distribution, and // there wasn't one. final SharedPreferences settings = getSharedPreferences(); final String keyName = getKeyName(); this.state = settings.getInt(keyName, STATE_UNKNOWN); if (this.state == STATE_NONE) { runReadyQueue(); return false; } // We've done the work once; don't do it again. if (this.state == STATE_SET) { // Note that we don't compute the distribution directory. // Call `ensureDistributionDir` if you need it. runReadyQueue(); return true; } // We try to find the install intent, then the APK, then the system directory, and finally // an already copied distribution. Already copied might originate from the bouncer APK. final boolean distributionSet = checkIntentDistribution(referrer) || copyAndCheckAPKDistribution() || checkSystemDistribution() || checkDataDistribution(); // If this is our first run -- and thus we weren't already in STATE_NONE or STATE_SET above -- // and we didn't find a distribution already, then we should hold on to callbacks in case we // get a late distribution. this.shouldDelayLateCallbacks = !distributionSet; this.state = distributionSet ? STATE_SET : STATE_NONE; settings.edit().putInt(keyName, this.state).apply(); runReadyQueue(); return distributionSet; } /** * If applicable, download and select the distribution specified in * the referrer intent. * * @return true if a referrer-supplied distribution was selected. */ private boolean checkIntentDistribution(final ReferrerDescriptor referrer) { if (referrer == null) { return false; } URI uri = getReferredDistribution(referrer); if (uri == null) { return false; } long start = SystemClock.uptimeMillis(); Log.v(LOGTAG, "Downloading referred distribution: " + uri); try { final HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); // If the Search Activity starts, and we handle the referrer intent, this'll return // null. Recover gracefully in this case. final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface(); final String ua; if (geckoInterface == null) { // Fall back to GeckoApp's default implementation. ua = HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET : AppConstants.USER_AGENT_FENNEC_MOBILE; } else { ua = geckoInterface.getDefaultUAString(); } connection.setRequestProperty(HTTP.USER_AGENT, ua); connection.setRequestProperty("Accept", EXPECTED_CONTENT_TYPE); try { final JarInputStream distro; try { distro = fetchDistribution(uri, connection); } catch (Exception e) { Log.e(LOGTAG, "Error fetching distribution from network.", e); recordFetchTelemetry(e); return false; } long end = SystemClock.uptimeMillis(); final long duration = end - start; Log.d(LOGTAG, "Distro fetch took " + duration + "ms; result? " + (distro != null)); Telemetry.addToHistogram(HISTOGRAM_DOWNLOAD_TIME_MS, clamp(MAX_DOWNLOAD_TIME_MSEC, duration)); if (distro == null) { // Nothing to do. return false; } // Try to copy distribution files from the fetched stream. try { Log.d(LOGTAG, "Copying files from fetched zip."); if (copyFilesFromStream(distro)) { // We always copy to the data dir, and we only copy files from // a 'distribution' subdirectory. Now determine our actual distribution directory. return checkDataDistribution(); } } catch (SecurityException e) { Log.e(LOGTAG, "Security exception copying files. Corrupt or malicious?", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION); } catch (Exception e) { Log.e(LOGTAG, "Error copying files from distribution.", e); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_EXCEPTION); } finally { distro.close(); } } finally { connection.disconnect(); } } catch (IOException e) { Log.e(LOGTAG, "Error copying distribution files from network.", e); recordFetchTelemetry(e); } return false; } private static final int clamp(long v, long c) { return (int) Math.min(c, v); } /** * Fetch the provided URI, returning a {@link JarInputStream} if the response body * is appropriate. * * Protected to allow for mocking. * * @return the entity body as a stream, or null on failure. */ @SuppressWarnings("static-method") @RobocopTarget protected JarInputStream fetchDistribution(URI uri, HttpURLConnection connection) throws IOException { final int status = connection.getResponseCode(); Log.d(LOGTAG, "Distribution fetch: " + status); // We record HTTP statuses as 2xx, 3xx, 4xx, 5xx => 2, 3, 4, 5. final int value; if (status > 599 || status < 100) { Log.wtf(LOGTAG, "Unexpected HTTP status code: " + status); value = CODE_CATEGORY_STATUS_OUT_OF_RANGE; } else { value = status / 100; } Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, value); if (status != 200) { Log.w(LOGTAG, "Got status " + status + " fetching distribution."); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE); return null; } final String contentType = connection.getContentType(); if (contentType == null || !contentType.startsWith(EXPECTED_CONTENT_TYPE)) { Log.w(LOGTAG, "Malformed response: invalid Content-Type."); Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE); return null; } return new JarInputStream(new BufferedInputStream(connection.getInputStream()), true); } private static void recordFetchTelemetry(final Exception exception) { if (exception == null) { // Should never happen. Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION); return; } if (exception instanceof UnknownHostException) { // Unknown host => we're offline. Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_OFFLINE); return; } if (exception instanceof SSLException) { Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SSL_ERROR); return; } if (exception instanceof ProtocolException || exception instanceof SocketException) { Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SOCKET_ERROR); return; } Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION); } /** * @return true if we copied files out of the APK. Sets distributionDir in that case. */ private boolean copyAndCheckAPKDistribution() { try { // First, try copying distribution files out of the APK. if (copyFilesFromPackagedAssets()) { // We always copy to the data dir, and we only copy files from // a 'distribution' subdirectory. Now determine our actual distribution directory. return checkDataDistribution(); } } catch (IOException e) { Log.e(LOGTAG, "Error copying distribution files from APK.", e); } return false; } /** * @return true if we found a data distribution (copied from APK or OTA). Sets distributionDir in that case. */ private boolean checkDataDistribution() { return checkDirectories(getDataDistributionDirectories(context)); } /** * @return true if we found a system distribution. Sets distributionDir in that case. */ private boolean checkSystemDistribution() { return checkDirectories(getSystemDistributionDirectories(context)); } /** * @return true if one of the specified distribution directories exists. Sets distributionDir in that case. */ private boolean checkDirectories(String[] directories) { for (String path : directories) { File directory = new File(path); if (directory.exists()) { distributionDir = directory; return true; } } return false; } /** * Unpack distribution files from a downloaded jar stream. * * The caller is responsible for closing the provided stream. */ private boolean copyFilesFromStream(JarInputStream jar) throws FileNotFoundException, IOException { final byte[] buffer = new byte[1024]; boolean distributionSet = false; JarEntry entry; while ((entry = jar.getNextJarEntry()) != null) { final String name = entry.getName(); if (entry.isDirectory()) { // We'll let getDataFile deal with creating the directory hierarchy. // Yes, we can do better, but it can wait. continue; } if (!name.startsWith(DISTRIBUTION_PATH)) { continue; } File outFile = getDataFile(name); if (outFile == null) { continue; } distributionSet = true; writeStream(jar, outFile, entry.getTime(), buffer); } return distributionSet; } /** * Copies the /assets/distribution folder out of the APK and into the app's data directory. * Returns true if distribution files were found and copied. */ private boolean copyFilesFromPackagedAssets() throws IOException { final File applicationPackage = new File(packagePath); final ZipFile zip = new ZipFile(applicationPackage); final String assetsPrefix = "assets/"; final String fullPrefix = assetsPrefix + DISTRIBUTION_PATH; boolean distributionSet = false; try { final byte[] buffer = new byte[1024]; final Enumeration zipEntries = zip.entries(); while (zipEntries.hasMoreElements()) { final ZipEntry fileEntry = zipEntries.nextElement(); final String name = fileEntry.getName(); if (fileEntry.isDirectory()) { // We'll let getDataFile deal with creating the directory hierarchy. continue; } // Read from "assets/distribution/**". if (!name.startsWith(fullPrefix)) { continue; } // Write to "distribution/**". final String nameWithoutPrefix = name.substring(assetsPrefix.length()); final File outFile = getDataFile(nameWithoutPrefix); if (outFile == null) { continue; } distributionSet = true; final InputStream fileStream = zip.getInputStream(fileEntry); try { writeStream(fileStream, outFile, fileEntry.getTime(), buffer); } finally { fileStream.close(); } } } finally { zip.close(); } return distributionSet; } private void writeStream(InputStream fileStream, File outFile, final long modifiedTime, byte[] buffer) throws FileNotFoundException, IOException { final OutputStream outStream = new FileOutputStream(outFile); try { int count; while ((count = fileStream.read(buffer)) > 0) { outStream.write(buffer, 0, count); } outFile.setLastModified(modifiedTime); } finally { outStream.close(); } } /** * Return a File instance in the data directory, ensuring * that the parent exists. * * @return null if the parents could not be created. */ private File getDataFile(final String name) { File outFile = new File(getDataDir(), name); File dir = outFile.getParentFile(); if (!dir.exists()) { Log.d(LOGTAG, "Creating " + dir.getAbsolutePath()); if (!dir.mkdirs()) { Log.e(LOGTAG, "Unable to create directories: " + dir.getAbsolutePath()); return null; } } return outFile; } private URI getReferredDistribution(ReferrerDescriptor descriptor) { final String content = descriptor.content; if (content == null) { return null; } // We restrict here to avoid injection attacks. After all, // we're downloading a distribution payload based on intent input. if (!content.matches("^[a-zA-Z0-9]+$")) { Log.e(LOGTAG, "Invalid referrer content: " + content); Telemetry.addToHistogram(HISTOGRAM_REFERRER_INVALID, 1); return null; } try { return new URI(FETCH_PROTOCOL, FETCH_HOSTNAME, FETCH_PATH + content + FETCH_EXTENSION, null); } catch (URISyntaxException e) { // This should never occur. Log.wtf(LOGTAG, "Invalid URI with content " + content + "!"); return null; } } /** * After calling this method, either distributionDir * will be set, or there is no distribution in use. * * Only call after init. */ private File ensureDistributionDir() { if (this.distributionDir != null) { return this.distributionDir; } if (this.state != STATE_SET) { return null; } // After init, we know that either we've copied a distribution out of // the APK, or it exists in /system/. // Look in each location in turn. // (This could be optimized by caching the path in shared prefs.) if (checkDataDistribution() || checkSystemDistribution()) { return distributionDir; } return null; } private String getDataDir() { return context.getApplicationInfo().dataDir; } @JNITarget public static String[] getDistributionDirectories() { final Context context = GeckoAppShell.getApplicationContext(); final String[] dataDirectories = getDataDistributionDirectories(context); final String[] systemDirectories = getSystemDistributionDirectories(context); final String[] directories = new String[dataDirectories.length + systemDirectories.length]; System.arraycopy(dataDirectories, 0, directories, 0, dataDirectories.length); System.arraycopy(systemDirectories, 0, directories, dataDirectories.length, systemDirectories.length); return directories; } /** * Get a list of system distribution folder candidates. * * /system//distribution// - For bundled distributions for specific network providers * /system//distribution/ - For bundled distributions for specific countries * /system//distribution/default - For bundled distributions with no matching mcc/mnc * /system//distribution - Default non-bundled system distribution */ private static String[] getSystemDistributionDirectories(Context context) { final String baseDirectory = "/system/" + context.getPackageName() + "/distribution"; return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory); } /** * Get a list of data distribution folder candidates. * * /distribution// - For bundled distributions for specific network providers * /distribution/ - For bundled distributions for specific countries * /distribution/default - For bundled distributions with no matching mcc/mnc * /distribution - Default non-bundled system distribution */ private static String[] getDataDistributionDirectories(Context context) { final String baseDirectory = new File(context.getApplicationInfo().dataDir, DISTRIBUTION_PATH).getAbsolutePath(); return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory); } /** * Get a list of distribution folder candidates inside the specified base directory. */ private static String[] getDistributionDirectoriesFromBaseDirectory(Context context, String baseDirectory) { final TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null && telephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY) { final String simOperator = telephonyManager.getSimOperator(); if (simOperator != null && simOperator.length() >= 5) { final String mcc = simOperator.substring(0, 3); final String mnc = simOperator.substring(3); return new String[] { baseDirectory + "/" + mcc + "/" + mnc, baseDirectory + "/" + mcc, baseDirectory + "/default", baseDirectory }; } } return new String[] { baseDirectory + "/default", baseDirectory }; } /** * The provided ReadyCallback will be queued for execution after * the distribution is ready, or queued for immediate execution if the * distribution has already been processed. * * Each ReadyCallback will be executed on the background thread. */ public void addOnDistributionReadyCallback(final ReadyCallback callback) { if (state == STATE_UNKNOWN) { // Queue for later. onDistributionReady.add(callback); } else { invokeCallbackDelayed(callback); } } /** * Run our delayed queue, after a delayed distribution arrives. */ private void runLateReadyQueue() { ReadyCallback task; while ((task = onLateReady.poll()) != null) { invokeLateCallbackDelayed(task); } } /** * Execute tasks that wanted to run when we were done loading * the distribution. */ private void runReadyQueue() { ReadyCallback task; while ((task = onDistributionReady.poll()) != null) { invokeCallbackDelayed(task); } } private void invokeLateCallbackDelayed(final ReadyCallback callback) { ThreadUtils.postToBackgroundThread(new Runnable() { @Override public void run() { // Sanity. if (state != STATE_SET) { Log.w(LOGTAG, "Refusing to invoke late distro callback in state " + state); return; } callback.distributionArrivedLate(Distribution.this); } }); } private void invokeCallbackDelayed(final ReadyCallback callback) { ThreadUtils.postToBackgroundThread(new Runnable() { @WorkerThread @Override public void run() { switch (state) { case STATE_SET: callback.distributionFound(Distribution.this); break; case STATE_NONE: callback.distributionNotFound(); if (shouldDelayLateCallbacks) { onLateReady.add(callback); } break; default: throw new IllegalStateException("Expected STATE_NONE or STATE_SET, got " + state); } } }); } /** * A safe way for callers to determine if this Distribution instance * represents a real live distribution. */ public boolean exists() { return state == STATE_SET; } private String getKeyName() { return context.getPackageName() + ".distribution_state"; } private SharedPreferences getSharedPreferences() { final SharedPreferences settings; if (prefsBranch == null) { settings = GeckoSharedPrefs.forApp(context); } else { settings = context.getSharedPreferences(prefsBranch, Activity.MODE_PRIVATE); } return settings; } }