Bug 1038843 - Part 1: Land initial stumbler Java code and manifest fragments. r=nalexander,rnewman
authorGarvan Keeley <gkeeley@mozilla.com>
Thu, 28 Aug 2014 17:19:00 -0700
changeset 224240 0239fbe8a0c8119fa37323c3143201a9e3d82ee1
parent 224239 5b89084d3dd65db7461998db03f554e614a59a79
child 224241 e7075da2b9d84de2dffb7e9db27b25cc495fc9a0
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, rnewman
bugs1038843
milestone34.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1038843 - Part 1: Land initial stumbler Java code and manifest fragments. r=nalexander,rnewman The stumbler is a geolocation data collecting and uploading service. This code is a partial export of the MozStumbler repository hosted at https://github.com/mozilla/MozStumbler.
mobile/android/stumbler/java/org/mozilla/mozstumbler/PlaceHolder.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/PassiveServiceReceiver.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/SSIDBlockList.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/WifiBlockListInterface.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerNoWCDMA.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/PersistentIntentService.java
mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/Zipper.java
mobile/android/stumbler/manifests/StumblerManifest_services.xml.in
mobile/android/stumbler/stumbler_sources.mozbuild
deleted file mode 100644
--- a/mobile/android/stumbler/java/org/mozilla/mozstumbler/PlaceHolder.java
+++ /dev/null
@@ -1,12 +0,0 @@
-/* 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.mozstumbler;
-
-/**
- * Bug 1024708: this class is a place-holder for landing the build integration
- * of the background stumbler into Fennec.
- */
-public class PlaceHolder {
-}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java
@@ -0,0 +1,60 @@
+/* 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.mozstumbler.service;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+public class AppGlobals {
+    public static final String LOG_PREFIX = "Stumbler:";
+
+    /* All intent actions start with this string. Only locally broadcasted. */
+    public static final String ACTION_NAMESPACE = "org.mozilla.mozstumbler.intent.action";
+
+    /* Handle this for logging reporter info. */
+    public static final String ACTION_GUI_LOG_MESSAGE = AppGlobals.ACTION_NAMESPACE + ".LOG_MESSAGE";
+    public static final String ACTION_GUI_LOG_MESSAGE_EXTRA = ACTION_GUI_LOG_MESSAGE + ".MESSAGE";
+
+    /* Defined here so that the Reporter class can access the time of an Intent in a generic fashion.
+     * Classes should have their own constant that is assigned to this, for example,
+     * WifiScanner has ACTION_WIFIS_SCANNED_ARG_TIME = ACTION_ARG_TIME.
+     * This member definition in the broadcaster makes it clear what the extra Intent args are for that class. */
+    public static final String ACTION_ARG_TIME = "time";
+
+    /* Location constructor requires a named origin, these are created in the app. */
+    public static final String LOCATION_ORIGIN_INTERNAL = "internal";
+
+    public enum ActiveOrPassiveStumbling { ACTIVE_STUMBLING, PASSIVE_STUMBLING }
+
+    /* In passive mode, only scan this many times for each gps. */
+    public static final int PASSIVE_MODE_MAX_SCANS_PER_GPS = 3;
+
+    /* These are set on startup. The appVersionName and code are not used in the service-only case. */
+    public static String appVersionName = "0.0.0";
+    public static int appVersionCode = 0;
+    public static String appName = "StumblerService";
+    public static boolean isDebug;
+
+    /* The log activity will clear this periodically, and display the messages.
+     * Always null when the stumbler service is used stand-alone. */
+    public static volatile ConcurrentLinkedQueue<String> guiLogMessageBuffer;
+
+    public static void guiLogError(String msg) {
+        guiLogInfo(msg, "red", true);
+    }
+
+    public static void guiLogInfo(String msg) {
+        guiLogInfo(msg, "white", false);
+    }
+
+    public static void guiLogInfo(String msg, String color, boolean isBold) {
+        if (guiLogMessageBuffer != null) {
+            if (isBold) {
+                msg = "<b>" + msg + "</b>";
+            }
+            guiLogMessageBuffer.add("<font color='" + color +"'>" + msg + "</font>");
+        }
+    }
+}
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java
@@ -0,0 +1,210 @@
+/* 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.mozstumbler.service;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.location.Location;
+import android.os.Build.VERSION;
+import android.text.TextUtils;
+import android.util.Log;
+
+public  final class Prefs {
+    private static final String LOG_TAG = Prefs.class.getSimpleName();
+    private static final String NICKNAME_PREF = "nickname";
+    private static final String USER_AGENT_PREF = "user-agent";
+    private static final String VALUES_VERSION_PREF = "values_version";
+    private static final String WIFI_ONLY = "wifi_only";
+    private static final String LAT_PREF = "lat_pref";
+    private static final String LON_PREF = "lon_pref";
+    private static final String GEOFENCE_HERE = "geofence_here";
+    private static final String GEOFENCE_SWITCH = "geofence_switch";
+    private static final String FIREFOX_SCAN_ENABLED = "firefox_scan_on";
+    private static final String MOZ_API_KEY = "moz_api_key";
+    private static final String WIFI_SCAN_ALWAYS = "wifi_scan_always";
+    private static final String LAST_ATTEMPTED_UPLOAD_TIME = "last_attempted_upload_time";
+    // Public for MozStumbler to use for manual upgrade of old prefs.
+    public static final String PREFS_FILE = Prefs.class.getSimpleName();
+
+    private final SharedPreferences mSharedPrefs;
+    static private Prefs sInstance;
+
+    private Prefs(Context context) {
+        mSharedPrefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_MULTI_PROCESS | Context.MODE_PRIVATE);
+        if (getPrefs().getInt(VALUES_VERSION_PREF, -1) != AppGlobals.appVersionCode) {
+            Log.i(LOG_TAG, "Version of the application has changed. Updating default values.");
+            // Remove old keys
+            getPrefs().edit()
+                    .remove("reports")
+                    .remove("power_saving_mode")
+                    .commit();
+
+            getPrefs().edit().putInt(VALUES_VERSION_PREF, AppGlobals.appVersionCode).commit();
+            getPrefs().edit().commit();
+        }
+    }
+
+    /* Prefs must be created on application startup or service startup.
+     * TODO: turn into regular singleton if Context dependency can be removed. */
+    public static void createGlobalInstance(Context c) {
+        if (sInstance != null) {
+            return;
+        }
+        sInstance = new Prefs(c);
+    }
+
+    /* Only access after CreatePrefsInstance(Context) has been called at startup. */
+    public static Prefs getInstance() {
+        assert(sInstance != null);
+        return sInstance;
+    }
+
+    ///
+    /// Setters
+    ///
+    public synchronized void setUserAgent(String userAgent) {
+        setStringPref(USER_AGENT_PREF, userAgent);
+    }
+
+    public synchronized void setUseWifiOnly(boolean state) {
+        setBoolPref(WIFI_ONLY, state);
+    }
+
+    public synchronized void setGeofenceEnabled(boolean state) {
+        setBoolPref(GEOFENCE_SWITCH, state);
+    }
+
+    public synchronized void setGeofenceHere(boolean flag) {
+        setBoolPref(GEOFENCE_HERE, flag);
+    }
+
+    public synchronized void setGeofenceLocation(Location location) {
+        SharedPreferences.Editor editor = getPrefs().edit();
+        editor.putFloat(LAT_PREF, (float) location.getLatitude());
+        editor.putFloat(LON_PREF, (float) location.getLongitude());
+        apply(editor);
+    }
+
+    public synchronized void setMozApiKey(String s) {
+        setStringPref(MOZ_API_KEY, s);
+    }
+
+    ///
+    /// Getters
+    ///
+    public synchronized String getUserAgent() {
+        String s = getStringPref(USER_AGENT_PREF);
+        return (s == null)? AppGlobals.appName + "/" + AppGlobals.appVersionName : s;
+    }
+
+    public synchronized boolean getFirefoxScanEnabled() {
+        return getBoolPrefWithDefault(FIREFOX_SCAN_ENABLED, false);
+    }
+
+    public synchronized String getMozApiKey() {
+        String s = getStringPref(MOZ_API_KEY);
+        return (s == null)? "no-mozilla-api-key" : s;
+    }
+
+    public synchronized boolean getGeofenceEnabled() {
+        return getBoolPrefWithDefault(GEOFENCE_SWITCH, false);
+    }
+
+    public synchronized boolean getGeofenceHere() {
+        return getBoolPrefWithDefault(GEOFENCE_HERE, false);
+    }
+
+    public synchronized Location getGeofenceLocation() {
+        Location loc = new Location(AppGlobals.LOCATION_ORIGIN_INTERNAL);
+        loc.setLatitude(getPrefs().getFloat(LAT_PREF, 0));
+        loc.setLongitude(getPrefs().getFloat(LON_PREF,0));
+        return loc;
+    }
+
+    // This is the time an upload was last attempted, not necessarily successful.
+    // Used to ensure upload attempts aren't happening too frequently.
+    public synchronized long getLastAttemptedUploadTime() {
+        return getPrefs().getLong(LAST_ATTEMPTED_UPLOAD_TIME, 0);
+    }
+
+    public synchronized String getNickname() {
+        String nickname = getStringPref(NICKNAME_PREF);
+        if (nickname != null) {
+            nickname = nickname.trim();
+        }
+        return TextUtils.isEmpty(nickname) ? null : nickname;
+    }
+
+    public synchronized void setFirefoxScanEnabled(boolean on) {
+        setBoolPref(FIREFOX_SCAN_ENABLED, on);
+    }
+
+    public synchronized void setLastAttemptedUploadTime(long time) {
+        SharedPreferences.Editor editor = getPrefs().edit();
+        editor.putLong(LAST_ATTEMPTED_UPLOAD_TIME, time);
+        apply(editor);
+    }
+
+    public synchronized void setNickname(String nick) {
+        if (nick != null) {
+            nick = nick.trim();
+            if (nick.length() > 0) {
+                setStringPref(NICKNAME_PREF, nick);
+            }
+        }
+    }
+
+    public synchronized boolean getUseWifiOnly() {
+        return getBoolPrefWithDefault(WIFI_ONLY, true);
+    }
+
+    public synchronized boolean getWifiScanAlways() {
+        return getBoolPrefWithDefault(WIFI_SCAN_ALWAYS, false);
+    }
+
+    public synchronized void setWifiScanAlways(boolean b) {
+        setBoolPref(WIFI_SCAN_ALWAYS, b);
+    }
+
+    ///
+    /// Privates
+    ///
+
+    private String getStringPref(String key) {
+        return getPrefs().getString(key, null);
+    }
+
+    private boolean getBoolPrefWithDefault(String key, boolean def) {
+        return getPrefs().getBoolean(key, def);
+    }
+
+    private void setBoolPref(String key, Boolean state) {
+        SharedPreferences.Editor editor = getPrefs().edit();
+        editor.putBoolean(key,state);
+        apply(editor);
+    }
+
+    private void setStringPref(String key, String value) {
+        SharedPreferences.Editor editor = getPrefs().edit();
+        editor.putString(key, value);
+        apply(editor);
+    }
+
+    @TargetApi(9)
+    private static void apply(SharedPreferences.Editor editor) {
+        if (VERSION.SDK_INT >= 9) {
+            editor.apply();
+        } else if (!editor.commit()) {
+            Log.e(LOG_TAG, "", new IllegalStateException("commit() failed?!"));
+        }
+    }
+
+    @SuppressLint("InlinedApi")
+    private SharedPreferences getPrefs() {
+        return mSharedPrefs;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/PassiveServiceReceiver.java
@@ -0,0 +1,73 @@
+/* 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.mozstumbler.service.mainthread;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.stumblerthread.StumblerService;
+
+/* Starts the StumblerService, an Intent service, which by definition runs on its own thread.
+ * Registered as a receiver in the AndroidManifest.xml.
+ * Starts the StumblerService in passive listening mode.
+ *
+ * The received intent serves these purposes:
+ * 1) The host application enables (or disables) the StumblerService.
+ *    Enabling requires passing in the upload API key. Both the enabled state, and the API key are stored in prefs.
+ *
+ * 2) If previously enabled by (1), notify the service to start (such as with a BOOT Intent).
+ *    The StumblerService is where the enabled state is checked, and if not enabled, the
+ *    service stops immediately.
+ *
+ * 3) Upload notification: onReceive intents are used to tell the StumblerService to check for upload.
+ *    In the Fennec host app use, startup and pause are used as indicators to the StumblerService that now
+ *    is a good time to try upload, as it is likely that the network is in use.
+ */
+public class PassiveServiceReceiver extends BroadcastReceiver {
+    static final String LOG_TAG = AppGlobals.LOG_PREFIX + PassiveServiceReceiver.class.getSimpleName();
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (intent == null) {
+            return;
+        }
+
+        final String action = intent.getAction();
+        final boolean isIntentFromHostApp = (action != null) && action.contains(".STUMBLER_PREF");
+        if (!isIntentFromHostApp) {
+            Log.d(LOG_TAG, "Stumbler: received intent external to host app");
+            Intent startServiceIntent = new Intent(context, StumblerService.class);
+            startServiceIntent.putExtra(StumblerService.ACTION_NOT_FROM_HOST_APP, true);
+            context.startService(startServiceIntent);
+            return;
+        }
+
+        if (intent.hasExtra("is_debug")) {
+            AppGlobals.isDebug = intent.getBooleanExtra("is_debug", false);
+        }
+        StumblerService.sFirefoxStumblingEnabled.set(intent.getBooleanExtra("enabled", false));
+
+        if (!StumblerService.sFirefoxStumblingEnabled.get()) {
+            // This calls the service's onDestroy(), and the service's onHandleIntent(...) is not called
+            context.stopService(new Intent(context, StumblerService.class));
+            return;
+        }
+
+        Log.d(LOG_TAG, "Stumbler: Sending passive start message | isDebug:" + AppGlobals.isDebug);
+
+
+        final Intent startServiceIntent = new Intent(context, StumblerService.class);
+        startServiceIntent.putExtra(StumblerService.ACTION_START_PASSIVE, true);
+        final String mozApiKey = intent.getStringExtra("moz_mozilla_api_key");
+        startServiceIntent.putExtra(StumblerService.ACTION_EXTRA_MOZ_API_KEY, mozApiKey);
+        final String userAgent = intent.getStringExtra("user_agent");
+        startServiceIntent.putExtra(StumblerService.ACTION_EXTRA_USER_AGENT, userAgent);
+        context.startService(startServiceIntent);
+    }
+}
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java
@@ -0,0 +1,199 @@
+/* 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.mozstumbler.service.stumblerthread;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.location.Location;
+import android.net.wifi.ScanResult;
+import android.support.v4.content.LocalBroadcastManager;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageContract;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageManager;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.StumblerBundle;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellInfo;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScanner;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.GPSScanner;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.WifiScanner;
+
+public final class Reporter extends BroadcastReceiver {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + Reporter.class.getSimpleName();
+    public static final String ACTION_FLUSH_TO_BUNDLE = AppGlobals.ACTION_NAMESPACE + ".FLUSH";
+    private boolean mIsStarted;
+
+    /* The maximum number of Wi-Fi access points in a single observation. */
+    private static final int MAX_WIFIS_PER_LOCATION = 200;
+
+    /* The maximum number of cells in a single observation */
+    private static final int MAX_CELLS_PER_LOCATION  = 50;
+
+    private Context mContext;
+    private int mPhoneType;
+
+    private StumblerBundle mBundle;
+
+    Reporter() {}
+
+    private void resetData() {
+        mBundle = null;
+    }
+
+    public void flush() {
+        reportCollectedLocation();
+    }
+
+    void startup(Context context) {
+        if (mIsStarted) {
+            return;
+        }
+
+        mContext = context.getApplicationContext();
+        TelephonyManager tm = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
+        mPhoneType = tm.getPhoneType();
+
+        mIsStarted = true;
+
+        resetData();
+        IntentFilter intentFilter = new IntentFilter();
+        intentFilter.addAction(WifiScanner.ACTION_WIFIS_SCANNED);
+        intentFilter.addAction(CellScanner.ACTION_CELLS_SCANNED);
+        intentFilter.addAction(GPSScanner.ACTION_GPS_UPDATED);
+        intentFilter.addAction(ACTION_FLUSH_TO_BUNDLE);
+        LocalBroadcastManager.getInstance(mContext).registerReceiver(this,
+                intentFilter);
+    }
+
+    void shutdown() {
+        if (mContext == null) {
+            return;
+        }
+
+        mIsStarted = false;
+
+        Log.d(LOG_TAG, "shutdown");
+        flush();
+        LocalBroadcastManager.getInstance(mContext).unregisterReceiver(this);
+    }
+
+    private void receivedWifiMessage(Intent intent) {
+        List<ScanResult> results = intent.getParcelableArrayListExtra(WifiScanner.ACTION_WIFIS_SCANNED_ARG_RESULTS);
+        putWifiResults(results);
+    }
+
+    private void receivedCellMessage(Intent intent) {
+        List<CellInfo> results = intent.getParcelableArrayListExtra(CellScanner.ACTION_CELLS_SCANNED_ARG_CELLS);
+        putCellResults(results);
+    }
+
+    private void receivedGpsMessage(Intent intent) {
+        String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
+        if (subject.equals(GPSScanner.SUBJECT_NEW_LOCATION)) {
+            reportCollectedLocation();
+            Location newPosition = intent.getParcelableExtra(GPSScanner.NEW_LOCATION_ARG_LOCATION);
+            mBundle = (newPosition != null) ? new StumblerBundle(newPosition, mPhoneType) : mBundle;
+        }
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String action = intent.getAction();
+
+        if (action.equals(ACTION_FLUSH_TO_BUNDLE)) {
+            flush();
+            return;
+        } else if (action.equals(WifiScanner.ACTION_WIFIS_SCANNED)) {
+            receivedWifiMessage(intent);
+        } else if (action.equals(CellScanner.ACTION_CELLS_SCANNED)) {
+            receivedCellMessage(intent);
+        } else if (action.equals(GPSScanner.ACTION_GPS_UPDATED)) {
+            // Calls reportCollectedLocation, this is the ideal case
+            receivedGpsMessage(intent);
+        }
+
+        if (mBundle != null &&
+            (mBundle.getWifiData().size() > MAX_WIFIS_PER_LOCATION ||
+             mBundle.getCellData().size() > MAX_CELLS_PER_LOCATION)) {
+            // no gps for a while, have too much data, just bundle it
+            reportCollectedLocation();
+        }
+    }
+
+    private void putWifiResults(List<ScanResult> results) {
+        if (mBundle == null) {
+            return;
+        }
+
+        Map<String, ScanResult> currentWifiData = mBundle.getWifiData();
+        for (ScanResult result : results) {
+            String key = result.BSSID;
+            if (!currentWifiData.containsKey(key)) {
+                currentWifiData.put(key, result);
+            }
+        }
+    }
+
+    private void putCellResults(List<CellInfo> cells) {
+        if (mBundle == null) {
+            return;
+        }
+
+        Map<String, CellInfo> currentCellData = mBundle.getCellData();
+        for (CellInfo result : cells) {
+            String key = result.getCellIdentity();
+            if (!currentCellData.containsKey(key)) {
+                currentCellData.put(key, result);
+            }
+        }
+    }
+
+    private void reportCollectedLocation() {
+        if (mBundle == null) {
+            return;
+        }
+
+        storeBundleAsJSON(mBundle);
+
+        mBundle.wasSent();
+    }
+
+    private void storeBundleAsJSON(StumblerBundle bundle) {
+        JSONObject mlsObj;
+        int wifiCount = 0;
+        int cellCount = 0;
+        try {
+            mlsObj = bundle.toMLSJSON();
+            wifiCount = mlsObj.getInt(DataStorageContract.ReportsColumns.WIFI_COUNT);
+            cellCount = mlsObj.getInt(DataStorageContract.ReportsColumns.CELL_COUNT);
+
+        } catch (JSONException e) {
+            Log.w(LOG_TAG, "Failed to convert bundle to JSON: " + e);
+            return;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "Received bundle: " + mlsObj.toString());
+        }
+
+        AppGlobals.guiLogInfo(mlsObj.toString());
+
+        try {
+            DataStorageManager.getInstance().insert(mlsObj.toString(), wifiCount, cellCount);
+        } catch (IOException e) {
+            Log.w(LOG_TAG, e.toString());
+        }
+    }
+}
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java
@@ -0,0 +1,247 @@
+/* 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.mozstumbler.service.stumblerthread;
+
+import android.content.Intent;
+import android.location.Location;
+import android.os.AsyncTask;
+import android.util.Log;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.Prefs;
+import org.mozilla.mozstumbler.service.stumblerthread.blocklist.WifiBlockListInterface;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageManager;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.ScanManager;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScanner;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScannerNoWCDMA;
+import org.mozilla.mozstumbler.service.uploadthread.UploadAlarmReceiver;
+import org.mozilla.mozstumbler.service.utils.NetworkUtils;
+import org.mozilla.mozstumbler.service.utils.PersistentIntentService;
+
+// In stand-alone service mode (a.k.a passive scanning mode), this is created from PassiveServiceReceiver (by calling startService).
+// The StumblerService is a sticky unbound service in this usage.
+//
+public class StumblerService extends PersistentIntentService
+        implements DataStorageManager.StorageIsEmptyTracker {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + StumblerService.class.getSimpleName();
+    public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE;
+    public static final String ACTION_START_PASSIVE = ACTION_BASE + ".START_PASSIVE";
+    public static final String ACTION_EXTRA_MOZ_API_KEY = ACTION_BASE + ".MOZKEY";
+    public static final String ACTION_EXTRA_USER_AGENT = ACTION_BASE + ".USER_AGENT";
+    public static final String ACTION_NOT_FROM_HOST_APP = ACTION_BASE + ".NOT_FROM_HOST";
+    public static final AtomicBoolean sFirefoxStumblingEnabled = new AtomicBoolean();
+    protected final ScanManager mScanManager = new ScanManager();
+    protected final Reporter mReporter = new Reporter();
+
+    // This is a delay before the single-shot upload is attempted. The number is arbitrary
+    // and used to avoid startup tasks bunching up.
+    private static final int DELAY_IN_SEC_BEFORE_STARTING_UPLOAD_IN_PASSIVE_MODE = 2;
+
+    // This is the frequency of the repeating upload alarm in active scanning mode.
+    private static final int FREQUENCY_IN_SEC_OF_UPLOAD_IN_ACTIVE_MODE = 5 * 60;
+
+    // Used to guard against attempting to upload too frequently in passive mode.
+    private static final long PASSIVE_UPLOAD_FREQ_GUARD_MSEC = 5 * 60 * 1000;
+
+    public StumblerService() {
+        this("StumblerService");
+    }
+
+    public StumblerService(String name) {
+        super(name);
+    }
+
+    public boolean isScanning() {
+        return mScanManager.isScanning();
+    }
+
+    public void startScanning() {
+        mScanManager.startScanning(this);
+    }
+
+    // This is optional, not used in Fennec, and is for clients to specify a (potentially long) list
+    // of blocklisted SSIDs/BSSIDs
+    public void setWifiBlockList(WifiBlockListInterface list) {
+        mScanManager.setWifiBlockList(list);
+    }
+
+    public Prefs getPrefs() {
+        return Prefs.getInstance();
+    }
+
+    public void checkPrefs() {
+        mScanManager.checkPrefs();
+    }
+
+    public int getLocationCount() {
+        return mScanManager.getLocationCount();
+    }
+
+    public double getLatitude() {
+        return mScanManager.getLatitude();
+    }
+
+    public double getLongitude() {
+        return mScanManager.getLongitude();
+    }
+
+    public Location getLocation() {
+        return mScanManager.getLocation();
+    }
+
+    public int getWifiStatus() {
+        return mScanManager.getWifiStatus();
+    }
+
+    public int getAPCount() {
+        return mScanManager.getAPCount();
+    }
+
+    public int getVisibleAPCount() {
+        return mScanManager.getVisibleAPCount();
+    }
+
+    public int getCellInfoCount() {
+        return mScanManager.getCellInfoCount();
+    }
+
+    public int getCurrentCellInfoCount() {
+        return mScanManager.getCurrentCellInfoCount();
+    }
+
+    public boolean isGeofenced () {
+        return mScanManager.isGeofenced();
+    }
+
+    // Previously this was done in onCreate(). Moved out of that so that in the passive standalone service
+    // use (i.e. Fennec), init() can be called from this class's dedicated thread.
+    // Safe to call more than once, ensure added code complies with that intent.
+    protected void init() {
+        Prefs.createGlobalInstance(this);
+        NetworkUtils.createGlobalInstance(this);
+        DataStorageManager.createGlobalInstance(this, this);
+
+        if (!CellScanner.isCellScannerImplSet()) {
+            CellScanner.setCellScannerImpl(new CellScannerNoWCDMA(this));
+        }
+
+        mReporter.startup(this);
+    }
+
+    // Called from the main thread.
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        setIntentRedelivery(true);
+    }
+
+    // Called from the main thread
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+
+        if (!mScanManager.isScanning()) {
+            return;
+        }
+
+        // Used to move these disk I/O ops off the calling thread. The current operations here are synchronized,
+        // however instead of creating another thread (if onDestroy grew to have concurrency complications)
+        // we could be messaging the stumbler thread to perform a shutdown function.
+        new AsyncTask<Void, Void, Void>() {
+            @Override
+            protected Void doInBackground(Void... params) {
+                if (AppGlobals.isDebug) {
+                    Log.d(LOG_TAG, "onDestroy");
+                }
+
+                if (!sFirefoxStumblingEnabled.get()) {
+                    Prefs.getInstance().setFirefoxScanEnabled(false);
+                }
+
+                if (DataStorageManager.getInstance() != null) {
+                    try {
+                        DataStorageManager.getInstance().saveCurrentReportsToDisk();
+                    } catch (IOException ex) {
+                        AppGlobals.guiLogInfo(ex.toString());
+                        Log.e(LOG_TAG, "Exception in onDestroy saving reports" + ex.toString());
+                    }
+                }
+                return null;
+            }
+        }.execute();
+
+        mReporter.shutdown();
+        mScanManager.stopScanning();
+    }
+
+    // This is the entry point for the stumbler thread.
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        // Do init() in all cases, there is no cost, whereas it is easy to add code that depends on this.
+        init();
+
+        // Post-init(), set the mode to passive.
+        mScanManager.setPassiveMode(true);
+
+        if (intent == null) {
+            return;
+        }
+
+        final boolean isScanEnabledInPrefs = Prefs.getInstance().getFirefoxScanEnabled();
+
+        if (!isScanEnabledInPrefs && intent.getBooleanExtra(ACTION_NOT_FROM_HOST_APP, false)) {
+            stopSelf();
+            return;
+        }
+
+        if (!DataStorageManager.getInstance().isDirEmpty()) {
+            // non-empty on startup, schedule an upload
+            // This is the only upload trigger in Firefox mode
+            // Firefox triggers this ~4 seconds after startup (after Gecko is loaded), add a small delay to avoid
+            // clustering with other operations that are triggered at this time.
+            final long lastAttemptedTime = Prefs.getInstance().getLastAttemptedUploadTime();
+            final long timeNow = System.currentTimeMillis();
+
+            if (timeNow - lastAttemptedTime < PASSIVE_UPLOAD_FREQ_GUARD_MSEC) {
+                // TODO Consider telemetry to track this.
+                if (AppGlobals.isDebug) {
+                    Log.d(LOG_TAG, "Upload attempt too frequent.");
+                }
+            } else {
+                Prefs.getInstance().setLastAttemptedUploadTime(timeNow);
+                UploadAlarmReceiver.scheduleAlarm(this, DELAY_IN_SEC_BEFORE_STARTING_UPLOAD_IN_PASSIVE_MODE, false /* no repeat*/);
+            }
+        }
+
+        if (!isScanEnabledInPrefs) {
+            Prefs.getInstance().setFirefoxScanEnabled(true);
+        }
+
+        String apiKey = intent.getStringExtra(ACTION_EXTRA_MOZ_API_KEY);
+        if (apiKey != null && !apiKey.equals(Prefs.getInstance().getMozApiKey())) {
+            Prefs.getInstance().setMozApiKey(apiKey);
+        }
+
+        String userAgent = intent.getStringExtra(ACTION_EXTRA_USER_AGENT);
+        if (userAgent != null && !userAgent.equals(Prefs.getInstance().getUserAgent())) {
+            Prefs.getInstance().setUserAgent(userAgent);
+        }
+
+        if (!mScanManager.isScanning()) {
+            startScanning();
+        }
+    }
+
+    // Note that in passive mode, having data isn't an upload trigger, it is triggered by the start intent
+    public void notifyStorageStateEmpty(boolean isEmpty) {
+        if (isEmpty) {
+            UploadAlarmReceiver.cancelAlarm(this, !mScanManager.isPassiveMode());
+        } else if (!mScanManager.isPassiveMode()) {
+            UploadAlarmReceiver.scheduleAlarm(this, FREQUENCY_IN_SEC_OF_UPLOAD_IN_ACTIVE_MODE, true /* repeating */);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java
@@ -0,0 +1,65 @@
+/* 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.mozstumbler.service.stumblerthread.blocklist;
+
+import android.net.wifi.ScanResult;
+import android.util.Log;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+public final class BSSIDBlockList {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + BSSIDBlockList.class.getSimpleName();
+    private static final String NULL_BSSID = "000000000000";
+    private static final String WILDCARD_BSSID = "ffffffffffff";
+    private static final Pattern BSSID_PATTERN = Pattern.compile("([0-9a-f]{12})");
+    private static String[] sOuiList = new String[]{};
+
+    private BSSIDBlockList() {
+    }
+
+    public static void setFilterList(String[] list) {
+        sOuiList = list;
+    }
+
+    public static boolean contains(ScanResult scanResult) {
+        String BSSID = scanResult.BSSID;
+        if (BSSID == null || NULL_BSSID.equals(BSSID) || WILDCARD_BSSID.equals(BSSID)) {
+            return true; // blocked!
+        }
+
+        if (!isCanonicalBSSID(BSSID)) {
+            Log.w(LOG_TAG, "", new IllegalArgumentException("Unexpected BSSID format: " + BSSID));
+            return true; // blocked!
+        }
+
+        for (String oui : sOuiList) {
+            if (BSSID.startsWith(oui)) {
+                return true; // blocked!
+            }
+        }
+
+        return false; // OK
+    }
+
+    public static String canonicalizeBSSID(String BSSID) {
+        if (BSSID == null) {
+            return "";
+        }
+
+        if (isCanonicalBSSID(BSSID)) {
+            return BSSID;
+        }
+
+        // Some devices may return BSSIDs with ':', '-' or '.' delimiters.
+        BSSID = BSSID.toLowerCase(Locale.US).replaceAll("[\\-\\.:]", "");
+
+        return isCanonicalBSSID(BSSID) ? BSSID : "";
+    }
+
+    private static boolean isCanonicalBSSID(String BSSID) {
+        return BSSID_PATTERN.matcher(BSSID).matches();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/SSIDBlockList.java
@@ -0,0 +1,41 @@
+/* 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.mozstumbler.service.stumblerthread.blocklist;
+
+import android.net.wifi.ScanResult;
+
+public final class SSIDBlockList {
+    private static String[] sPrefixList = new String[]{};
+    private static String[] sSuffixList = new String[]{"_nomap"};
+
+    private SSIDBlockList() {
+    }
+
+    public static void setFilterLists(String[] prefix, String[] suffix) {
+        sPrefixList = prefix;
+        sSuffixList = suffix;
+    }
+
+    public static boolean contains(ScanResult scanResult) {
+        String SSID = scanResult.SSID;
+        if (SSID == null) {
+            return true; // no SSID?
+        }
+
+        for (String prefix : sPrefixList) {
+            if (SSID.startsWith(prefix)) {
+                return true; // blocked!
+            }
+        }
+
+        for (String suffix : sSuffixList) {
+            if (SSID.endsWith(suffix)) {
+                return true; // blocked!
+            }
+        }
+
+        return false; // OK
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/WifiBlockListInterface.java
@@ -0,0 +1,11 @@
+/* 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.mozstumbler.service.stumblerthread.blocklist;
+
+public interface WifiBlockListInterface {
+    String[] getSsidPrefixList();
+    String[] getSsidSuffixList();
+    String[] getBssidOuiList();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java
@@ -0,0 +1,34 @@
+/* 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.mozstumbler.service.stumblerthread.datahandling;
+
+public final class DataStorageContract {
+
+    public static class ReportsColumns {
+        public static final String LAT = "lat";
+        public static final String LON = "lon";
+        public static final String TIME = "timestamp";
+        public static final String ACCURACY = "accuracy";
+        public static final String ALTITUDE = "altitude";
+        public static final String RADIO = "radio";
+        public static final String CELL = "cell";
+        public static final String WIFI = "wifi";
+        public static final String CELL_COUNT = "cell_count";
+        public static final String WIFI_COUNT = "wifi_count";
+    }
+
+    public static class Stats {
+        public static final String KEY_VERSION = "version_code";
+        public static final int VERSION_CODE = 1;
+        public static final String KEY_BYTES_SENT = "bytes_sent";
+        public static final String KEY_LAST_UPLOAD_TIME = "last_upload_time";
+        public static final String KEY_OBSERVATIONS_SENT = "observations_sent";
+        public static final String KEY_WIFIS_SENT = "wifis_sent";
+        public static final String KEY_CELLS_SENT = "cells_sent";
+    }
+
+    private DataStorageContract() {
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java
@@ -0,0 +1,527 @@
+/* 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.mozstumbler.service.stumblerthread.datahandling;
+
+import android.content.Context;
+import android.util.Log;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.utils.Zipper;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.Properties;
+import java.util.Timer;
+import java.util.TimerTask;
+
+/* Stores reports in memory (mCurrentReports) until MAX_REPORTS_IN_MEMORY,
+ * then writes them to disk as a .gz file. The name of the file has
+ * the time written, the # of reports, and the # of cells and wifis.
+ *
+ * Each .gz file is typically 1-5KB. File name example: reports-t1406863343313-r4-w25-c7.gz
+ *
+ * The sync stats are written as a key-value pair file (not zipped).
+ *
+ * The tricky bit is the mCurrentReportsSendBuffer. When the uploader code begins accessing the
+ * report batches, mCurrentReports gets pushed to mCurrentReportsSendBuffer.
+ * The mCurrentReports is then cleared, and can continue receiving new reports.
+ * From the uploader perspective, mCurrentReportsSendBuffer looks and acts exactly like a batch file on disk.
+ *
+ * If the network is reasonably active, and reporting is slow enough, there is no disk I/O, it all happens
+ * in-memory.
+ *
+ * Also of note: the in-memory buffers (both mCurrentReports and mCurrentReportsSendBuffer) are saved
+ * when the service is destroyed.
+ */
+public class DataStorageManager {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + DataStorageManager.class.getSimpleName();
+
+    // The max number of reports stored in the mCurrentReports. Each report is a GPS location plus wifi and cell scan.
+    // After this size is reached, data is persisted to disk, mCurrentReports is cleared.
+    private static final int MAX_REPORTS_IN_MEMORY = 50;
+
+    // Used to cap the amount of data stored. When this limit is hit, no more data is saved to disk
+    // until the data is uploaded, or and data exceeds DEFAULT_MAX_WEEKS_DATA_ON_DISK.
+    private static final long DEFAULT_MAX_BYTES_STORED_ON_DISK = 1024 * 250; // 250 KiB max by default
+
+    // Used as a safeguard to ensure stumbling data is not persisted. The intended use case of the stumbler lib is not
+    // for long-term storage, and so if ANY data on disk is this old, ALL data is wiped as a privacy mechanism.
+    private static final int DEFAULT_MAX_WEEKS_DATA_ON_DISK = 2;
+
+    // Set to the default value specified above.
+    private final long mMaxBytesDiskStorage;
+
+    // Set to the default value specified above.
+    private final int mMaxWeeksStored;
+
+    private final ReportBatchBuilder mCurrentReports = new ReportBatchBuilder();
+    private final File mReportsDir;
+    private final File mStatsFile;
+    private final StorageIsEmptyTracker mTracker;
+
+    private static DataStorageManager sInstance;
+
+    private ReportBatch mCurrentReportsSendBuffer;
+    private ReportBatchIterator mReportBatchIterator;
+    private ReportFileList mFileList;
+    private Timer mFlushMemoryBuffersToDiskTimer;
+
+    static final String SEP_REPORT_COUNT = "-r";
+    static final String SEP_WIFI_COUNT = "-w";
+    static final String SEP_CELL_COUNT = "-c";
+    static final String SEP_TIME_MS = "-t";
+    static final String FILENAME_PREFIX = "reports";
+    static final String MEMORY_BUFFER_NAME = "in memory send buffer";
+
+    public static class QueuedCounts {
+        public final int mReportCount;
+        public final int mWifiCount;
+        public final int mCellCount;
+        public final long mBytes;
+
+        QueuedCounts(int reportCount, int wifiCount, int cellCount, long bytes) {
+            this.mReportCount = reportCount;
+            this.mWifiCount = wifiCount;
+            this.mCellCount = cellCount;
+            this.mBytes = bytes;
+        }
+    }
+
+    /* Some data is calculated on-demand, don't abuse this function */
+    public QueuedCounts getQueuedCounts() {
+        int reportCount = mFileList.mReportCount + mCurrentReports.reports.size();
+        int wifiCount = mFileList.mWifiCount + mCurrentReports.wifiCount;
+        int cellCount = mFileList.mCellCount + mCurrentReports.cellCount;
+        long bytes = 0;
+
+        if (mCurrentReports.reports.size() > 0) {
+            try {
+                bytes = Zipper.zipData(finalizeReports(mCurrentReports.reports).getBytes()).length;
+            } catch (IOException ex) {
+                Log.e(LOG_TAG, "Zip error in getQueuedCounts()", ex);
+            }
+
+            if (mFileList.mReportCount > 0) {
+                bytes += mFileList.mFilesOnDiskBytes;
+            }
+        }
+
+        if (mCurrentReportsSendBuffer != null) {
+            reportCount += mCurrentReportsSendBuffer.reportCount;
+            wifiCount += mCurrentReportsSendBuffer.wifiCount;
+            cellCount += mCurrentReportsSendBuffer.cellCount;
+            bytes += mCurrentReportsSendBuffer.data.length;
+        }
+        return new QueuedCounts(reportCount, wifiCount, cellCount, bytes);
+    }
+
+    private static class ReportFileList {
+        File[] mFiles;
+        int mReportCount;
+        int mWifiCount;
+        int mCellCount;
+        long mFilesOnDiskBytes;
+
+        public ReportFileList() {}
+        public ReportFileList(ReportFileList other) {
+            if (other == null) {
+                return;
+            }
+
+            if (other.mFiles != null) {
+                mFiles = other.mFiles.clone();
+            }
+
+            mReportCount = other.mReportCount;
+            mWifiCount = other.mWifiCount;
+            mCellCount = other.mCellCount;
+            mFilesOnDiskBytes = other.mFilesOnDiskBytes;
+        }
+
+        void update(File directory) {
+            mFiles = directory.listFiles();
+            if (mFiles == null) {
+                return;
+            }
+
+            if (AppGlobals.isDebug) {
+                for (File f : mFiles) {
+                    Log.d("StumblerFiles", f.getName());
+                }
+            }
+
+            mFilesOnDiskBytes = mReportCount = mWifiCount = mCellCount = 0;
+            for (File f : mFiles) {
+                mReportCount += (int) getLongFromFilename(f.getName(), SEP_REPORT_COUNT);
+                mWifiCount += (int) getLongFromFilename(f.getName(), SEP_WIFI_COUNT);
+                mCellCount += (int) getLongFromFilename(f.getName(), SEP_CELL_COUNT);
+                mFilesOnDiskBytes += f.length();
+            }
+        }
+    }
+
+    public static class ReportBatch {
+        public final String filename;
+        public final byte[] data;
+        public final int reportCount;
+        public final int wifiCount;
+        public final int cellCount;
+
+        public ReportBatch(String filename, byte[] data, int reportCount, int wifiCount, int cellCount) {
+            this.filename = filename;
+            this.data = data;
+            this.reportCount = reportCount;
+            this.wifiCount = wifiCount;
+            this.cellCount = cellCount;
+        }
+    }
+
+    private static class ReportBatchBuilder {
+        public final ArrayList<String> reports = new ArrayList<String>();
+        public int wifiCount;
+        public int cellCount;
+    }
+
+    private static class ReportBatchIterator {
+        public ReportBatchIterator(ReportFileList list) {
+            fileList = new ReportFileList(list);
+        }
+
+        static final int BATCH_INDEX_FOR_MEM_BUFFER = -1;
+        public int currentIndex = BATCH_INDEX_FOR_MEM_BUFFER;
+        public final ReportFileList fileList;
+    }
+
+    public interface StorageIsEmptyTracker {
+        public void notifyStorageStateEmpty(boolean isEmpty);
+    }
+
+    private String getStorageDir(Context c) {
+        File dir = null;
+        if (AppGlobals.isDebug) {
+            // in debug, put files in public location
+            dir = c.getExternalFilesDir(null);
+            if (dir != null) {
+                dir = new File(dir.getAbsolutePath() + "/mozstumbler");
+            }
+        }
+
+        if (dir == null) {
+            dir = c.getFilesDir();
+        }
+
+        if (!dir.exists()) {
+            boolean ok = dir.mkdirs();
+            if (!ok) {
+                Log.d(LOG_TAG, "getStorageDir: error in mkdirs()");
+            }
+        }
+
+        return dir.getPath();
+    }
+
+    public static synchronized void createGlobalInstance(Context context, StorageIsEmptyTracker tracker) {
+        DataStorageManager.createGlobalInstance(context, tracker,
+                DEFAULT_MAX_BYTES_STORED_ON_DISK, DEFAULT_MAX_WEEKS_DATA_ON_DISK);
+    }
+
+    public static synchronized void createGlobalInstance(Context context, StorageIsEmptyTracker tracker,
+                                                         long maxBytesStoredOnDisk, int maxWeeksDataStored) {
+        if (sInstance != null) {
+            return;
+        }
+        sInstance = new DataStorageManager(context, tracker, maxBytesStoredOnDisk, maxWeeksDataStored);
+    }
+
+    public static synchronized DataStorageManager getInstance() {
+        return sInstance;
+    }
+
+    private DataStorageManager(Context c, StorageIsEmptyTracker tracker,
+                               long maxBytesStoredOnDisk, int maxWeeksDataStored) {
+        mMaxBytesDiskStorage = maxBytesStoredOnDisk;
+        mMaxWeeksStored = maxWeeksDataStored;
+        mTracker = tracker;
+        final String baseDir = getStorageDir(c);
+        mStatsFile = new File(baseDir, "upload_stats.ini");
+        mReportsDir = new File(baseDir + "/reports");
+        if (!mReportsDir.exists()) {
+            mReportsDir.mkdirs();
+        }
+        mFileList = new ReportFileList();
+        mFileList.update(mReportsDir);
+    }
+
+    public synchronized int getMaxWeeksStored() {
+        return mMaxWeeksStored;
+    }
+
+    private static byte[] readFile(File file) throws IOException {
+        final RandomAccessFile f = new RandomAccessFile(file, "r");
+        try {
+            final byte[] data = new byte[(int) f.length()];
+            f.readFully(data);
+            return data;
+        } finally {
+            f.close();
+        }
+    }
+
+    public synchronized boolean isDirEmpty() {
+        return (mFileList.mFiles == null || mFileList.mFiles.length < 1);
+    }
+
+    /* Pass filename returned from dataToSend() */
+    public synchronized boolean delete(String filename) {
+        if (filename == MEMORY_BUFFER_NAME) {
+            mCurrentReportsSendBuffer = null;
+            return true;
+        }
+
+        final File file = new File(mReportsDir, filename);
+        final boolean ok = file.delete();
+        mFileList.update(mReportsDir);
+        return ok;
+    }
+
+    private static long getLongFromFilename(String name, String separator) {
+        final int s = name.indexOf(separator) + separator.length();
+        int e = name.indexOf('-', s);
+        if (e < 0) {
+            e = name.indexOf('.', s);
+        }
+        return Long.parseLong(name.substring(s, e));
+    }
+
+    /* return name of file used, or memory buffer sentinel value.
+     * The return value is used to delete the file/buffer later. */
+    public synchronized ReportBatch getFirstBatch() throws IOException {
+        final boolean dirEmpty = isDirEmpty();
+        final int currentReportsCount = mCurrentReports.reports.size();
+
+        if (dirEmpty && currentReportsCount < 1) {
+            return null;
+        }
+
+        mReportBatchIterator = new ReportBatchIterator(mFileList);
+
+        if (currentReportsCount > 0) {
+            final String filename = MEMORY_BUFFER_NAME;
+            final byte[] data = Zipper.zipData(finalizeReports(mCurrentReports.reports).getBytes());
+            final int wifiCount = mCurrentReports.wifiCount;
+            final int cellCount = mCurrentReports.cellCount;
+            clearCurrentReports();
+            final ReportBatch result = new ReportBatch(filename, data, currentReportsCount, wifiCount, cellCount);
+            mCurrentReportsSendBuffer = result;
+            return result;
+        } else {
+            return getNextBatch();
+        }
+    }
+
+    private void clearCurrentReports() {
+        mCurrentReports.reports.clear();
+        mCurrentReports.wifiCount = mCurrentReports.cellCount = 0;
+    }
+
+    public synchronized ReportBatch getNextBatch() throws IOException {
+        if (mReportBatchIterator == null) {
+            return null;
+        }
+
+        mReportBatchIterator.currentIndex++;
+        if (mReportBatchIterator.currentIndex < 0 ||
+            mReportBatchIterator.currentIndex > mReportBatchIterator.fileList.mFiles.length - 1) {
+            return null;
+        }
+
+        final File f = mReportBatchIterator.fileList.mFiles[mReportBatchIterator.currentIndex];
+        final String filename = f.getName();
+        final int reportCount = (int) getLongFromFilename(f.getName(), SEP_REPORT_COUNT);
+        final int wifiCount = (int) getLongFromFilename(f.getName(), SEP_WIFI_COUNT);
+        final int cellCount = (int) getLongFromFilename(f.getName(), SEP_CELL_COUNT);
+        final byte[] data = readFile(f);
+        return new ReportBatch(filename, data, reportCount, wifiCount, cellCount);
+    }
+
+    private File createFile(int reportCount, int wifiCount, int cellCount) {
+        final long time = System.currentTimeMillis();
+        final String name = FILENAME_PREFIX +
+                      SEP_TIME_MS + time +
+                      SEP_REPORT_COUNT + reportCount +
+                      SEP_WIFI_COUNT + wifiCount +
+                      SEP_CELL_COUNT + cellCount + ".gz";
+        return new File(mReportsDir, name);
+    }
+
+    public synchronized long getOldestBatchTimeMs() {
+        if (isDirEmpty()) {
+            return 0;
+        }
+
+        long oldest = Long.MAX_VALUE;
+        for (File f : mFileList.mFiles) {
+            final long t = getLongFromFilename(f.getName(), SEP_TIME_MS);
+            if (t < oldest) {
+                oldest = t;
+            }
+        }
+        return oldest;
+    }
+
+    public synchronized void saveCurrentReportsSendBufferToDisk() throws IOException {
+        if (mCurrentReportsSendBuffer == null || mCurrentReportsSendBuffer.reportCount < 1) {
+            return;
+        }
+
+        saveToDisk(mCurrentReportsSendBuffer.data,
+                   mCurrentReportsSendBuffer.reportCount,
+                   mCurrentReportsSendBuffer.wifiCount,
+                   mCurrentReportsSendBuffer.cellCount);
+        mCurrentReportsSendBuffer = null;
+    }
+
+    private void saveToDisk(byte[] bytes, int reportCount, int wifiCount, int cellCount)
+      throws IOException {
+        if (mFileList.mFilesOnDiskBytes > mMaxBytesDiskStorage) {
+            return;
+        }
+
+        final FileOutputStream fos = new FileOutputStream(createFile(reportCount, wifiCount, cellCount));
+        try {
+            fos.write(bytes);
+        } finally {
+            fos.close();
+        }
+        mFileList.update(mReportsDir);
+    }
+
+    private String finalizeReports(ArrayList<String> reports) {
+        final String kPrefix = "{\"items\":[";
+        final String kSuffix = "]}";
+        final StringBuilder sb = new StringBuilder(kPrefix);
+        String sep = "";
+        final String separator = ",";
+        if (reports != null) {
+            for(String s: reports) {
+                sb.append(sep).append(s);
+                sep = separator;
+            }
+        }
+
+        final String result = sb.append(kSuffix).toString();
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, result);
+        }
+        return result;
+    }
+
+    public synchronized void saveCurrentReportsToDisk() throws IOException {
+        saveCurrentReportsSendBufferToDisk();
+        if (mCurrentReports.reports.size() < 1) {
+            return;
+        }
+        final byte[] bytes = Zipper.zipData(finalizeReports(mCurrentReports.reports).getBytes());
+        saveToDisk(bytes, mCurrentReports.reports.size(), mCurrentReports.wifiCount, mCurrentReports.cellCount);
+        clearCurrentReports();
+    }
+
+    public synchronized void insert(String report, int wifiCount, int cellCount) throws IOException {
+        notifyStorageIsEmpty(false);
+
+        if (mFlushMemoryBuffersToDiskTimer != null) {
+            mFlushMemoryBuffersToDiskTimer.cancel();
+            mFlushMemoryBuffersToDiskTimer = null;
+        }
+
+        mCurrentReports.reports.add(report);
+        mCurrentReports.wifiCount = wifiCount;
+        mCurrentReports.cellCount = cellCount;
+
+        if (mCurrentReports.reports.size() >= MAX_REPORTS_IN_MEMORY) {
+            // save to disk
+            saveCurrentReportsToDisk();
+        } else {
+            // Schedule a timer to flush to disk after a few mins.
+            // If collection stops and wifi not available for uploading, the memory buffer is flushed to disk.
+            final int kMillis = 1000 * 60 * 3;
+            mFlushMemoryBuffersToDiskTimer = new Timer();
+            mFlushMemoryBuffersToDiskTimer.schedule(new TimerTask() {
+                @Override
+                public void run() {
+                    try {
+                        saveCurrentReportsToDisk();
+                    } catch (IOException ex) {
+                        Log.e(LOG_TAG, "mFlushMemoryBuffersToDiskTimer exception" + ex);
+                    }
+                }
+            }, kMillis);
+        }
+    }
+
+    public synchronized Properties readSyncStats() throws IOException {
+        if (!mStatsFile.exists()) {
+            return new Properties();
+        }
+
+        final FileInputStream input = new FileInputStream(mStatsFile);
+        try {
+            final Properties props = new Properties();
+            props.load(input);
+            return props;
+        } finally {
+            input.close();
+        }
+    }
+
+    public synchronized void incrementSyncStats(long bytesSent, long reports, long cells, long wifis) throws IOException {
+        if (reports + cells + wifis < 1) {
+            return;
+        }
+
+        final Properties properties = readSyncStats();
+        final long time = System.currentTimeMillis();
+        writeSyncStats(time,
+            Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_BYTES_SENT, "0")) + bytesSent,
+            Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_OBSERVATIONS_SENT, "0")) + reports,
+            Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_CELLS_SENT, "0")) + cells,
+            Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_WIFIS_SENT, "0")) + wifis);
+    }
+
+    public void writeSyncStats(long time, long bytesSent, long totalObs, long totalCells, long totalWifis) throws IOException {
+        final FileOutputStream out = new FileOutputStream(mStatsFile);
+        try {
+            final Properties props = new Properties();
+            props.setProperty(DataStorageContract.Stats.KEY_LAST_UPLOAD_TIME, String.valueOf(time));
+            props.setProperty(DataStorageContract.Stats.KEY_BYTES_SENT, String.valueOf(bytesSent));
+            props.setProperty(DataStorageContract.Stats.KEY_OBSERVATIONS_SENT, String.valueOf(totalObs));
+            props.setProperty(DataStorageContract.Stats.KEY_CELLS_SENT, String.valueOf(totalCells));
+            props.setProperty(DataStorageContract.Stats.KEY_WIFIS_SENT, String.valueOf(totalWifis));
+            props.setProperty(DataStorageContract.Stats.KEY_VERSION, String.valueOf(DataStorageContract.Stats.VERSION_CODE));
+            props.store(out, null);
+        } finally {
+            out.close();
+        }
+    }
+
+    public synchronized void deleteAll() {
+        if (mFileList.mFiles == null) {
+            return;
+        }
+
+        for (File f : mFileList.mFiles) {
+            f.delete();
+        }
+        mFileList.update(mReportsDir);
+    }
+
+    private void notifyStorageIsEmpty(boolean isEmpty) {
+        if (mTracker != null) {
+            mTracker.notifyStorageStateEmpty(isEmpty);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java
@@ -0,0 +1,157 @@
+/* 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.mozstumbler.service.stumblerthread.datahandling;
+
+import android.location.Location;
+import android.net.wifi.ScanResult;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.telephony.TelephonyManager;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellInfo;
+
+public final class StumblerBundle implements Parcelable {
+    private final int mPhoneType;
+    private final Location mGpsPosition;
+    private final Map<String, ScanResult> mWifiData;
+    private final Map<String, CellInfo> mCellData;
+
+    public void wasSent() {
+        mGpsPosition.setTime(System.currentTimeMillis());
+        mWifiData.clear();
+        mCellData.clear();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flags) {
+        Bundle wifiBundle = new Bundle(ScanResult.class.getClassLoader());
+        Collection<String> scans = mWifiData.keySet();
+        for (String s : scans) {
+            wifiBundle.putParcelable(s, mWifiData.get(s));
+        }
+
+        Bundle cellBundle = new Bundle(CellInfo.class.getClassLoader());
+        Collection<String> cells = mCellData.keySet();
+        for (String c : cells) {
+            cellBundle.putParcelable(c, mCellData.get(c));
+        }
+
+        out.writeBundle(wifiBundle);
+        out.writeBundle(cellBundle);
+        out.writeParcelable(mGpsPosition, 0);
+        out.writeInt(mPhoneType);
+    }
+
+    public static final Parcelable.Creator<StumblerBundle> CREATOR
+        = new Parcelable.Creator<StumblerBundle>() {
+        @Override
+        public StumblerBundle createFromParcel(Parcel in) {
+            return new StumblerBundle(in);
+        }
+
+        @Override
+        public StumblerBundle[] newArray(int size) {
+            return new StumblerBundle[size];
+        }
+    };
+
+    private StumblerBundle(Parcel in) {
+        mWifiData = new HashMap<String, ScanResult>();
+        mCellData = new HashMap<String, CellInfo>();
+
+        Bundle wifiBundle = in.readBundle(ScanResult.class.getClassLoader());
+        Bundle cellBundle = in.readBundle(CellInfo.class.getClassLoader());
+
+        Collection<String> scans = wifiBundle.keySet();
+        for (String s : scans) {
+            mWifiData.put(s, (ScanResult) wifiBundle.get(s));
+        }
+
+        Collection<String> cells = cellBundle.keySet();
+        for (String c : cells) {
+            mCellData.put(c, (CellInfo) cellBundle.get(c));
+        }
+
+        mGpsPosition = in.readParcelable(Location.class.getClassLoader());
+        mPhoneType = in.readInt();
+    }
+
+    public StumblerBundle(Location position, int phoneType) {
+        mGpsPosition = position;
+        mPhoneType = phoneType;
+        mWifiData = new HashMap<String, ScanResult>();
+        mCellData = new HashMap<String, CellInfo>();
+    }
+
+    public Location getGpsPosition() {
+        return mGpsPosition;
+    }
+
+    public Map<String, ScanResult> getWifiData() {
+        return mWifiData;
+    }
+
+    public Map<String, CellInfo> getCellData() {
+        return mCellData;
+    }
+
+    public JSONObject toMLSJSON() throws JSONException {
+        JSONObject item = new JSONObject();
+
+        item.put(DataStorageContract.ReportsColumns.TIME, mGpsPosition.getTime());
+        item.put(DataStorageContract.ReportsColumns.LAT, Math.floor(mGpsPosition.getLatitude() * 1.0E6) / 1.0E6);
+        item.put(DataStorageContract.ReportsColumns.LON, Math.floor(mGpsPosition.getLongitude() * 1.0E6) / 1.0E6);
+
+        if (mGpsPosition.hasAccuracy()) {
+            item.put(DataStorageContract.ReportsColumns.ACCURACY, (int) Math.ceil(mGpsPosition.getAccuracy()));
+        }
+
+        if (mGpsPosition.hasAltitude()) {
+            item.put(DataStorageContract.ReportsColumns.ALTITUDE, Math.round(mGpsPosition.getAltitude()));
+        }
+
+        if (mPhoneType == TelephonyManager.PHONE_TYPE_GSM) {
+            item.put(DataStorageContract.ReportsColumns.RADIO, "gsm");
+        } else if (mPhoneType == TelephonyManager.PHONE_TYPE_CDMA) {
+            item.put(DataStorageContract.ReportsColumns.RADIO, "cdma");
+        } else {
+            // issue #598. investigate this case further in future
+            item.put(DataStorageContract.ReportsColumns.RADIO, "");
+        }
+
+        JSONArray cellJSON = new JSONArray();
+        for (CellInfo c : mCellData.values()) {
+            JSONObject obj = c.toJSONObject();
+            cellJSON.put(obj);
+        }
+
+        item.put(DataStorageContract.ReportsColumns.CELL, cellJSON);
+        item.put(DataStorageContract.ReportsColumns.CELL_COUNT, cellJSON.length());
+
+        JSONArray wifis = new JSONArray();
+        for (ScanResult s : mWifiData.values()) {
+            JSONObject wifiEntry = new JSONObject();
+            wifiEntry.put("key", s.BSSID);
+            wifiEntry.put("frequency", s.frequency);
+            wifiEntry.put("signal", s.level);
+            wifis.put(wifiEntry);
+        }
+        item.put(DataStorageContract.ReportsColumns.WIFI, wifis);
+        item.put(DataStorageContract.ReportsColumns.WIFI_COUNT, wifis.length());
+
+        return item;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java
@@ -0,0 +1,260 @@
+/* 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.mozstumbler.service.stumblerthread.scanners;
+
+import android.content.Context;
+import android.content.Intent;
+import android.location.GpsSatellite;
+import android.location.GpsStatus;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.location.LocationProvider;
+import android.os.Bundle;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling;
+import org.mozilla.mozstumbler.service.Prefs;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public class GPSScanner implements LocationListener {
+    public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE + ".GPSScanner.";
+    public static final String ACTION_GPS_UPDATED = ACTION_BASE + "GPS_UPDATED";
+    public static final String ACTION_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
+    public static final String SUBJECT_NEW_STATUS = "new_status";
+    public static final String SUBJECT_LOCATION_LOST = "location_lost";
+    public static final String SUBJECT_NEW_LOCATION = "new_location";
+    public static final String NEW_STATUS_ARG_FIXES = "fixes";
+    public static final String NEW_STATUS_ARG_SATS = "sats";
+    public static final String NEW_LOCATION_ARG_LOCATION = "location";
+
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + GPSScanner.class.getSimpleName();
+    private static final int MIN_SAT_USED_IN_FIX = 3;
+    private static final long ACTIVE_MODE_GPS_MIN_UPDATE_TIME_MS = 1000;
+    private static final float ACTIVE_MODE_GPS_MIN_UPDATE_DISTANCE_M = 10;
+    private static final long PASSIVE_GPS_MIN_UPDATE_FREQ_MS = 3000;
+    private static final float PASSIVE_GPS_MOVEMENT_MIN_DELTA_M = 30;
+
+    private final LocationBlockList mBlockList = new LocationBlockList();
+    private final Context mContext;
+    private GpsStatus.Listener mGPSListener;
+    private int mLocationCount;
+    private Location mLocation = new Location("internal");
+    private boolean mAutoGeofencing;
+    private boolean mIsPassiveMode;
+
+    private final ScanManager mScanManager;
+
+    public GPSScanner(Context context, ScanManager scanManager) {
+        mContext = context;
+        mScanManager = scanManager;
+    }
+
+    public void start(final ActiveOrPassiveStumbling stumblingMode) {
+        mIsPassiveMode = (stumblingMode == ActiveOrPassiveStumbling.PASSIVE_STUMBLING);
+        if (mIsPassiveMode ) {
+            startPassiveMode();
+        } else {
+            startActiveMode();
+        }
+    }
+
+    private void startPassiveMode() {
+        LocationManager locationManager = (LocationManager) mContext.getSystemService(Context.LOCATION_SERVICE);
+        locationManager.requestLocationUpdates(LocationManager.PASSIVE_PROVIDER,
+                                               0,
+                                               0, this);
+    }
+
+    private void startActiveMode() {
+        LocationManager lm = getLocationManager();
+        lm.requestLocationUpdates(LocationManager.GPS_PROVIDER,
+                                  ACTIVE_MODE_GPS_MIN_UPDATE_TIME_MS,
+                                  ACTIVE_MODE_GPS_MIN_UPDATE_DISTANCE_M,
+                                  this);
+
+        reportLocationLost();
+        mGPSListener = new GpsStatus.Listener() {
+                public void onGpsStatusChanged(int event) {
+                if (event == GpsStatus.GPS_EVENT_SATELLITE_STATUS) {
+                    GpsStatus status = getLocationManager().getGpsStatus(null);
+                    Iterable<GpsSatellite> sats = status.getSatellites();
+
+                    int satellites = 0;
+                    int fixes = 0;
+
+                    for (GpsSatellite sat : sats) {
+                        satellites++;
+                        if (sat.usedInFix()) {
+                            fixes++;
+                        }
+                    }
+                    reportNewGpsStatus(fixes, satellites);
+                    if (fixes < MIN_SAT_USED_IN_FIX) {
+                        reportLocationLost();
+                    }
+
+                    if (AppGlobals.isDebug) {
+                        Log.v(LOG_TAG, "onGpsStatusChange - satellites: " + satellites + " fixes: " + fixes);
+                    }
+                } else if (event == GpsStatus.GPS_EVENT_STOPPED) {
+                    reportLocationLost();
+                }
+            }
+        };
+
+        lm.addGpsStatusListener(mGPSListener);
+    }
+
+    public void stop() {
+        LocationManager lm = getLocationManager();
+        lm.removeUpdates(this);
+        reportLocationLost();
+
+        if (mGPSListener != null) {
+          lm.removeGpsStatusListener(mGPSListener);
+          mGPSListener = null;
+        }
+    }
+
+    public int getLocationCount() {
+        return mLocationCount;
+    }
+
+    public double getLatitude() {
+        return mLocation.getLatitude();
+    }
+
+    public double getLongitude() {
+        return mLocation.getLongitude();
+    }
+
+    public Location getLocation() {
+        return mLocation;
+    }
+
+    public void checkPrefs() {
+        if (mBlockList != null) {
+            mBlockList.updateBlocks();
+        }
+
+        mAutoGeofencing = Prefs.getInstance().getGeofenceHere();
+    }
+
+    public boolean isGeofenced() {
+        return (mBlockList != null) && mBlockList.isGeofenced();
+    }
+
+    private void sendToLogActivity(String msg) {
+        AppGlobals.guiLogInfo(msg, "#33ccff", false);
+    }
+
+    @Override
+    public void onLocationChanged(Location location) {
+        if (location == null) { // TODO: is this even possible??
+            reportLocationLost();
+            return;
+        }
+
+        String logMsg = (mIsPassiveMode)? "[Passive] " : "[Active] ";
+
+        String provider = location.getProvider();
+        if (!provider.toLowerCase().contains("gps")) {
+            sendToLogActivity(logMsg + "Discard fused/network location.");
+            // only interested in GPS locations
+            return;
+        }
+
+        // Seem to get greater likelihood of non-fused location with higher update freq.
+        // Check dist and time threshold here, not set on the listener.
+        if (mIsPassiveMode) {
+            final long timeDelta = location.getTime() - mLocation.getTime();
+            final boolean hasMoved = location.distanceTo(mLocation) > PASSIVE_GPS_MOVEMENT_MIN_DELTA_M;
+
+            if (timeDelta < PASSIVE_GPS_MIN_UPDATE_FREQ_MS || !hasMoved) {
+                return;
+            }
+        }
+
+        Date date = new Date(location.getTime());
+        SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss");
+        String time = formatter.format(date);
+        logMsg += String.format("%s Coord: %.4f,%.4f, Acc: %.0f, Speed: %.0f, Alt: %.0f, Bearing: %.1f", time, location.getLatitude(),
+                location.getLongitude(), location.getAccuracy(), location.getSpeed(), location.getAltitude(), location.getBearing());
+        sendToLogActivity(logMsg);
+
+        if (mBlockList.contains(location)) {
+            Log.w(LOG_TAG, "Blocked location: " + location);
+            reportLocationLost();
+            return;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "New location: " + location);
+        }
+
+        mLocation = location;
+
+        if (!mAutoGeofencing) {
+            reportNewLocationReceived(location);
+        }
+        mLocationCount++;
+
+        if (mIsPassiveMode) {
+            mScanManager.newPassiveGpsLocation();
+        }
+    }
+
+    @Override
+    public void onProviderDisabled(String provider) {
+        if (LocationManager.GPS_PROVIDER.equals(provider)) {
+            reportLocationLost();
+        }
+    }
+
+    @Override
+    public void onProviderEnabled(String provider) {
+    }
+
+    @Override
+    public void onStatusChanged(String provider, int status, Bundle extras) {
+        if ((status != LocationProvider.AVAILABLE) &&
+            (LocationManager.GPS_PROVIDER.equals(provider))) {
+            reportLocationLost();
+        }
+    }
+
+    private LocationManager getLocationManager() {
+        return (LocationManager) mContext.getSystemService(Context.LOCATION_SERVICE);
+    }
+
+    private void reportNewLocationReceived(Location location) {
+        Intent i = new Intent(ACTION_GPS_UPDATED);
+        i.putExtra(Intent.EXTRA_SUBJECT, SUBJECT_NEW_LOCATION);
+        i.putExtra(NEW_LOCATION_ARG_LOCATION, location);
+        i.putExtra(ACTION_ARG_TIME, System.currentTimeMillis());
+        LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(i);
+    }
+
+    private void reportLocationLost() {
+        Intent i = new Intent(ACTION_GPS_UPDATED);
+        i.putExtra(Intent.EXTRA_SUBJECT, SUBJECT_LOCATION_LOST);
+        i.putExtra(ACTION_ARG_TIME, System.currentTimeMillis());
+        LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(i);
+    }
+
+    private void reportNewGpsStatus(int fixes, int sats) {
+        Intent i = new Intent(ACTION_GPS_UPDATED);
+        i.putExtra(Intent.EXTRA_SUBJECT, SUBJECT_NEW_STATUS);
+        i.putExtra(NEW_STATUS_ARG_FIXES, fixes);
+        i.putExtra(NEW_STATUS_ARG_SATS, sats);
+        i.putExtra(ACTION_ARG_TIME, System.currentTimeMillis());
+        LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(i);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java
@@ -0,0 +1,101 @@
+/* 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.mozstumbler.service.stumblerthread.scanners;
+
+import android.location.Location;
+import android.util.Log;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.Prefs;
+
+public final class LocationBlockList {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + LocationBlockList.class.getSimpleName();
+    private static final double MAX_ALTITUDE = 8848;      // Mount Everest's altitude in meters
+    private static final double MIN_ALTITUDE = -418;      // Dead Sea's altitude in meters
+    private static final float MAX_SPEED = 340.29f;   // Mach 1 in meters/second
+    private static final float MIN_ACCURACY = 500;       // meter radius
+    private static final long MIN_TIMESTAMP = 946684801; // 2000-01-01 00:00:01
+    private static final double GEOFENCE_RADIUS = 0.01;      // .01 degrees is approximately 1km
+    private static final long MILLISECONDS_PER_DAY = 86400000;
+
+    private Location mBlockedLocation;
+    private boolean mGeofencingEnabled;
+    private boolean mIsGeofenced = false;
+
+    public LocationBlockList() {
+        updateBlocks();
+    }
+
+    public void updateBlocks()    {
+        mBlockedLocation = Prefs.getInstance().getGeofenceLocation();
+        mGeofencingEnabled = Prefs.getInstance().getGeofenceEnabled();
+    }
+
+    public boolean contains(Location location) {
+        final float inaccuracy = location.getAccuracy();
+        final double altitude = location.getAltitude();
+        final float bearing = location.getBearing();
+        final double latitude = location.getLatitude();
+        final double longitude = location.getLongitude();
+        final float speed = location.getSpeed();
+        final long timestamp = location.getTime();
+        final long tomorrow = System.currentTimeMillis() + MILLISECONDS_PER_DAY;
+
+        boolean block = false;
+        mIsGeofenced = false;
+
+        if (latitude == 0 && longitude == 0) {
+            block = true;
+            Log.w(LOG_TAG, "Bogus latitude,longitude: 0,0");
+        } else {
+            if (latitude < -90 || latitude > 90) {
+                block = true;
+                Log.w(LOG_TAG, "Bogus latitude: " + latitude);
+            }
+
+            if (longitude < -180 || longitude > 180) {
+                block = true;
+                Log.w(LOG_TAG, "Bogus longitude: " + longitude);
+            }
+        }
+
+        if (location.hasAccuracy() && (inaccuracy < 0 || inaccuracy > MIN_ACCURACY)) {
+            block = true;
+            Log.w(LOG_TAG, "Insufficient accuracy: " + inaccuracy + " meters");
+        }
+
+        if (location.hasAltitude() && (altitude < MIN_ALTITUDE || altitude > MAX_ALTITUDE)) {
+            block = true;
+            Log.w(LOG_TAG, "Bogus altitude: " + altitude + " meters");
+        }
+
+        if (location.hasBearing() && (bearing < 0 || bearing > 360)) {
+            block = true;
+            Log.w(LOG_TAG, "Bogus bearing: " + bearing + " degrees");
+        }
+
+        if (location.hasSpeed() && (speed < 0 || speed > MAX_SPEED)) {
+            block = true;
+            Log.w(LOG_TAG, "Bogus speed: " + speed + " meters/second");
+        }
+
+        if (timestamp < MIN_TIMESTAMP || timestamp > tomorrow) {
+            block = true;
+            Log.w(LOG_TAG, "Bogus timestamp: " + timestamp);
+        }
+
+        if (mGeofencingEnabled &&
+            Math.abs(location.getLatitude() - mBlockedLocation.getLatitude()) < GEOFENCE_RADIUS &&
+            Math.abs(location.getLongitude() - mBlockedLocation.getLongitude()) < GEOFENCE_RADIUS) {
+            block = true;
+            mIsGeofenced = true;
+        }
+
+        return block;
+    }
+
+    public boolean isGeofenced() {
+        return mIsGeofenced;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java
@@ -0,0 +1,190 @@
+/* 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.mozstumbler.service.stumblerthread.scanners;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.location.Location;
+import android.os.BatteryManager;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.stumblerthread.Reporter;
+import org.mozilla.mozstumbler.service.stumblerthread.blocklist.WifiBlockListInterface;
+import org.mozilla.mozstumbler.service.stumblerthread.scanners.cellscanner.CellScanner;
+import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling;
+
+import java.util.Date;
+import java.util.Timer;
+import java.util.TimerTask;
+
+public class ScanManager {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + ScanManager.class.getSimpleName();
+    private Timer mPassiveModeFlushTimer;
+    private Context mContext;
+    private boolean mIsScanning;
+    private GPSScanner mGPSScanner;
+    private WifiScanner mWifiScanner;
+    private CellScanner mCellScanner;
+    private ActiveOrPassiveStumbling mStumblingMode = ActiveOrPassiveStumbling.ACTIVE_STUMBLING;
+
+    public ScanManager() {
+    }
+
+    private boolean isBatteryLow() {
+        Intent intent = mContext.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
+        if (intent == null) {
+            return false;
+        }
+
+        int rawLevel = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+        int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
+        int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
+        boolean isCharging = (status == BatteryManager.BATTERY_STATUS_CHARGING);
+        int level = Math.round(rawLevel * scale/100.0f);
+
+        final int kMinBatteryPct = 15;
+        return !isCharging && level < kMinBatteryPct;
+    }
+
+    public void newPassiveGpsLocation() {
+        if (isBatteryLow()) {
+            return;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "New passive location");
+        }
+
+        mWifiScanner.start(ActiveOrPassiveStumbling.PASSIVE_STUMBLING);
+        mCellScanner.start(ActiveOrPassiveStumbling.PASSIVE_STUMBLING);
+
+        // how often to flush a leftover bundle to the reports table
+        // If there is a bundle, and nothing happens for 10sec, then flush it
+        final int flushRate_ms = 10000;
+
+        if (mPassiveModeFlushTimer != null) {
+            mPassiveModeFlushTimer.cancel();
+        }
+
+        Date when = new Date();
+        when.setTime(when.getTime() + flushRate_ms);
+        mPassiveModeFlushTimer = new Timer();
+        mPassiveModeFlushTimer.schedule(new TimerTask() {
+          @Override
+          public void run() {
+              Intent flush = new Intent(Reporter.ACTION_FLUSH_TO_BUNDLE);
+              LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(flush);
+          }
+        }, when);
+    }
+
+    public void setPassiveMode(boolean on) {
+        mStumblingMode = (on)? ActiveOrPassiveStumbling.PASSIVE_STUMBLING :
+               ActiveOrPassiveStumbling.ACTIVE_STUMBLING;
+    }
+
+    public boolean isPassiveMode() {
+        return ActiveOrPassiveStumbling.PASSIVE_STUMBLING == mStumblingMode;
+    }
+
+    public void startScanning(Context context) {
+        if (mIsScanning) {
+            return;
+        }
+
+        mContext = context.getApplicationContext();
+        if (mGPSScanner == null) {
+            mGPSScanner = new GPSScanner(context, this);
+            mWifiScanner = new WifiScanner(context);
+            mCellScanner = new CellScanner(context);
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "Scanning started...");
+        }
+
+        mGPSScanner.start(mStumblingMode);
+        if (mStumblingMode == ActiveOrPassiveStumbling.ACTIVE_STUMBLING) {
+            mWifiScanner.start(mStumblingMode);
+            mCellScanner.start(mStumblingMode);
+            // in passive mode, these scans are started by passive gps notifications
+        }
+        mIsScanning = true;
+    }
+
+    public boolean stopScanning() {
+        if (!mIsScanning) {
+            return false;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.d(LOG_TAG, "Scanning stopped");
+        }
+
+        mGPSScanner.stop();
+        mWifiScanner.stop();
+        mCellScanner.stop();
+
+        mIsScanning = false;
+        return true;
+    }
+
+    public void setWifiBlockList(WifiBlockListInterface list) {
+        WifiScanner.setWifiBlockList(list);
+    }
+
+    public boolean isScanning() {
+        return mIsScanning;
+    }
+
+    public int getAPCount() {
+        return (mWifiScanner == null)? 0 : mWifiScanner.getAPCount();
+    }
+
+    public int getVisibleAPCount() {
+        return (mWifiScanner == null)? 0 :mWifiScanner.getVisibleAPCount();
+    }
+
+    public int getWifiStatus() {
+        return (mWifiScanner == null)? 0 : mWifiScanner.getStatus();
+    }
+
+    public int getCellInfoCount() {
+        return (mCellScanner == null)? 0 :mCellScanner.getCellInfoCount();
+    }
+
+    public int getCurrentCellInfoCount() {
+        return (mCellScanner == null)? 0 :mCellScanner.getCurrentCellInfoCount();
+    }
+
+    public int getLocationCount() {
+        return (mGPSScanner == null)? 0 : mGPSScanner.getLocationCount();
+    }
+
+    public double getLatitude() {
+        return (mGPSScanner == null)? 0.0 : mGPSScanner.getLatitude();
+    }
+
+    public double getLongitude() {
+        return (mGPSScanner == null)? 0.0 : mGPSScanner.getLongitude();
+    }
+
+    public Location getLocation() {
+        return (mGPSScanner == null)? new Location("null") : mGPSScanner.getLocation();
+    }
+
+    public void checkPrefs() {
+        if (mGPSScanner != null) {
+            mGPSScanner.checkPrefs();
+        }
+    }
+
+    public boolean isGeofenced() {
+        return (mGPSScanner == null)? false : mGPSScanner.isGeofenced();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java
@@ -0,0 +1,220 @@
+/* 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.mozstumbler.service.stumblerthread.scanners;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.wifi.ScanResult;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.WifiLock;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.stumblerthread.blocklist.BSSIDBlockList;
+import org.mozilla.mozstumbler.service.stumblerthread.blocklist.SSIDBlockList;
+import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling;
+import org.mozilla.mozstumbler.service.Prefs;
+import org.mozilla.mozstumbler.service.stumblerthread.blocklist.WifiBlockListInterface;
+
+public class WifiScanner extends BroadcastReceiver {
+    public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE + ".WifiScanner.";
+    public static final String ACTION_WIFIS_SCANNED = ACTION_BASE + "WIFIS_SCANNED";
+    public static final String ACTION_WIFIS_SCANNED_ARG_RESULTS = "scan_results";
+    public static final String ACTION_WIFIS_SCANNED_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
+
+    public static final int STATUS_IDLE = 0;
+    public static final int STATUS_ACTIVE = 1;
+    public static final int STATUS_WIFI_DISABLED = -1;
+
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + WifiScanner.class.getSimpleName();
+    private static final long WIFI_MIN_UPDATE_TIME = 5000; // milliseconds
+
+    private boolean mStarted;
+    private final Context mContext;
+    private WifiLock mWifiLock;
+    private Timer mWifiScanTimer;
+    private final Set<String> mAPs = Collections.synchronizedSet(new HashSet<String>());
+    private AtomicInteger mVisibleAPs = new AtomicInteger();
+
+    /* Testing */
+    public static boolean sIsTestMode;
+    public List<ScanResult> mTestModeFakeScanResults = new ArrayList<ScanResult>();
+    public Set<String> getAccessPoints(android.test.AndroidTestCase restrictedAccessor) { return mAPs; }
+    /* ------- */
+
+    public WifiScanner(Context c) {
+        mContext = c;
+    }
+
+    private boolean isWifiEnabled() {
+        return (sIsTestMode) || getWifiManager().isWifiEnabled();
+    }
+
+    private List<ScanResult> getScanResults() {
+        return (sIsTestMode)? mTestModeFakeScanResults : getWifiManager().getScanResults();
+    }
+
+
+    public synchronized void start(final ActiveOrPassiveStumbling stumblingMode) {
+        if (mStarted) {
+            return;
+        }
+        mStarted = true;
+
+        boolean scanAlways = Prefs.getInstance().getWifiScanAlways();
+
+        if (scanAlways || isWifiEnabled()) {
+            activatePeriodicScan(stumblingMode);
+        }
+
+        IntentFilter i = new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION);
+        if (!scanAlways) i.addAction(WifiManager.WIFI_STATE_CHANGED_ACTION);
+        mContext.registerReceiver(this, i);
+    }
+
+    public synchronized void stop() {
+        if (mStarted) {
+            mContext.unregisterReceiver(this);
+        }
+        deactivatePeriodicScan();
+        mStarted = false;
+    }
+
+    public void onReceive(Context c, Intent intent) {
+        String action = intent.getAction();
+
+        if (WifiManager.WIFI_STATE_CHANGED_ACTION.equals(action)) {
+            if (isWifiEnabled()) {
+                activatePeriodicScan(ActiveOrPassiveStumbling.ACTIVE_STUMBLING);
+            } else {
+                deactivatePeriodicScan();
+            }
+        } else if (WifiManager.SCAN_RESULTS_AVAILABLE_ACTION.equals(action)) {
+            ArrayList<ScanResult> scanResults = new ArrayList<ScanResult>();
+            for (ScanResult scanResult : getScanResults()) {
+                scanResult.BSSID = BSSIDBlockList.canonicalizeBSSID(scanResult.BSSID);
+                if (shouldLog(scanResult)) {
+                    scanResults.add(scanResult);
+                    mAPs.add(scanResult.BSSID);
+                }
+            }
+            mVisibleAPs.set(scanResults.size());
+            reportScanResults(scanResults);
+        }
+    }
+
+    public static void setWifiBlockList(WifiBlockListInterface blockList) {
+        BSSIDBlockList.setFilterList(blockList.getBssidOuiList());
+        SSIDBlockList.setFilterLists(blockList.getSsidPrefixList(), blockList.getSsidSuffixList());
+    }
+
+    public int getAPCount() {
+        return mAPs.size();
+    }
+
+    public int getVisibleAPCount() {
+        return mVisibleAPs.get();
+    }
+
+    public synchronized int getStatus() {
+        if (!mStarted) {
+            return STATUS_IDLE;
+        }
+        if (mWifiScanTimer == null) {
+            return STATUS_WIFI_DISABLED;
+        }
+        return STATUS_ACTIVE;
+    }
+
+    private synchronized void activatePeriodicScan(final ActiveOrPassiveStumbling stumblingMode) {
+        if (mWifiScanTimer != null) {
+            return;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.v(LOG_TAG, "Activate Periodic Scan");
+        }
+
+        mWifiLock = getWifiManager().createWifiLock(WifiManager.WIFI_MODE_SCAN_ONLY, "MozStumbler");
+        mWifiLock.acquire();
+
+        // Ensure that we are constantly scanning for new access points.
+        mWifiScanTimer = new Timer();
+        mWifiScanTimer.schedule(new TimerTask() {
+            int mPassiveScanCount;
+            @Override
+            public void run() {
+                if (stumblingMode == ActiveOrPassiveStumbling.PASSIVE_STUMBLING &&
+                    mPassiveScanCount++ > AppGlobals.PASSIVE_MODE_MAX_SCANS_PER_GPS)
+                {
+                    mPassiveScanCount = 0;
+                    stop(); // set mWifiScanTimer to null
+                    return;
+                }
+                if (AppGlobals.isDebug) {
+                    Log.v(LOG_TAG, "WiFi Scanning Timer fired");
+                }
+                getWifiManager().startScan();
+            }
+        }, 0, WIFI_MIN_UPDATE_TIME);
+    }
+
+    private synchronized void deactivatePeriodicScan() {
+        if (mWifiScanTimer == null) {
+            return;
+        }
+
+        if (AppGlobals.isDebug) {
+            Log.v(LOG_TAG, "Deactivate periodic scan");
+        }
+
+        mWifiLock.release();
+        mWifiLock = null;
+
+        mWifiScanTimer.cancel();
+        mWifiScanTimer = null;
+
+        mVisibleAPs.set(0);
+    }
+
+    public static boolean shouldLog(ScanResult scanResult) {
+        if (BSSIDBlockList.contains(scanResult)) {
+            Log.w(LOG_TAG, "Blocked BSSID: " + scanResult);
+            return false;
+        }
+        if (SSIDBlockList.contains(scanResult)) {
+            Log.w(LOG_TAG, "Blocked SSID: " + scanResult);
+            return false;
+        }
+        return true;
+    }
+
+    private WifiManager getWifiManager() {
+        return (WifiManager) mContext.getSystemService(Context.WIFI_SERVICE);
+    }
+
+    private void reportScanResults(ArrayList<ScanResult> scanResults) {
+        if (scanResults.isEmpty()) {
+            return;
+        }
+
+        Intent i = new Intent(ACTION_WIFIS_SCANNED);
+        i.putParcelableArrayListExtra(ACTION_WIFIS_SCANNED_ARG_RESULTS, scanResults);
+        i.putExtra(ACTION_WIFIS_SCANNED_ARG_TIME, System.currentTimeMillis());
+        LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(i);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java
@@ -0,0 +1,389 @@
+/* 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.mozstumbler.service.stumblerthread.scanners.cellscanner;
+
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.telephony.CellLocation;
+import android.telephony.NeighboringCellInfo;
+import android.telephony.TelephonyManager;
+import android.telephony.cdma.CdmaCellLocation;
+import android.telephony.gsm.GsmCellLocation;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.mozstumbler.service.AppGlobals;
+
+public class CellInfo implements Parcelable {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + CellInfo.class.getSimpleName();
+
+    public static final String RADIO_GSM = "gsm";
+    public static final String RADIO_CDMA = "cdma";
+    public static final String RADIO_WCDMA = "wcdma";
+
+    public static final String CELL_RADIO_GSM = "gsm";
+    public static final String CELL_RADIO_UMTS = "umts";
+    public static final String CELL_RADIO_CDMA = "cdma";
+    public static final String CELL_RADIO_LTE = "lte";
+
+    public static final int UNKNOWN_CID = -1;
+    public static final int UNKNOWN_SIGNAL = -1000;
+
+    public static final Parcelable.Creator<CellInfo> CREATOR
+            = new Parcelable.Creator<CellInfo>() {
+        public CellInfo createFromParcel(Parcel in) {
+            return new CellInfo(in);
+        }
+
+        public CellInfo[] newArray(int size) {
+            return new CellInfo[size];
+        }
+    };
+
+    private String mRadio;
+    private String mCellRadio;
+
+    private int mMcc;
+    private int mMnc;
+    private int mCid;
+    private int mLac;
+    private int mSignal;
+    private int mAsu;
+    private int mTa;
+    private int mPsc;
+
+    public CellInfo(int phoneType) {
+        reset();
+        setRadio(phoneType);
+    }
+
+    private CellInfo(Parcel in) {
+        mRadio = in.readString();
+        mCellRadio = in.readString();
+        mMcc = in.readInt();
+        mMnc = in.readInt();
+        mCid = in.readInt();
+        mLac = in.readInt();
+        mSignal = in.readInt();
+        mAsu = in.readInt();
+        mTa = in.readInt();
+        mPsc = in.readInt();
+    }
+
+    public boolean isCellRadioValid() {
+        return mCellRadio != null && (mCellRadio.length() > 0) && !mCellRadio.equals("0");
+    }
+
+    public String getRadio() {
+        return mRadio;
+    }
+
+    public String getCellRadio() {
+        return mCellRadio;
+    }
+
+    public int getMcc() {
+        return mMcc;
+    }
+
+    public int getMnc() {
+        return mMnc;
+    }
+
+    public int getCid() {
+        return mCid;
+    }
+
+    public int getLac() {
+        return mLac;
+    }
+
+    public int getPsc() {
+        return mPsc;
+    }
+
+    public JSONObject toJSONObject() {
+        final JSONObject obj = new JSONObject();
+
+        try {
+            obj.put("radio", getCellRadio());
+            obj.put("mcc", mMcc);
+            obj.put("mnc", mMnc);
+            if (mLac != UNKNOWN_CID) obj.put("lac", mLac);
+            if (mCid != UNKNOWN_CID) obj.put("cid", mCid);
+            if (mSignal != UNKNOWN_SIGNAL) obj.put("signal", mSignal);
+            if (mAsu != UNKNOWN_SIGNAL) obj.put("asu", mAsu);
+            if (mTa != UNKNOWN_CID) obj.put("ta", mTa);
+            if (mPsc != UNKNOWN_CID) obj.put("psc", mPsc);
+        } catch (JSONException jsonE) {
+            throw new IllegalStateException(jsonE);
+        }
+
+        return obj;
+    }
+
+    public String getCellIdentity() {
+        return getRadio()
+                + " " + getCellRadio()
+                + " " + getMcc()
+                + " " + getMnc()
+                + " " + getLac()
+                + " " + getCid()
+                + " " + getPsc();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(mRadio);
+        dest.writeString(mCellRadio);
+        dest.writeInt(mMcc);
+        dest.writeInt(mMnc);
+        dest.writeInt(mCid);
+        dest.writeInt(mLac);
+        dest.writeInt(mSignal);
+        dest.writeInt(mAsu);
+        dest.writeInt(mTa);
+        dest.writeInt(mPsc);
+    }
+
+    void reset() {
+        mRadio = RADIO_GSM;
+        mCellRadio = CELL_RADIO_GSM;
+        mMcc = UNKNOWN_CID;
+        mMnc = UNKNOWN_CID;
+        mLac = UNKNOWN_CID;
+        mCid = UNKNOWN_CID;
+        mSignal = UNKNOWN_SIGNAL;
+        mAsu = UNKNOWN_SIGNAL;
+        mTa = UNKNOWN_CID;
+        mPsc = UNKNOWN_CID;
+    }
+
+    void setRadio(int phoneType) {
+        mRadio = getRadioTypeName(phoneType);
+    }
+
+    void setCellLocation(CellLocation cl,
+                         int networkType,
+                         String networkOperator,
+                         Integer gsmSignalStrength,
+                         Integer cdmaRssi) {
+        if (cl instanceof GsmCellLocation) {
+            final int lac, cid;
+            final GsmCellLocation gcl = (GsmCellLocation) cl;
+
+            reset();
+            mCellRadio = getCellRadioTypeName(networkType);
+            setNetworkOperator(networkOperator);
+
+            lac = gcl.getLac();
+            cid = gcl.getCid();
+            if (lac >= 0) mLac = lac;
+            if (cid >= 0) mCid = cid;
+
+            if (Build.VERSION.SDK_INT >= 9) {
+                final int psc = gcl.getPsc();
+                if (psc >= 0) mPsc = psc;
+            }
+
+            if (gsmSignalStrength != null) {
+                mAsu = gsmSignalStrength;
+            }
+        } else if (cl instanceof CdmaCellLocation) {
+            final CdmaCellLocation cdl = (CdmaCellLocation) cl;
+
+            reset();
+            mCellRadio = getCellRadioTypeName(networkType);
+
+            setNetworkOperator(networkOperator);
+
+            mMnc = cdl.getSystemId();
+
+            mLac = cdl.getNetworkId();
+            mCid = cdl.getBaseStationId();
+
+            if (cdmaRssi != null) {
+                mSignal = cdmaRssi;
+            }
+        } else {
+            throw new IllegalArgumentException("Unexpected CellLocation type: " + cl.getClass().getName());
+        }
+    }
+
+    void setNeighboringCellInfo(NeighboringCellInfo nci, String networkOperator) {
+        final int lac, cid, psc, rssi;
+
+        reset();
+        mCellRadio = getCellRadioTypeName(nci.getNetworkType());
+        setNetworkOperator(networkOperator);
+
+        lac = nci.getLac();
+        cid = nci.getCid();
+        psc = nci.getPsc();
+        rssi = nci.getRssi();
+
+        if (lac >= 0) mLac = lac;
+        if (cid >= 0) mCid = cid;
+        if (psc >= 0) mPsc = psc;
+        if (rssi != NeighboringCellInfo.UNKNOWN_RSSI) mAsu = rssi;
+    }
+
+    void setGsmCellInfo(int mcc, int mnc, int lac, int cid, int asu) {
+        mCellRadio = CELL_RADIO_GSM;
+        mMcc = mcc != Integer.MAX_VALUE ? mcc : UNKNOWN_CID;
+        mMnc = mnc != Integer.MAX_VALUE ? mnc : UNKNOWN_CID;
+        mLac = lac != Integer.MAX_VALUE ? lac : UNKNOWN_CID;
+        mCid = cid != Integer.MAX_VALUE ? cid : UNKNOWN_CID;
+        mAsu = asu;
+    }
+
+    public void setWcmdaCellInfo(int mcc, int mnc, int lac, int cid, int psc, int asu) {
+        mCellRadio = CELL_RADIO_UMTS;
+        mMcc = mcc != Integer.MAX_VALUE ? mcc : UNKNOWN_CID;
+        mMnc = mnc != Integer.MAX_VALUE ? mnc : UNKNOWN_CID;
+        mLac = lac != Integer.MAX_VALUE ? lac : UNKNOWN_CID;
+        mCid = cid != Integer.MAX_VALUE ? cid : UNKNOWN_CID;
+        mPsc = psc != Integer.MAX_VALUE ? psc : UNKNOWN_CID;
+        mAsu = asu;
+    }
+
+    /**
+     * @param mcc Mobile Country Code, Integer.MAX_VALUE if unknown
+     * @param mnc Mobile Network Code, Integer.MAX_VALUE if unknown
+     * @param ci Cell Identity, Integer.MAX_VALUE if unknown
+     * @param pci Physical Cell Id, Integer.MAX_VALUE if unknown
+     * @param tac Tracking Area Code, Integer.MAX_VALUE if unknown
+     * @param asu Arbitrary strength unit
+     * @param ta Timing advance
+     */
+    void setLteCellInfo(int mcc, int mnc, int ci, int pci, int tac, int asu, int ta) {
+        mCellRadio = CELL_RADIO_LTE;
+        mMcc = mcc != Integer.MAX_VALUE ? mcc : UNKNOWN_CID;
+        mMnc = mnc != Integer.MAX_VALUE ? mnc : UNKNOWN_CID;
+        mLac = tac != Integer.MAX_VALUE ? tac : UNKNOWN_CID;
+        mCid = ci != Integer.MAX_VALUE ? ci : UNKNOWN_CID;
+        mPsc = pci != Integer.MAX_VALUE ? pci : UNKNOWN_CID;
+        mAsu = asu;
+        mTa = ta;
+    }
+
+    void setCdmaCellInfo(int baseStationId, int networkId, int systemId, int dbm) {
+        mCellRadio = CELL_RADIO_CDMA;
+        mMnc = systemId != Integer.MAX_VALUE ? systemId : UNKNOWN_CID;
+        mLac = networkId != Integer.MAX_VALUE ? networkId : UNKNOWN_CID;
+        mCid = baseStationId != Integer.MAX_VALUE ? baseStationId : UNKNOWN_CID;
+        mSignal = dbm;
+    }
+
+    void setNetworkOperator(String mccMnc) {
+        if (mccMnc == null || mccMnc.length() < 5 || mccMnc.length() > 8) {
+            throw new IllegalArgumentException("Bad mccMnc: " + mccMnc);
+        }
+        mMcc = Integer.parseInt(mccMnc.substring(0, 3));
+        mMnc = Integer.parseInt(mccMnc.substring(3));
+    }
+
+    static String getCellRadioTypeName(int networkType) {
+        switch (networkType) {
+            // If the network is either GSM or any high-data-rate variant of it, the radio
+            // field should be specified as `gsm`. This includes `GSM`, `EDGE` and `GPRS`.
+            case TelephonyManager.NETWORK_TYPE_GPRS:
+            case TelephonyManager.NETWORK_TYPE_EDGE:
+                return CELL_RADIO_GSM;
+
+            // If the network is either UMTS or any high-data-rate variant of it, the radio
+            // field should be specified as `umts`. This includes `UMTS`, `HSPA`, `HSDPA`,
+            // `HSPA+` and `HSUPA`.
+            case TelephonyManager.NETWORK_TYPE_UMTS:
+            case TelephonyManager.NETWORK_TYPE_HSDPA:
+            case TelephonyManager.NETWORK_TYPE_HSUPA:
+            case TelephonyManager.NETWORK_TYPE_HSPA:
+            case TelephonyManager.NETWORK_TYPE_HSPAP:
+                return CELL_RADIO_UMTS;
+
+            case TelephonyManager.NETWORK_TYPE_LTE:
+                return CELL_RADIO_LTE;
+
+            // If the network is either CDMA or one of the EVDO variants, the radio
+            // field should be specified as `cdma`. This includes `1xRTT`, `CDMA`, `eHRPD`,
+            // `EVDO_0`, `EVDO_A`, `EVDO_B`, `IS95A` and `IS95B`.
+            case TelephonyManager.NETWORK_TYPE_EVDO_0:
+            case TelephonyManager.NETWORK_TYPE_EVDO_A:
+            case TelephonyManager.NETWORK_TYPE_EVDO_B:
+            case TelephonyManager.NETWORK_TYPE_1xRTT:
+            case TelephonyManager.NETWORK_TYPE_EHRPD:
+            case TelephonyManager.NETWORK_TYPE_IDEN:
+                return CELL_RADIO_CDMA;
+
+            default:
+                Log.e(LOG_TAG, "", new IllegalArgumentException("Unexpected network type: " + networkType));
+                return String.valueOf(networkType);
+        }
+    }
+
+    @SuppressWarnings("fallthrough")
+    private static String getRadioTypeName(int phoneType) {
+        switch (phoneType) {
+            case TelephonyManager.PHONE_TYPE_CDMA:
+                return RADIO_CDMA;
+
+            case TelephonyManager.PHONE_TYPE_GSM:
+                return RADIO_GSM;
+
+            default:
+                Log.e(LOG_TAG, "", new IllegalArgumentException("Unexpected phone type: " + phoneType));
+                // fallthrough
+
+            case TelephonyManager.PHONE_TYPE_NONE:
+            case TelephonyManager.PHONE_TYPE_SIP:
+                // These devices have no radio.
+                return "";
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (!(o instanceof CellInfo)) {
+            return false;
+        }
+        CellInfo ci = (CellInfo) o;
+        return mRadio.equals(ci.mRadio)
+               && mCellRadio.equals(ci.mCellRadio)
+               && mMcc == ci.mMcc
+               && mMnc == ci.mMnc
+               && mCid == ci.mCid
+               && mLac == ci.mLac
+               && mSignal == ci.mSignal
+               && mAsu == ci.mAsu
+               && mTa == ci.mTa
+               && mPsc == ci.mPsc;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = 17;
+        result = 31 * result + mRadio.hashCode();
+        result = 31 * result + mCellRadio.hashCode();
+        result = 31 * result + mMcc;
+        result = 31 * result + mMnc;
+        result = 31 * result + mCid;
+        result = 31 * result + mLac;
+        result = 31 * result + mSignal;
+        result = 31 * result + mAsu;
+        result = 31 * result + mTa;
+        result = 31 * result + mPsc;
+        return result;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java
@@ -0,0 +1,135 @@
+/* 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.mozstumbler.service.stumblerthread.scanners.cellscanner;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling;
+
+
+public class CellScanner {
+    public static final String ACTION_BASE = AppGlobals.ACTION_NAMESPACE + ".CellScanner.";
+    public static final String ACTION_CELLS_SCANNED = ACTION_BASE + "CELLS_SCANNED";
+    public static final String ACTION_CELLS_SCANNED_ARG_CELLS = "cells";
+    public static final String ACTION_CELLS_SCANNED_ARG_TIME = AppGlobals.ACTION_ARG_TIME;
+
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + CellScanner.class.getSimpleName();
+    private static final long CELL_MIN_UPDATE_TIME = 1000; // milliseconds
+
+    private final Context mContext;
+    private static CellScannerImpl sImpl;
+    private Timer mCellScanTimer;
+    private final Set<String> mCells = new HashSet<String>();
+    private int mCurrentCellInfoCount;
+
+    public ArrayList<CellInfo> sTestingModeCellInfoArray;
+
+    public interface CellScannerImpl {
+        public void start();
+
+        public void stop();
+
+        public List<CellInfo> getCellInfo();
+    }
+
+    public CellScanner(Context context) {
+        mContext = context;
+    }
+
+    private static synchronized CellScannerImpl getImplementation() {
+        return sImpl;
+    }
+
+    public static synchronized boolean isCellScannerImplSet() {
+        return sImpl != null;
+    }
+
+    /* Fennec doesn't support the apis needed for full scanning, we have different implementations.*/
+    public static synchronized void setCellScannerImpl(CellScannerImpl cellScanner) {
+        sImpl = cellScanner;
+    }
+
+    public void start(final ActiveOrPassiveStumbling stumblingMode) {
+        if (getImplementation() == null) {
+            return;
+        }
+
+        try {
+            getImplementation().start();
+        } catch (UnsupportedOperationException uoe) {
+            Log.e(LOG_TAG, "Cell scanner probe failed", uoe);
+            return;
+        }
+
+        if (mCellScanTimer != null) {
+            return;
+        }
+
+        mCellScanTimer = new Timer();
+
+        mCellScanTimer.schedule(new TimerTask() {
+            int mPassiveScanCount;
+            @Override
+            public void run() {
+                if (getImplementation() == null) {
+                    return;
+                }
+
+                if (stumblingMode == ActiveOrPassiveStumbling.PASSIVE_STUMBLING &&
+                        mPassiveScanCount++ > AppGlobals.PASSIVE_MODE_MAX_SCANS_PER_GPS)
+                {
+                    mPassiveScanCount = 0;
+                    stop();
+                    return;
+                }
+                //if (SharedConstants.isDebug) Log.d(LOG_TAG, "Cell Scanning Timer fired");
+                final long curTime = System.currentTimeMillis();
+
+                ArrayList<CellInfo> cells = (sTestingModeCellInfoArray != null)? sTestingModeCellInfoArray :
+                        new ArrayList<CellInfo>(getImplementation().getCellInfo());
+
+                mCurrentCellInfoCount = cells.size();
+                if (cells.isEmpty()) {
+                    return;
+                }
+                for (CellInfo cell: cells) mCells.add(cell.getCellIdentity());
+
+                Intent intent = new Intent(ACTION_CELLS_SCANNED);
+                intent.putParcelableArrayListExtra(ACTION_CELLS_SCANNED_ARG_CELLS, cells);
+                intent.putExtra(ACTION_CELLS_SCANNED_ARG_TIME, curTime);
+                LocalBroadcastManager.getInstance(mContext).sendBroadcastSync(intent);
+            }
+        }, 0, CELL_MIN_UPDATE_TIME);
+    }
+
+    public void stop() {
+        if (mCellScanTimer != null) {
+            mCellScanTimer.cancel();
+            mCellScanTimer = null;
+        }
+        if (getImplementation() != null) {
+            getImplementation().stop();
+        }
+    }
+
+    public int getCellInfoCount() {
+        return mCells.size();
+    }
+
+    public int getCurrentCellInfoCount() {
+        return mCurrentCellInfoCount;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerNoWCDMA.java
@@ -0,0 +1,254 @@
+/* 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.mozstumbler.service.stumblerthread.scanners.cellscanner;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.telephony.CellIdentityCdma;
+import android.telephony.CellIdentityGsm;
+import android.telephony.CellIdentityLte;
+import android.telephony.CellInfoCdma;
+import android.telephony.CellInfoGsm;
+import android.telephony.CellInfoLte;
+import android.telephony.CellLocation;
+import android.telephony.CellSignalStrengthCdma;
+import android.telephony.CellSignalStrengthGsm;
+import android.telephony.CellSignalStrengthLte;
+import android.telephony.NeighboringCellInfo;
+import android.telephony.PhoneStateListener;
+import android.telephony.SignalStrength;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/* Fennec does not yet support the api level for WCDMA import */
+public class CellScannerNoWCDMA implements CellScanner.CellScannerImpl {
+
+    protected static String LOG_TAG = AppGlobals.LOG_PREFIX + CellScannerNoWCDMA.class.getSimpleName();
+    protected GetAllCellInfoScannerImpl mGetAllInfoCellScanner;
+    protected TelephonyManager mTelephonyManager;
+    protected boolean mIsStarted;
+    protected int mPhoneType;
+    protected final Context mContext;
+    protected volatile int mSignalStrength;
+    protected volatile int mCdmaDbm;
+
+    private PhoneStateListener mPhoneStateListener;
+
+    private static class GetAllCellInfoScannerDummy implements GetAllCellInfoScannerImpl {
+        @Override
+        public List<CellInfo> getAllCellInfo(TelephonyManager tm) {
+            return Collections.emptyList();
+        }
+    }
+
+    interface GetAllCellInfoScannerImpl {
+        List<CellInfo> getAllCellInfo(TelephonyManager tm);
+    }
+
+    public CellScannerNoWCDMA(Context context) {
+        mContext = context;
+    }
+
+    @Override
+    public void start() {
+        if (mIsStarted) {
+            return;
+        }
+        mIsStarted = true;
+
+        if (mTelephonyManager == null) {
+            if (Build.VERSION.SDK_INT >= 18 /*Build.VERSION_CODES.JELLY_BEAN_MR2 */) { // Fennec: no Build.VERSION_CODES
+                mGetAllInfoCellScanner = new GetAllCellInfoScannerMr2();
+            } else {
+                mGetAllInfoCellScanner = new GetAllCellInfoScannerDummy();
+            }
+
+            mTelephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
+            if (mTelephonyManager == null) {
+                throw new UnsupportedOperationException("TelephonyManager service is not available");
+            }
+
+            mPhoneType = mTelephonyManager.getPhoneType();
+
+            if (mPhoneType != TelephonyManager.PHONE_TYPE_GSM
+                && mPhoneType != TelephonyManager.PHONE_TYPE_CDMA) {
+                throw new UnsupportedOperationException("Unexpected Phone Type: " + mPhoneType);
+            }
+            mSignalStrength = CellInfo.UNKNOWN_SIGNAL;
+            mCdmaDbm = CellInfo.UNKNOWN_SIGNAL;
+        }
+
+        mSignalStrength = CellInfo.UNKNOWN_SIGNAL;
+        mCdmaDbm = CellInfo.UNKNOWN_SIGNAL;
+
+        mPhoneStateListener = new PhoneStateListener() {
+            @Override
+            public void onSignalStrengthsChanged(SignalStrength ss) {
+                if (ss.isGsm()) {
+                    mSignalStrength = ss.getGsmSignalStrength();
+                } else {
+                    mCdmaDbm = ss.getCdmaDbm();
+                }
+            }
+        };
+        mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS);
+    }
+
+    @Override
+    public void stop() {
+        mIsStarted = false;
+        if (mTelephonyManager != null) {
+            mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE);
+        }
+        mSignalStrength = CellInfo.UNKNOWN_SIGNAL;
+        mCdmaDbm = CellInfo.UNKNOWN_SIGNAL;
+    }
+
+    @Override
+    public List<CellInfo> getCellInfo() {
+        List<CellInfo> records = new ArrayList<CellInfo>();
+
+        List<CellInfo> allCells = mGetAllInfoCellScanner.getAllCellInfo(mTelephonyManager);
+        if (allCells.isEmpty()) {
+            CellInfo currentCell = getCurrentCellInfo();
+            if (currentCell == null) {
+                return records;
+            }
+            records.add(currentCell);
+        }else {
+            records.addAll(allCells);
+        }
+
+        // getNeighboringCells() sometimes contains more information than that is already
+        // in getAllCellInfo(). Use the results of both of them.
+        records.addAll(getNeighboringCells());
+        return records;
+    }
+
+    private String getNetworkOperator() {
+        String networkOperator = mTelephonyManager.getNetworkOperator();
+        // getNetworkOperator() may be unreliable on CDMA networks
+        if (networkOperator == null || networkOperator.length() <= 3) {
+            networkOperator = mTelephonyManager.getSimOperator();
+        }
+        return networkOperator;
+    }
+
+    protected CellInfo getCurrentCellInfo() {
+        final CellLocation currentCell = mTelephonyManager.getCellLocation();
+        if (currentCell == null) {
+            return null;
+        }
+
+        try {
+            final CellInfo info = new CellInfo(mPhoneType);
+            final int signalStrength = mSignalStrength;
+            final int cdmaDbm = mCdmaDbm;
+            info.setCellLocation(currentCell,
+                    mTelephonyManager.getNetworkType(),
+                    getNetworkOperator(),
+                    signalStrength == CellInfo.UNKNOWN_SIGNAL ? null : signalStrength,
+                    cdmaDbm == CellInfo.UNKNOWN_SIGNAL ? null : cdmaDbm);
+            return info;
+        } catch (IllegalArgumentException iae) {
+            Log.e(LOG_TAG, "Skip invalid or incomplete CellLocation: " + currentCell, iae);
+        }
+        return null;
+    }
+
+    private List<CellInfo> getNeighboringCells() {
+        Collection<NeighboringCellInfo> cells = mTelephonyManager.getNeighboringCellInfo();
+        if (cells == null || cells.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        String networkOperator = getNetworkOperator();
+        List<CellInfo> records = new ArrayList<CellInfo>(cells.size());
+        for (NeighboringCellInfo nci : cells) {
+            try {
+                final CellInfo record = new CellInfo(mPhoneType);
+                record.setNeighboringCellInfo(nci, networkOperator);
+                if (record.isCellRadioValid()) {
+                    records.add(record);
+                }
+            } catch (IllegalArgumentException iae) {
+                Log.e(LOG_TAG, "Skip invalid or incomplete NeighboringCellInfo: " + nci, iae);
+            }
+        }
+        return records;
+    }
+
+    @TargetApi(18)
+    protected boolean addCellToList(List<CellInfo> cells,
+                                 android.telephony.CellInfo observedCell,
+                                 TelephonyManager tm) {
+        boolean added = false;
+        if (observedCell instanceof CellInfoGsm) {
+            CellIdentityGsm ident = ((CellInfoGsm) observedCell).getCellIdentity();
+            if (ident.getMcc() != Integer.MAX_VALUE && ident.getMnc() != Integer.MAX_VALUE) {
+                CellSignalStrengthGsm strength = ((CellInfoGsm) observedCell).getCellSignalStrength();
+                CellInfo cell = new CellInfo(tm.getPhoneType());
+                cell.setGsmCellInfo(ident.getMcc(),
+                        ident.getMnc(),
+                        ident.getLac(),
+                        ident.getCid(),
+                        strength.getAsuLevel());
+                cells.add(cell);
+                added = true;
+            }
+        } else if (observedCell instanceof CellInfoCdma) {
+            CellInfo cell = new CellInfo(tm.getPhoneType());
+            CellIdentityCdma ident = ((CellInfoCdma) observedCell).getCellIdentity();
+            CellSignalStrengthCdma strength = ((CellInfoCdma) observedCell).getCellSignalStrength();
+            cell.setCdmaCellInfo(ident.getBasestationId(),
+                    ident.getNetworkId(),
+                    ident.getSystemId(),
+                    strength.getDbm());
+            cells.add(cell);
+            added = true;
+        } else if (observedCell instanceof CellInfoLte) {
+            CellIdentityLte ident = ((CellInfoLte) observedCell).getCellIdentity();
+            if (ident.getMnc() != Integer.MAX_VALUE && ident.getMcc() != Integer.MAX_VALUE) {
+                CellInfo cell = new CellInfo(tm.getPhoneType());
+                CellSignalStrengthLte strength = ((CellInfoLte) observedCell).getCellSignalStrength();
+                cell.setLteCellInfo(ident.getMcc(),
+                        ident.getMnc(),
+                        ident.getCi(),
+                        ident.getPci(),
+                        ident.getTac(),
+                        strength.getAsuLevel(),
+                        strength.getTimingAdvance());
+               cells.add(cell);
+               added = true;
+            }
+        }
+        return added;
+    }
+
+    @TargetApi(18)
+    private class GetAllCellInfoScannerMr2 implements GetAllCellInfoScannerImpl {
+        @Override
+        public List<CellInfo> getAllCellInfo(TelephonyManager tm) {
+            final List<android.telephony.CellInfo> observed = tm.getAllCellInfo();
+            if (observed == null || observed.isEmpty()) {
+                return Collections.emptyList();
+            }
+
+            List<CellInfo> cells = new ArrayList<CellInfo>(observed.size());
+            for (android.telephony.CellInfo observedCell : observed) {
+                if (!addCellToList(cells, observedCell, tm)) {
+                    //Log.i(LOG_TAG, "Skipped CellInfo of unknown class: " + observedCell.toString());
+                }
+            }
+            return cells;
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java
@@ -0,0 +1,213 @@
+/* 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.mozstumbler.service.uploadthread;
+
+import android.os.AsyncTask;
+import android.util.Log;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.mozilla.mozstumbler.service.Prefs;
+import org.mozilla.mozstumbler.service.utils.AbstractCommunicator;
+import org.mozilla.mozstumbler.service.utils.AbstractCommunicator.SyncSummary;
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageManager;
+import org.mozilla.mozstumbler.service.utils.NetworkUtils;
+
+/* Only one at a time may be uploading. If executed while another upload is in progress
+* it will return immediately, and SyncResult is null.
+*
+* Threading:
+* Uploads on a separate thread. ONLY DataStorageManager is thread-safe, do not call
+* preferences, do not call any code that isn't thread-safe. You will cause suffering.
+* An exception is made for AppGlobals.isDebug, a false reading is of no consequence. */
+public class AsyncUploader extends AsyncTask<Void, Void, SyncSummary> {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + AsyncUploader.class.getSimpleName();
+    private final UploadSettings mSettings;
+    private final Object mListenerLock = new Object();
+    private AsyncUploaderListener mListener;
+    private static AtomicBoolean sIsUploading = new AtomicBoolean();
+    private String mNickname;
+
+    public interface AsyncUploaderListener {
+        public void onUploadComplete(SyncSummary result);
+        public void onUploadProgress();
+    }
+
+    public static class UploadSettings {
+        public final boolean mShouldIgnoreWifiStatus;
+        public final boolean mUseWifiOnly;
+        public UploadSettings(boolean shouldIgnoreWifiStatus, boolean useWifiOnly) {
+            mShouldIgnoreWifiStatus = shouldIgnoreWifiStatus;
+            mUseWifiOnly = useWifiOnly;
+        }
+    }
+
+    public AsyncUploader(UploadSettings settings, AsyncUploaderListener listener) {
+        mListener = listener;
+        mSettings = settings;
+    }
+
+    public void setNickname(String name) {
+        mNickname = name;
+    }
+
+    public void clearListener() {
+        synchronized (mListenerLock) {
+            mListener = null;
+        }
+    }
+
+    public static boolean isUploading() {
+        return sIsUploading.get();
+    }
+
+    @Override
+    protected SyncSummary doInBackground(Void... voids) {
+        if (sIsUploading.get()) {
+            // This if-block is not synchronized, don't care, this is an erroneous usage.
+            Log.d(LOG_TAG, "Usage error: check isUploading first, only one at a time task usage is permitted.");
+            return null;
+        }
+
+        sIsUploading.set(true);
+        SyncSummary result = new SyncSummary();
+        Runnable progressListener = null;
+
+        // no need to lock here, lock is checked again later
+        if (mListener != null) {
+            progressListener = new Runnable() {
+                @Override
+                public void run() {
+                    synchronized (mListenerLock) {
+                        if (mListener != null) {
+                            mListener.onUploadProgress();
+                        }
+                    }
+                }
+            };
+        }
+
+        uploadReports(result, progressListener);
+
+        return result;
+    }
+    @Override
+    protected void onPostExecute(SyncSummary result) {
+        sIsUploading.set(false);
+
+        synchronized (mListenerLock) {
+            if (mListener != null) {
+                mListener.onUploadComplete(result);
+            }
+        }
+    }
+    @Override
+    protected void onCancelled(SyncSummary result) {
+        sIsUploading.set(false);
+    }
+
+    private class Submitter extends AbstractCommunicator {
+        private static final String SUBMIT_URL = "https://location.services.mozilla.com/v1/submit";
+
+        public Submitter() {
+            super(Prefs.getInstance().getUserAgent());
+        }
+
+        @Override
+        public String getUrlString() {
+            return SUBMIT_URL;
+        }
+
+        @Override
+        public String getNickname(){
+            return mNickname;
+        }
+
+        @Override
+        public NetworkSendResult cleanSend(byte[] data) {
+            final NetworkSendResult result = new NetworkSendResult();
+            try {
+                result.bytesSent = this.send(data, ZippedState.eAlreadyZipped);
+                result.errorCode = 0;
+            } catch (IOException ex) {
+                String msg = "Error submitting: " + ex;
+                if (ex instanceof HttpErrorException) {
+                    result.errorCode = ((HttpErrorException) ex).responseCode;
+                    msg += " Code:" + result.errorCode;
+                }
+                Log.e(LOG_TAG, msg);
+                AppGlobals.guiLogError(msg);
+            }
+            return result;
+        }
+    }
+
+    private void uploadReports(AbstractCommunicator.SyncSummary syncResult, Runnable progressListener) {
+        long uploadedObservations = 0;
+        long uploadedCells = 0;
+        long uploadedWifis = 0;
+
+        if (!mSettings.mShouldIgnoreWifiStatus && mSettings.mUseWifiOnly && !NetworkUtils.getInstance().isWifiAvailable()) {
+            if (AppGlobals.isDebug) {
+                Log.d(LOG_TAG, "not on WiFi, not sending");
+            }
+            syncResult.numIoExceptions += 1;
+            return;
+        }
+
+        Submitter submitter = new Submitter();
+        DataStorageManager dm = DataStorageManager.getInstance();
+
+        String error = null;
+
+        try {
+            DataStorageManager.ReportBatch batch = dm.getFirstBatch();
+            while (batch != null) {
+                AbstractCommunicator.NetworkSendResult result = submitter.cleanSend(batch.data);
+
+                if (result.errorCode == 0) {
+                    syncResult.totalBytesSent += result.bytesSent;
+
+                    dm.delete(batch.filename);
+
+                    uploadedObservations += batch.reportCount;
+                    uploadedWifis += batch.wifiCount;
+                    uploadedCells += batch.cellCount;
+                } else {
+                    if (result.errorCode / 100 == 4) {
+                        // delete on 4xx, no point in resending
+                        dm.delete(batch.filename);
+                    } else {
+                        DataStorageManager.getInstance().saveCurrentReportsSendBufferToDisk();
+                    }
+                    syncResult.numIoExceptions += 1;
+                }
+
+                if (progressListener != null) {
+                    progressListener.run();
+                }
+
+                batch = dm.getNextBatch();
+            }
+        }
+        catch (IOException ex) {
+            error = ex.toString();
+        }
+
+        try {
+            dm.incrementSyncStats(syncResult.totalBytesSent, uploadedObservations, uploadedCells, uploadedWifis);
+        } catch (IOException ex) {
+            error = ex.toString();
+        } finally {
+            if (error != null) {
+                syncResult.numIoExceptions += 1;
+                Log.d(LOG_TAG, error);
+                AppGlobals.guiLogError(error + " (uploadReports)");
+            }
+            submitter.close();
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java
@@ -0,0 +1,130 @@
+/* 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.mozstumbler.service.uploadthread;
+
+import android.app.AlarmManager;
+import android.app.IntentService;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.Prefs;
+import org.mozilla.mozstumbler.service.stumblerthread.datahandling.DataStorageManager;
+import org.mozilla.mozstumbler.service.utils.NetworkUtils;
+
+// Only if data is queued and device awake: check network availability and upload.
+// MozStumbler use: this alarm is periodic and repeating.
+// Fennec use: The alarm is single-shot and it is set to run -if there is data in the queue-
+// under these conditions:
+// 1) Fennec start/pause (actually gecko start which is ~4 sec after Fennec start).
+// 2) Changing the pref in Fennec to stumble or not.
+// 3) Boot intent (and SD card app available intent).
+//
+// Threading:
+// - scheduled from the stumbler thread
+// - triggered from the main thread
+// - actual work is done the upload thread (AsyncUploader)
+public class UploadAlarmReceiver extends BroadcastReceiver {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + UploadAlarmReceiver.class.getSimpleName();
+    private static final String EXTRA_IS_REPEATING = "is_repeating";
+    private static boolean sIsAlreadyScheduled;
+
+    public UploadAlarmReceiver() {}
+
+    public static class UploadAlarmService extends IntentService {
+
+        public UploadAlarmService(String name) {
+            super(name);
+        }
+
+        public UploadAlarmService() {
+            super(LOG_TAG);
+        }
+
+        @Override
+        protected void onHandleIntent(Intent intent) {
+            boolean isRepeating = intent.getBooleanExtra(EXTRA_IS_REPEATING, true);
+            if (DataStorageManager.getInstance() == null) {
+                DataStorageManager.createGlobalInstance(this, null);
+            }
+            upload(isRepeating);
+        }
+
+        void upload(boolean isRepeating) {
+            if (!isRepeating) {
+                sIsAlreadyScheduled = false;
+            }
+
+            // Defensive approach: if it is too old, delete all data
+            long oldestMs = DataStorageManager.getInstance().getOldestBatchTimeMs();
+            int maxWeeks = DataStorageManager.getInstance().getMaxWeeksStored();
+            if (oldestMs > 0) {
+                long currentTime = System.currentTimeMillis();
+                long msPerWeek = 604800 * 1000;
+                if (currentTime - oldestMs > maxWeeks * msPerWeek) {
+                    DataStorageManager.getInstance().deleteAll();
+                    UploadAlarmReceiver.cancelAlarm(this, isRepeating);
+                    return;
+                }
+            }
+
+            if (NetworkUtils.getInstance().isWifiAvailable() &&
+                !AsyncUploader.isUploading()) {
+                Log.d(LOG_TAG, "Alarm upload(), call AsyncUploader");
+                AsyncUploader.UploadSettings settings =
+                    new AsyncUploader.UploadSettings(Prefs.getInstance().getWifiScanAlways(), Prefs.getInstance().getUseWifiOnly());
+                AsyncUploader uploader = new AsyncUploader(settings, null);
+                uploader.setNickname(Prefs.getInstance().getNickname());
+                uploader.execute();
+                // we could listen for completion and cancel, instead, cancel on next alarm when db empty
+            }
+        }
+    }
+
+    static PendingIntent createIntent(Context c, boolean isRepeating) {
+        Intent intent = new Intent(c, UploadAlarmReceiver.class);
+        intent.putExtra(EXTRA_IS_REPEATING, isRepeating);
+        PendingIntent pi = PendingIntent.getBroadcast(c, 0, intent, 0);
+        return pi;
+    }
+
+    public static void cancelAlarm(Context c, boolean isRepeating) {
+        Log.d(LOG_TAG, "cancelAlarm");
+        // this is to stop scheduleAlarm from constantly rescheduling, not to guard cancellation.
+        sIsAlreadyScheduled = false;
+        AlarmManager alarmManager = (AlarmManager) c.getSystemService(Context.ALARM_SERVICE);
+        PendingIntent pi = createIntent(c, isRepeating);
+        alarmManager.cancel(pi);
+    }
+
+    public static void scheduleAlarm(Context c, long secondsToWait, boolean isRepeating) {
+        if (sIsAlreadyScheduled) {
+            return;
+        }
+
+        long intervalMsec = secondsToWait * 1000;
+        Log.d(LOG_TAG, "schedule alarm (ms):" + intervalMsec);
+
+        sIsAlreadyScheduled = true;
+        AlarmManager alarmManager = (AlarmManager) c.getSystemService(Context.ALARM_SERVICE);
+        PendingIntent pi = createIntent(c, isRepeating);
+
+        long triggerAtMs = System.currentTimeMillis() + intervalMsec;
+        if (isRepeating) {
+            alarmManager.setInexactRepeating(AlarmManager.RTC, triggerAtMs, intervalMsec, pi);
+        } else {
+            alarmManager.set(AlarmManager.RTC, triggerAtMs, pi);
+        }
+    }
+
+    @Override
+    public void onReceive(final Context context, Intent intent) {
+        Intent startServiceIntent = new Intent(context, UploadAlarmService.class);
+        context.startService(startServiceIntent);
+    }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java
@@ -0,0 +1,156 @@
+/* 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.mozstumbler.service.utils;
+
+import android.os.Build;
+import android.util.Log;
+
+import org.mozilla.mozstumbler.service.AppGlobals;
+import org.mozilla.mozstumbler.service.Prefs;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+public abstract class AbstractCommunicator {
+
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + AbstractCommunicator.class.getSimpleName();
+    private static final String NICKNAME_HEADER = "X-Nickname";
+    private static final String USER_AGENT_HEADER = "User-Agent";
+    private HttpURLConnection mHttpURLConnection;
+    private final String mUserAgent;
+    private static int sBytesSentTotal = 0;
+    private static String sMozApiKey;
+
+    public abstract String getUrlString();
+
+    public static class HttpErrorException extends IOException {
+        private static final long serialVersionUID = -5404095858043243126L;
+        public final int responseCode;
+
+        public HttpErrorException(int responseCode) {
+            super();
+            this.responseCode = responseCode;
+        }
+
+        public boolean isTemporary() {
+            return responseCode >= 500 && responseCode <= 599;
+        }
+    }
+
+    public static class SyncSummary {
+        public int numIoExceptions;
+        public int totalBytesSent;
+    }
+
+    public static class NetworkSendResult {
+        public int bytesSent;
+        // Zero is no error, for HTTP error cases, set this code to the error
+        public int errorCode = -1;
+    }
+
+    public abstract NetworkSendResult cleanSend(byte[] data);
+
+    public String getNickname() {
+        return null;
+    }
+
+    public AbstractCommunicator(String userAgent) {
+        mUserAgent = userAgent;
+    }
+
+    private void openConnectionAndSetHeaders() {
+        try {
+            if (sMozApiKey == null) {
+                sMozApiKey = Prefs.getInstance().getMozApiKey();
+            }
+            URL url = new URL(getUrlString() + "?key=" + sMozApiKey);
+            mHttpURLConnection = (HttpURLConnection) url.openConnection();
+            mHttpURLConnection.setRequestMethod("POST");
+        } catch (MalformedURLException e) {
+            throw new IllegalArgumentException(e);
+        } catch (IOException e) {
+            Log.e(LOG_TAG, "Couldn't open a connection: " + e);
+        }
+        mHttpURLConnection.setDoOutput(true);
+        mHttpURLConnection.setRequestProperty(USER_AGENT_HEADER, mUserAgent);
+        mHttpURLConnection.setRequestProperty("Content-Type", "application/json");
+
+        // Workaround for a bug in Android mHttpURLConnection. When the library
+        // reuses a stale connection, the connection may fail with an EOFException
+        if (Build.VERSION.SDK_INT > 13 && Build.VERSION.SDK_INT < 19) {
+            mHttpURLConnection.setRequestProperty("Connection", "Close");
+        }
+        String nickname = getNickname();
+        if (nickname != null) {
+            mHttpURLConnection.setRequestProperty(NICKNAME_HEADER, nickname);
+        }
+    }
+
+    private byte[] zipData(byte[] data) throws IOException {
+        byte[] output = Zipper.zipData(data);
+        return output;
+    }
+
+    private void sendData(byte[] data) throws IOException{
+        mHttpURLConnection.setFixedLengthStreamingMode(data.length);
+        OutputStream out = new BufferedOutputStream(mHttpURLConnection.getOutputStream());
+        out.write(data);
+        out.flush();
+        int code = mHttpURLConnection.getResponseCode();
+        final boolean isSuccessCode2XX = (code/100 == 2);
+        if (!isSuccessCode2XX) {
+            throw new HttpErrorException(code);
+        }
+    }
+
+    public enum ZippedState { eNotZipped, eAlreadyZipped };
+    /* Return the number of bytes sent. */
+    public int send(byte[] data, ZippedState isAlreadyZipped) throws IOException {
+        openConnectionAndSetHeaders();
+        String logMsg;
+        try {
+            if (isAlreadyZipped != ZippedState.eAlreadyZipped) {
+                data = zipData(data);
+            }
+            mHttpURLConnection.setRequestProperty("Content-Encoding","gzip");
+        } catch (IOException e) {
+            Log.e(LOG_TAG, "Couldn't compress and send data, falling back to plain-text: ", e);
+            close();
+        }
+
+        try {
+            sendData(data);
+        } finally {
+            close();
+        }
+        sBytesSentTotal += data.length;
+        logMsg = "Send data: " + String.format("%.2f", data.length / 1024.0) + " kB";
+        logMsg += " Session Total:" + String.format("%.2f", sBytesSentTotal / 1024.0) + " kB";
+        AppGlobals.guiLogInfo(logMsg, "#FFFFCC", true);
+        Log.d(LOG_TAG, logMsg);
+        return data.length;
+    }
+
+    public InputStream getInputStream() {
+        try {
+            return mHttpURLConnection.getInputStream();
+        } catch (IOException e) {
+            return mHttpURLConnection.getErrorStream();
+        }
+    }
+
+    public void close() {
+        if (mHttpURLConnection == null) {
+            return;
+        }
+        mHttpURLConnection.disconnect();
+        mHttpURLConnection = null;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java
@@ -0,0 +1,41 @@
+/* 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.mozstumbler.service.utils;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.util.Log;
+import org.mozilla.mozstumbler.service.AppGlobals;
+
+public final class NetworkUtils {
+    private static final String LOG_TAG = AppGlobals.LOG_PREFIX + NetworkUtils.class.getSimpleName();
+
+    ConnectivityManager mConnectivityManager;
+    static NetworkUtils sInstance;
+
+    /* Created at startup by app, or service, using a context. */
+    static public void createGlobalInstance(Context context) {
+        sInstance = new NetworkUtils();
+        sInstance.mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+    }
+
+    /* If accessed before singleton instantiation will abort. */
+    public static NetworkUtils getInstance() {
+        assert(sInstance != null);
+        return sInstance;
+    }
+
+    public synchronized boolean isWifiAvailable() {
+        if (mConnectivityManager == null) {
+            Log.e(LOG_TAG, "ConnectivityManager is null!");
+            return false;
+        }
+
+        NetworkInfo aNet = mConnectivityManager.getActiveNetworkInfo();
+        return (aNet != null && aNet.getType() == ConnectivityManager.TYPE_WIFI);
+    }
+
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/PersistentIntentService.java
@@ -0,0 +1,85 @@
+package org.mozilla.mozstumbler.service.utils;
+
+/*
+ * Copyright (C) 2008 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.
+ */
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+
+/* This code is copied from android IntentService, with stopSelf commented out. */
+public abstract class PersistentIntentService extends Service {
+    private volatile Looper mServiceLooper;
+    private volatile ServiceHandler mServiceHandler;
+    private String mName;
+    private boolean mRedelivery;
+
+    private final class ServiceHandler extends Handler {
+        public ServiceHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            onHandleIntent((Intent) msg.obj);
+            // stopSelf(msg.arg1); <-- modified from original file
+        }
+    }
+
+    public PersistentIntentService(String name) {
+        super();
+        mName = name;
+    }
+
+    public void setIntentRedelivery(boolean enabled) {
+        mRedelivery = enabled;
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        HandlerThread thread = new HandlerThread("IntentService[" + mName + "]");
+        thread.start();
+        mServiceLooper = thread.getLooper();
+        mServiceHandler = new ServiceHandler(mServiceLooper);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        Message msg = mServiceHandler.obtainMessage();
+        msg.arg1 = startId;
+        msg.obj = intent;
+        mServiceHandler.sendMessage(msg);
+        return mRedelivery ? START_REDELIVER_INTENT : START_NOT_STICKY;
+    }
+
+    @Override
+    public void onDestroy() {
+        mServiceLooper.quit();
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    protected abstract void onHandleIntent(Intent intent);
+}
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/Zipper.java
@@ -0,0 +1,48 @@
+/* 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.mozstumbler.service.utils;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+public class Zipper {
+    public static byte[] zipData(byte[] data) throws IOException {
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        GZIPOutputStream gstream = new GZIPOutputStream(os);
+        byte[] output;
+        try {
+            gstream.write(data);
+            gstream.finish();
+            output = os.toByteArray();
+        } finally {
+            gstream.close();
+            os.close();
+        }
+        return output;
+    }
+
+    public static String unzipData(byte[] data) throws IOException {
+        StringBuilder result = new StringBuilder();
+        final ByteArrayInputStream bs = new ByteArrayInputStream(data);
+        GZIPInputStream gstream = new GZIPInputStream(bs);
+        try {
+            InputStreamReader reader = new InputStreamReader(gstream);
+            BufferedReader in = new BufferedReader(reader);
+            String read;
+            while ((read = in.readLine()) != null) {
+                result.append(read);
+            }
+        } finally {
+            gstream.close();
+            bs.close();
+        }
+        return result.toString();
+    }
+}
--- a/mobile/android/stumbler/manifests/StumblerManifest_services.xml.in
+++ b/mobile/android/stumbler/manifests/StumblerManifest_services.xml.in
@@ -1,2 +1,15 @@
-<!-- Bug 1024708: this fragment is a place-holder for landing the
-     build integration of the background stumbler into Fennec. -->
+<service
+    android:name="org.mozilla.mozstumbler.service.stumblerthread.StumblerService"
+    android:label="stumbler">
+</service>
+
+<receiver android:name="org.mozilla.mozstumbler.service.uploadthread.UploadAlarmReceiver" />
+<service android:name="org.mozilla.mozstumbler.service.uploadthread.UploadAlarmReceiver$UploadAlarmService" />
+
+<receiver android:name="org.mozilla.mozstumbler.service.mainthread.PassiveServiceReceiver">
+  <intent-filter>
+      <action android:name="android.intent.action.BOOT_COMPLETED" />
+      <action android:name="android.intent.action.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE" />
+      <action android:name="@ANDROID_PACKAGE_NAME@.STUMBLER_PREF" />
+  </intent-filter>
+</receiver>
--- a/mobile/android/stumbler/stumbler_sources.mozbuild
+++ b/mobile/android/stumbler/stumbler_sources.mozbuild
@@ -1,9 +1,32 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 stumbler_sources = [
-    'java/org/mozilla/mozstumbler/PlaceHolder.java',
+    'java/org/mozilla/mozstumbler/service/AppGlobals.java',
+    'java/org/mozilla/mozstumbler/service/mainthread/PassiveServiceReceiver.java',
+    'java/org/mozilla/mozstumbler/service/Prefs.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/SSIDBlockList.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/WifiBlockListInterface.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerNoWCDMA.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java',
+    'java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java',
+    'java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java',
+    'java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java',
+    'java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java',
+    'java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java',
+    'java/org/mozilla/mozstumbler/service/utils/PersistentIntentService.java',
+    'java/org/mozilla/mozstumbler/service/utils/Zipper.java',
 ]