diff options
Diffstat (limited to 'mobile/android/stumbler')
32 files changed, 4312 insertions, 0 deletions
diff --git a/mobile/android/stumbler/Makefile.in b/mobile/android/stumbler/Makefile.in new file mode 100644 index 000000000..958d50362 --- /dev/null +++ b/mobile/android/stumbler/Makefile.in @@ -0,0 +1,9 @@ +# 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/. + +include $(topsrcdir)/config/rules.mk + +include $(topsrcdir)/config/android-common.mk + +libs:: stumbler.jar diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java new file mode 100644 index 000000000..11a3bf4e0 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/AppGlobals.java @@ -0,0 +1,82 @@ +/* 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>"); + } + } + + public static String makeLogTag(String name) { + final int maxLen = 23 - LOG_PREFIX.length(); + if (name.length() > maxLen) { + name = name.substring(name.length() - maxLen, name.length()); + } + return LOG_PREFIX + name; + } + + public static final String ACTION_TEST_SETTING_ENABLED = "stumbler-test-setting-enabled"; + public static final String ACTION_TEST_SETTING_DISABLED = "stumbler-test-setting-disabled"; + + // Histogram values + public static final String TELEMETRY_TIME_BETWEEN_UPLOADS_SEC = "STUMBLER_TIME_BETWEEN_UPLOADS_SEC"; + public static final String TELEMETRY_BYTES_UPLOADED_PER_SEC = "STUMBLER_VOLUME_BYTES_UPLOADED_PER_SEC"; + public static final String TELEMETRY_TIME_BETWEEN_STARTS_SEC = "STUMBLER_TIME_BETWEEN_START_SEC"; + public static final String TELEMETRY_BYTES_PER_UPLOAD = "STUMBLER_UPLOAD_BYTES"; + public static final String TELEMETRY_OBSERVATIONS_PER_UPLOAD = "STUMBLER_UPLOAD_OBSERVATION_COUNT"; + public static final String TELEMETRY_CELLS_PER_UPLOAD = "STUMBLER_UPLOAD_CELL_COUNT"; + public static final String TELEMETRY_WIFIS_PER_UPLOAD = "STUMBLER_UPLOAD_WIFI_AP_COUNT"; + public static final String TELEMETRY_OBSERVATIONS_PER_DAY = "STUMBLER_OBSERVATIONS_PER_DAY"; + public static final String TELEMETRY_TIME_BETWEEN_RECEIVED_LOCATIONS_SEC = "STUMBLER_TIME_BETWEEN_RECEIVED_LOCATIONS_SEC"; +} + diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java new file mode 100644 index 000000000..fa00f29e9 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/Prefs.java @@ -0,0 +1,205 @@ +/* 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.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 = AppGlobals.makeLogTag(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_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(); + } + } + + public static Prefs getInstance(Context c) { + if (sInstance == null) { + sInstance = new Prefs(c); + } + return sInstance; + } + + // Allows code without a context handle to grab the prefs. The caller must null check the return value. + public static Prefs getInstanceWithoutContext() { + 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?!")); + } + } + + private SharedPreferences getPrefs() { + return mSharedPrefs; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/LocalPreferenceReceiver.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/LocalPreferenceReceiver.java new file mode 100644 index 000000000..388abba15 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/LocalPreferenceReceiver.java @@ -0,0 +1,70 @@ +/* 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 local broadcast receiver in SafeReceiver. + * Starts the StumblerService in passive listening mode. + * + * The received intent contains enabled state, upload API key and user agent, + * and is used to initialize the StumblerService. + */ +public class LocalPreferenceReceiver extends BroadcastReceiver { + // This allows global debugging logs to be enabled by doing + // |adb shell setprop log.tag.PassiveStumbler DEBUG| + static final String LOG_TAG = "PassiveStumbler"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + + // This value is cached, so if |setprop| is performed (as described on the LOG_TAG above), + // then the start/stop intent must be resent by toggling the setting or stopping/starting Fennec. + // This does not guard against dumping PII (PII in stumbler is location, wifi BSSID, cell tower details). + AppGlobals.isDebug = Log.isLoggable(LOG_TAG, Log.DEBUG); + + StumblerService.sFirefoxStumblingEnabled.set(intent.getBooleanExtra("enabled", false)); + + if (!StumblerService.sFirefoxStumblingEnabled.get()) { + Log.d(LOG_TAG, "Stopping StumblerService | isDebug:" + AppGlobals.isDebug); + // This calls the service's onDestroy(), and the service's onHandleIntent(...) is not called + context.stopService(new Intent(context, StumblerService.class)); + // For testing service messages were received + context.sendBroadcast(new Intent(AppGlobals.ACTION_TEST_SETTING_DISABLED)); + return; + } + + // For testing service messages were received + context.sendBroadcast(new Intent(AppGlobals.ACTION_TEST_SETTING_ENABLED)); + + Log.d(LOG_TAG, "Sending passive start message | isDebug:" + AppGlobals.isDebug); + + final Intent startServiceIntent = new Intent(context, StumblerService.class); + + startServiceIntent.putExtra(StumblerService.ACTION_START_PASSIVE, true); + startServiceIntent.putExtra( + StumblerService.ACTION_EXTRA_MOZ_API_KEY, + intent.getStringExtra("moz_mozilla_api_key") + ); + startServiceIntent.putExtra( + StumblerService.ACTION_EXTRA_USER_AGENT, + intent.getStringExtra("user_agent") + ); + + context.startService(startServiceIntent); + } +} + diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SafeReceiver.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SafeReceiver.java new file mode 100644 index 000000000..e145dbb0f --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SafeReceiver.java @@ -0,0 +1,43 @@ +/* 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.content.IntentFilter; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; + +/** + * Responsible for registering LocalPreferenceReceiver as a receiver with LocalBroadcastManager. + * This receiver is registered in the AndroidManifest.xml + */ +public class SafeReceiver extends BroadcastReceiver { + static final String LOG_TAG = "StumblerSafeReceiver"; + static final String PREFERENCE_INTENT_FILTER = "STUMBLER_PREF"; + + private boolean registeredLocalReceiver = false; + + @Override + public synchronized void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + + if (registeredLocalReceiver) { + return; + } + + LocalBroadcastManager.getInstance(context).registerReceiver( + new LocalPreferenceReceiver(), + new IntentFilter(PREFERENCE_INTENT_FILTER) + ); + + Log.d(LOG_TAG, "Registered local preference listener"); + + registeredLocalReceiver = true; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SystemReceiver.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SystemReceiver.java new file mode 100644 index 000000000..eaaab3423 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/mainthread/SystemReceiver.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.mainthread; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.mozstumbler.service.stumblerthread.StumblerService; + +/** + * Responsible for starting StumblerService in response to + * BOOT_COMPLETE and EXTERNAL_APPLICATIONS_AVAILABLE system intents. + */ +public class SystemReceiver extends BroadcastReceiver { + static final String LOG_TAG = "StumblerSystemReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + + final String action = intent.getAction(); + + if (!TextUtils.equals(action, Intent.ACTION_BOOT_COMPLETED) && !TextUtils.equals(action, Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE)) { + // This is not the broadcast you are looking for. + return; + } + + final Intent startServiceIntent = new Intent(context, StumblerService.class); + startServiceIntent.putExtra(StumblerService.ACTION_NOT_FROM_HOST_APP, true); + context.startService(startServiceIntent); + + Log.d(LOG_TAG, "Responded to a system intent"); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java new file mode 100644 index 000000000..8f7f19c8d --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/Reporter.java @@ -0,0 +1,219 @@ +/* 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.makeLogTag(Reporter.class.getSimpleName()); + public static final String ACTION_FLUSH_TO_BUNDLE = AppGlobals.ACTION_NAMESPACE + ".FLUSH"; + public static final String ACTION_NEW_BUNDLE = AppGlobals.ACTION_NAMESPACE + ".NEW_BUNDLE"; + 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); + if (tm != null) { + mPhoneType = tm.getPhoneType(); + } else { + Log.d(LOG_TAG, "No telephony manager."); + mPhoneType = TelephonyManager.PHONE_TYPE_NONE; + } + + 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 (GPSScanner.SUBJECT_NEW_LOCATION.equals(subject)) { + 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(); + + switch (action) { + case ACTION_FLUSH_TO_BUNDLE: + flush(); + return; + case WifiScanner.ACTION_WIFIS_SCANNED: + receivedWifiMessage(intent); + break; + case CellScanner.ACTION_CELLS_SCANNED: + receivedCellMessage(intent); + break; + case GPSScanner.ACTION_GPS_UPDATED: + // Calls reportCollectedLocation, this is the ideal case + receivedGpsMessage(intent); + break; + } + + 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) { + if (currentWifiData.size() > MAX_WIFIS_PER_LOCATION) { + return; + } + + 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) { + if (currentCellData.size() > MAX_CELLS_PER_LOCATION) { + return; + } + 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) { + // PII: do not log the bundle without obfuscating it + Log.d(LOG_TAG, "Received bundle"); + } + + if (wifiCount + cellCount < 1) { + return; + } + + try { + DataStorageManager.getInstance().insert(mlsObj.toString(), wifiCount, cellCount); + } catch (IOException e) { + Log.w(LOG_TAG, e.toString()); + } + } +} + diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.java new file mode 100644 index 000000000..5d1a278b9 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/StumblerService.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; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.location.Location; +import android.os.AsyncTask; +import android.support.v4.content.ContextCompat; +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.uploadthread.UploadAlarmReceiver; +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.makeLogTag(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(Context c) { + return Prefs.getInstance(c); + } + + 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 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() { + // Ensure Prefs is created, so internal utility code can use getInstanceWithoutContext + Prefs.getInstance(this); + DataStorageManager.createGlobalInstance(this, 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(StumblerService.this).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 (!hasLocationPermission()) { + Log.d(LOG_TAG, "Location permission not granted. Aborting."); + return; + } + + if (intent == null) { + return; + } + + final boolean isScanEnabledInPrefs = Prefs.getInstance(this).getFirefoxScanEnabled(); + + if (!isScanEnabledInPrefs && intent.getBooleanExtra(ACTION_NOT_FROM_HOST_APP, false)) { + stopSelf(); + return; + } + + boolean hasFilesWaiting = !DataStorageManager.getInstance().isDirEmpty(); + if (AppGlobals.isDebug) { + Log.d(LOG_TAG, "Files waiting:" + hasFilesWaiting); + } + if (hasFilesWaiting) { + // 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(this).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(this).setLastAttemptedUploadTime(timeNow); + UploadAlarmReceiver.scheduleAlarm(this, DELAY_IN_SEC_BEFORE_STARTING_UPLOAD_IN_PASSIVE_MODE, false /* no repeat*/); + } + } + + if (!isScanEnabledInPrefs) { + Prefs.getInstance(this).setFirefoxScanEnabled(true); + } + + String apiKey = intent.getStringExtra(ACTION_EXTRA_MOZ_API_KEY); + if (apiKey != null && !apiKey.equals(Prefs.getInstance(this).getMozApiKey())) { + Prefs.getInstance(this).setMozApiKey(apiKey); + } + + String userAgent = intent.getStringExtra(ACTION_EXTRA_USER_AGENT); + if (userAgent != null && !userAgent.equals(Prefs.getInstance(this).getUserAgent())) { + Prefs.getInstance(this).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 + @Override + 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 */); + } + } + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/BSSIDBlockList.java new file mode 100644 index 000000000..6354cb0cc --- /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.makeLogTag(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(); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/SSIDBlockList.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/SSIDBlockList.java new file mode 100644 index 000000000..f5086ab34 --- /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 + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/WifiBlockListInterface.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/blocklist/WifiBlockListInterface.java new file mode 100644 index 000000000..0e940cdc9 --- /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(); +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java new file mode 100644 index 000000000..2aaeb05ff --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageContract.java @@ -0,0 +1,38 @@ +/* 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 final String HEADING = "heading"; + public static final String SPEED = "speed"; + public static final String PRESSURE = "pressure"; + } + + public static class Stats { + public static final String KEY_VERSION = "version_code"; + public static final int VERSION_CODE = 2; + 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"; + public static final String KEY_OBSERVATIONS_PER_DAY = "obs_per_day"; + } + + private DataStorageContract() { + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java new file mode 100644 index 000000000..adaaea4dc --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/DataStorageManager.java @@ -0,0 +1,473 @@ +/* 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.makeLogTag(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 final ReportFileList mFileList; + private Timer mFlushMemoryBuffersToDiskTimer; + private final PersistedStats mPersistedOnDiskUploadStats; + + 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 = 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); + mPersistedOnDiskUploadStats = new PersistedStats(baseDir); + } + + 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.equals(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(); + 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 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); + } + } + + public synchronized void incrementSyncStats(long bytesSent, long reports, long cells, long wifis) throws IOException { + mPersistedOnDiskUploadStats.incrementSyncStats(bytesSent, reports, cells, wifis); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/PersistedStats.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/PersistedStats.java new file mode 100644 index 000000000..79c8e59d1 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/PersistedStats.java @@ -0,0 +1,99 @@ +/* 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 org.mozilla.mozstumbler.service.AppGlobals; +import org.mozilla.mozstumbler.service.utils.TelemetryWrapper; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +class PersistedStats { + private final File mStatsFile; + + public PersistedStats(String baseDir) { + mStatsFile = new File(baseDir, "upload_stats.ini"); + } + + 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(); + final long lastUploadTime = Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_LAST_UPLOAD_TIME, "0")); + final long storedObsPerDay = Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_OBSERVATIONS_PER_DAY, "0")); + long observationsToday = reports; + if (lastUploadTime > 0) { + long dayLastUploaded = TimeUnit.MILLISECONDS.toDays(lastUploadTime); + long dayDiff = TimeUnit.MILLISECONDS.toDays(time) - dayLastUploaded; + if (dayDiff > 0) { + // send value of store obs per day + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_OBSERVATIONS_PER_DAY, + Long.valueOf(storedObsPerDay / dayDiff).intValue()); + } else { + observationsToday += storedObsPerDay; + } + } + + 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, + observationsToday); + + + final long lastUploadMs = Long.parseLong(properties.getProperty(DataStorageContract.Stats.KEY_LAST_UPLOAD_TIME, "0")); + final int timeDiffSec = Long.valueOf((time - lastUploadMs) / 1000).intValue(); + if (lastUploadMs > 0 && timeDiffSec > 0) { + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_TIME_BETWEEN_UPLOADS_SEC, timeDiffSec); + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_BYTES_UPLOADED_PER_SEC, Long.valueOf(bytesSent).intValue() / timeDiffSec); + } + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_BYTES_PER_UPLOAD, Long.valueOf(bytesSent).intValue()); + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_OBSERVATIONS_PER_UPLOAD, Long.valueOf(reports).intValue()); + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_WIFIS_PER_UPLOAD, Long.valueOf(wifis).intValue()); + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_CELLS_PER_UPLOAD, Long.valueOf(cells).intValue()); + } + + public synchronized void writeSyncStats(long time, long bytesSent, long totalObs, + long totalCells, long totalWifis, long obsPerDay) 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.setProperty(DataStorageContract.Stats.KEY_OBSERVATIONS_PER_DAY, String.valueOf(obsPerDay)); + props.store(out, null); + } finally { + out.close(); + } + } + +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java new file mode 100644 index 000000000..4f47e3302 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/datahandling/StumblerBundle.java @@ -0,0 +1,187 @@ +/* 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.Build; +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; + private float mPressureHPA; + + + 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); + + item.put(DataStorageContract.ReportsColumns.HEADING, mGpsPosition.getBearing()); + item.put(DataStorageContract.ReportsColumns.SPEED, mGpsPosition.getSpeed()); + if (mPressureHPA != 0.0) { + item.put(DataStorageContract.ReportsColumns.PRESSURE, mPressureHPA); + } + + + 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(); + + long gpsTimeSinceBootInMS = 0; + + if (Build.VERSION.SDK_INT >= 17) { + gpsTimeSinceBootInMS = mGpsPosition.getElapsedRealtimeNanos() / 1000000; + } + + for (ScanResult s : mWifiData.values()) { + JSONObject wifiEntry = new JSONObject(); + wifiEntry.put("key", s.BSSID); + wifiEntry.put("frequency", s.frequency); + wifiEntry.put("signal", s.level); + + if (Build.VERSION.SDK_INT >= 17) { + long wifiTimeSinceBootInMS = (s.timestamp / 1000); + long ageMS = wifiTimeSinceBootInMS - gpsTimeSinceBootInMS; + wifiEntry.put("age", ageMS); + } + + wifis.put(wifiEntry); + } + item.put(DataStorageContract.ReportsColumns.WIFI, wifis); + item.put(DataStorageContract.ReportsColumns.WIFI_COUNT, wifis.length()); + + return item; + } + + + public void addPressure(float hPa) { + mPressureHPA = hPa; + } + +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java new file mode 100644 index 000000000..218b97af4 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/GPSScanner.java @@ -0,0 +1,293 @@ +/* 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.annotation.SuppressLint; +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 org.mozilla.mozstumbler.service.utils.TelemetryWrapper; + +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.makeLogTag(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 long mTelemetry_lastStartedMs; + 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 boolean isGpsAvailable(LocationManager locationManager) { + if (locationManager == null || + locationManager.getProvider(LocationManager.GPS_PROVIDER) == null) { + String msg = "No GPS available, scanning not started."; + Log.d(LOG_TAG, msg); + AppGlobals.guiLogError(msg); + return false; + } + return true; + } + + @SuppressLint("MissingPermission") // Permissions are explicitly checked for in StumblerService.onHandleIntent() + private void startPassiveMode() { + LocationManager locationManager = (LocationManager) mContext.getSystemService(Context.LOCATION_SERVICE); + if (!isGpsAvailable(locationManager)) { + return; + } + + locationManager.requestLocationUpdates(LocationManager.PASSIVE_PROVIDER, 0, 0, this); + + final int timeDiffSec = Long.valueOf((System.currentTimeMillis() - mTelemetry_lastStartedMs) / 1000).intValue(); + if (mTelemetry_lastStartedMs > 0 && timeDiffSec > 0) { + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_TIME_BETWEEN_STARTS_SEC, timeDiffSec); + } + mTelemetry_lastStartedMs = System.currentTimeMillis(); + } + + @SuppressLint("MissingPermission") // Permissions are explicitly checked for in StumblerService.onHandleIntent() + private void startActiveMode() { + LocationManager lm = getLocationManager(); + if (!isGpsAvailable(lm)) { + return; + } + + 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() { + @Override + 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); + } + + @SuppressLint("MissingPermission") // Permissions are explicitly checked for in StumblerService.onHandleIntent() + 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(); + } + + Prefs prefs = Prefs.getInstanceWithoutContext(); + if (prefs == null) { + return; + } + mAutoGeofencing = prefs.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")) { + Log.d(LOG_TAG, "Discard fused/network location."); + // only interested in GPS locations + return; + } + + final long timeDeltaMs = location.getTime() - mLocation.getTime(); + + // 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 boolean hasMoved = location.distanceTo(mLocation) > PASSIVE_GPS_MOVEMENT_MIN_DELTA_M; + + if (timeDeltaMs < 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)) { + reportLocationLost(); + return; + } + + mLocation = location; + + if (!mAutoGeofencing) { + reportNewLocationReceived(location); + } + mLocationCount++; + + if (mIsPassiveMode) { + mScanManager.newPassiveGpsLocation(); + } + + if (timeDeltaMs > 0) { + TelemetryWrapper.addToHistogram(AppGlobals.TELEMETRY_TIME_BETWEEN_RECEIVED_LOCATIONS_SEC, + Long.valueOf(timeDeltaMs).intValue() / 1000); + } + } + + @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); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java new file mode 100644 index 000000000..c3cba7b45 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/LocationBlockList.java @@ -0,0 +1,105 @@ +/* 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.makeLogTag(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() { + Prefs prefs = Prefs.getInstanceWithoutContext(); + if (prefs == null) { + return; + } + mBlockedLocation = prefs.getGeofenceLocation(); + mGeofencingEnabled = prefs.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; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java new file mode 100644 index 000000000..60d7c8f1c --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/ScanManager.java @@ -0,0 +1,191 @@ +/* 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.makeLogTag(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 (mContext == null) { + Log.w(LOG_TAG, "No app context available."); + return; + } + + 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 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) && mGPSScanner.isGeofenced(); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java new file mode 100644 index 000000000..eed61d8bb --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/WifiScanner.java @@ -0,0 +1,228 @@ +/* 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.makeLogTag(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 final 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() { + WifiManager manager = getWifiManager(); + if (manager == null) { + return null; + } + return getWifiManager().getScanResults(); + } + + + public synchronized void start(final ActiveOrPassiveStumbling stumblingMode) { + Prefs prefs = Prefs.getInstanceWithoutContext(); + if (mStarted || prefs == null) { + return; + } + mStarted = true; + + boolean scanAlways = prefs.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; + } + + @Override + 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)) { + final List<ScanResult> scanResultList = getScanResults(); + if (scanResultList == null) { + return; + } + final ArrayList<ScanResult> scanResults = new ArrayList<ScanResult>(); + for (ScanResult scanResult : scanResultList) { + 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)) { + return false; + } + if (SSIDBlockList.contains(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); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java new file mode 100644 index 000000000..f435dcf11 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellInfo.java @@ -0,0 +1,391 @@ +/* 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.makeLogTag(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>() { + @Override + public CellInfo createFromParcel(Parcel in) { + return new CellInfo(in); + } + + @Override + 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; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java new file mode 100644 index 000000000..193de9923 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScanner.java @@ -0,0 +1,178 @@ +/* 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.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Message; +import android.support.v4.content.LocalBroadcastManager; +import android.telephony.TelephonyManager; +import android.util.Log; + +import org.mozilla.mozstumbler.service.AppGlobals; +import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling; + +import java.lang.ref.WeakReference; +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 java.util.concurrent.atomic.AtomicBoolean; +import org.mozilla.mozstumbler.service.AppGlobals.ActiveOrPassiveStumbling; +import org.mozilla.mozstumbler.service.stumblerthread.Reporter; + +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.makeLogTag(CellScanner.class.getSimpleName()); + private static final long CELL_MIN_UPDATE_TIME = 1000; // milliseconds + + private final Context mContext; + private Timer mCellScanTimer; + private final Set<String> mCells = new HashSet<String>(); + private final ReportFlushedReceiver mReportFlushedReceiver = new ReportFlushedReceiver(); + private final AtomicBoolean mReportWasFlushed = new AtomicBoolean(); + private Handler mBroadcastScannedHandler; + private final CellScannerImpl mCellScannerImplementation; + + public ArrayList<CellInfo> sTestingModeCellInfoArray; + + public interface CellScannerImpl { + void start(); + boolean isStarted(); + boolean isSupportedOnThisDevice(); + void stop(); + List<CellInfo> getCellInfo(); + } + + public CellScanner(Context context) { + mContext = context; + mCellScannerImplementation = new CellScannerImplementation(context); + } + + public void start(final ActiveOrPassiveStumbling stumblingMode) { + if (!mCellScannerImplementation.isSupportedOnThisDevice()) { + return; + } + + if (mCellScanTimer != null) { + return; + } + + LocalBroadcastManager.getInstance(mContext).registerReceiver(mReportFlushedReceiver, + new IntentFilter(Reporter.ACTION_NEW_BUNDLE)); + + // This is to ensure the broadcast happens from the same thread the CellScanner start() is on + mBroadcastScannedHandler = new BroadcastScannedHandler(this); + + mCellScannerImplementation.start(); + + mCellScanTimer = new Timer(); + + mCellScanTimer.schedule(new TimerTask() { + int mPassiveScanCount; + @Override + public void run() { + if (!mCellScannerImplementation.isStarted()) { + return; + } + + if (stumblingMode == ActiveOrPassiveStumbling.PASSIVE_STUMBLING && + mPassiveScanCount++ > AppGlobals.PASSIVE_MODE_MAX_SCANS_PER_GPS) + { + mPassiveScanCount = 0; + stop(); + return; + } + + final long curTime = System.currentTimeMillis(); + + ArrayList<CellInfo> cells = (sTestingModeCellInfoArray != null)? sTestingModeCellInfoArray : + new ArrayList<CellInfo>(mCellScannerImplementation.getCellInfo()); + + if (mReportWasFlushed.getAndSet(false)) { + clearCells(); + } + + if (cells.isEmpty()) { + return; + } + + for (CellInfo cell : cells) { + addToCells(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); + // send to handler, so broadcast is not from timer thread + Message message = new Message(); + message.obj = intent; + mBroadcastScannedHandler.sendMessage(message); + + } + }, 0, CELL_MIN_UPDATE_TIME); + } + + private synchronized void clearCells() { + mCells.clear(); + } + + private synchronized void addToCells(String cell) { + mCells.add(cell); + } + + public synchronized void stop() { + mReportWasFlushed.set(false); + clearCells(); + LocalBroadcastManager.getInstance(mContext).unregisterReceiver(mReportFlushedReceiver); + + if (mCellScanTimer != null) { + mCellScanTimer.cancel(); + mCellScanTimer = null; + } + mCellScannerImplementation.stop(); + } + + public synchronized int getCellInfoCount() { + return mCells.size(); + } + + private class ReportFlushedReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context c, Intent i) { + mReportWasFlushed.set(true); + } + } + + // Note: this reimplements org.mozilla.gecko.util.WeakReferenceHandler because it's not available here. + private static class BroadcastScannedHandler extends Handler { + private WeakReference<CellScanner> mTarget; + + public BroadcastScannedHandler(final CellScanner that) { + super(); + mTarget = new WeakReference<>(that); + } + + @Override + public void handleMessage(Message msg) { + final CellScanner that = mTarget.get(); + if (that == null) { + return; + } + + final Intent intent = (Intent) msg.obj; + LocalBroadcastManager.getInstance(that.mContext).sendBroadcastSync(intent); + } + }; +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerImplementation.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerImplementation.java new file mode 100644 index 000000000..c6674a3c4 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/stumblerthread/scanners/cellscanner/CellScannerImplementation.java @@ -0,0 +1,299 @@ +/* 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.CellIdentityWcdma; +import android.telephony.CellInfoCdma; +import android.telephony.CellInfoGsm; +import android.telephony.CellInfoLte; +import android.telephony.CellInfoWcdma; +import android.telephony.CellLocation; +import android.telephony.CellSignalStrengthCdma; +import android.telephony.CellSignalStrengthGsm; +import android.telephony.CellSignalStrengthLte; +import android.telephony.CellSignalStrengthWcdma; +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; + +public class CellScannerImplementation implements CellScanner.CellScannerImpl { + + protected static String LOG_TAG = AppGlobals.makeLogTag(CellScannerImplementation.class.getSimpleName()); + protected GetAllCellInfoScannerImpl mGetAllInfoCellScanner; + protected TelephonyManager mTelephonyManager; + protected boolean mIsStarted; + protected int mPhoneType; + protected final Context mContext; + + protected volatile int mSignalStrength = CellInfo.UNKNOWN_SIGNAL; + protected volatile int mCdmaDbm = CellInfo.UNKNOWN_SIGNAL; + + 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 CellScannerImplementation(Context context) { + mContext = context; + } + + public boolean isSupportedOnThisDevice() { + TelephonyManager telephonyManager = mTelephonyManager; + if (telephonyManager == null) { + telephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + } + return telephonyManager != null && + (telephonyManager.getPhoneType() == TelephonyManager.PHONE_TYPE_CDMA || + telephonyManager.getPhoneType() == TelephonyManager.PHONE_TYPE_GSM); + } + + @Override + public synchronized boolean isStarted() { + return mIsStarted; + } + + @Override + public synchronized void start() { + if (mIsStarted || !isSupportedOnThisDevice()) { + return; + } + mIsStarted = true; + + if (mTelephonyManager == null) { + if (Build.VERSION.SDK_INT >= 18 /*Build.VERSION_CODES.JELLY_BEAN_MR2 */) { + mGetAllInfoCellScanner = new GetAllCellInfoScannerMr2(); + } else { + mGetAllInfoCellScanner = new GetAllCellInfoScannerDummy(); + } + + mTelephonyManager = (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + } + + 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 synchronized void stop() { + mIsStarted = false; + if (mTelephonyManager != null && mPhoneStateListener != null) { + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + } + mSignalStrength = CellInfo.UNKNOWN_SIGNAL; + mCdmaDbm = CellInfo.UNKNOWN_SIGNAL; + } + + @Override + public synchronized 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() { + // For max fennec compatibility, avoid VERSION_CODES + if (Build.VERSION.SDK_INT >= 22 /* Build.VERSION_CODES.LOLLIPOP_MR1 */) { + return Collections.emptyList(); + } + + @SuppressWarnings("deprecation") + 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 addWCDMACellToList(List<CellInfo> cells, + android.telephony.CellInfo observedCell, + TelephonyManager tm) { + boolean added = false; + if (Build.VERSION.SDK_INT >= 18 && + observedCell instanceof CellInfoWcdma) { + CellIdentityWcdma ident = ((CellInfoWcdma) observedCell).getCellIdentity(); + if (ident.getMnc() != Integer.MAX_VALUE && ident.getMcc() != Integer.MAX_VALUE) { + CellInfo cell = new CellInfo(tm.getPhoneType()); + CellSignalStrengthWcdma strength = ((CellInfoWcdma) observedCell).getCellSignalStrength(); + cell.setWcmdaCellInfo(ident.getMcc(), + ident.getMnc(), + ident.getLac(), + ident.getCid(), + ident.getPsc(), + strength.getAsuLevel()); + cells.add(cell); + added = true; + } + } + return added; + } + + @TargetApi(18) + protected boolean addCellToList(List<CellInfo> cells, + android.telephony.CellInfo observedCell, + TelephonyManager tm) { + if (tm.getPhoneType() == 0) { + return false; + } + + 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; + } + } + + if (!added && Build.VERSION.SDK_INT >= 18) { + added = addWCDMACellToList(cells, observedCell, tm); + } + + 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; + } + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java new file mode 100644 index 000000000..308e35678 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/AsyncUploader.java @@ -0,0 +1,214 @@ +/* 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.makeLogTag(AsyncUploader.class.getSimpleName()); + private final AsyncUploadArgs mUploadArgs; + private final Object mListenerLock = new Object(); + private AsyncUploaderListener mListener; + private static final AtomicBoolean sIsUploading = new AtomicBoolean(); + private String mNickname; + + public interface AsyncUploaderListener { + public void onUploadComplete(SyncSummary result); + public void onUploadProgress(); + } + + public static class AsyncUploadArgs { + public final NetworkUtils mNetworkUtils; + public final boolean mShouldIgnoreWifiStatus; + public final boolean mUseWifiOnly; + public AsyncUploadArgs(NetworkUtils networkUtils, + boolean shouldIgnoreWifiStatus, + boolean useWifiOnly) { + mNetworkUtils = networkUtils; + mShouldIgnoreWifiStatus = shouldIgnoreWifiStatus; + mUseWifiOnly = useWifiOnly; + } + } + + public AsyncUploader(AsyncUploadArgs args, AsyncUploaderListener listener) { + mListener = listener; + mUploadArgs = args; + } + + 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"; + + @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 (!mUploadArgs.mShouldIgnoreWifiStatus && mUploadArgs.mUseWifiOnly && + !mUploadArgs.mNetworkUtils.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(); + } + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java new file mode 100644 index 000000000..d6680a161 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/uploadthread/UploadAlarmReceiver.java @@ -0,0 +1,138 @@ +/* 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.makeLogTag(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); + // makes the service START_NOT_STICKY, that is, the service is not auto-restarted + setIntentRedelivery(false); + } + + public UploadAlarmService() { + this(LOG_TAG); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent == null) { + return; + } + 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; + } + } + + NetworkUtils networkUtils = new NetworkUtils(this); + if (networkUtils.isWifiAvailable() && + !AsyncUploader.isUploading()) { + Log.d(LOG_TAG, "Alarm upload(), call AsyncUploader"); + AsyncUploader.AsyncUploadArgs settings = + new AsyncUploader.AsyncUploadArgs(networkUtils, + Prefs.getInstance(this).getWifiScanAlways(), + Prefs.getInstance(this).getUseWifiOnly()); + AsyncUploader uploader = new AsyncUploader(settings, null); + uploader.setNickname(Prefs.getInstance(this).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); + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java new file mode 100644 index 000000000..70816371a --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/AbstractCommunicator.java @@ -0,0 +1,158 @@ +/* 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.makeLogTag(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() { + Prefs prefs = Prefs.getInstanceWithoutContext(); + mUserAgent = (prefs != null)? prefs.getUserAgent() : "fennec-stumbler-unset-user-agent"; + } + + private void openConnectionAndSetHeaders() { + try { + Prefs prefs = Prefs.getInstanceWithoutContext(); + if (sMozApiKey == null || prefs != null) { + sMozApiKey = prefs.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; + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java new file mode 100644 index 000000000..b3b33b02a --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/NetworkUtils.java @@ -0,0 +1,32 @@ +/* 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.makeLogTag(NetworkUtils.class.getSimpleName()); + + ConnectivityManager mConnectivityManager; + + public NetworkUtils(Context context) { + mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + 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); + } + +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/PersistentIntentService.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/PersistentIntentService.java new file mode 100644 index 000000000..8387c7edd --- /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 final 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); +} + diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/TelemetryWrapper.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/TelemetryWrapper.java new file mode 100644 index 000000000..91cde26f2 --- /dev/null +++ b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/TelemetryWrapper.java @@ -0,0 +1,35 @@ +package org.mozilla.mozstumbler.service.utils; + +import android.util.Log; +import org.mozilla.mozstumbler.service.AppGlobals; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public class TelemetryWrapper { + private static final String LOG_TAG = AppGlobals.makeLogTag(TelemetryWrapper.class.getSimpleName()); + private static Method mAddToHistogram; + + public static void addToHistogram(String key, int value) { + if (mAddToHistogram == null) { + try { + Class<?> telemetry = Class.forName("org.mozilla.gecko.Telemetry"); + mAddToHistogram = telemetry.getMethod("addToHistogram", String.class, int.class); + } catch (ClassNotFoundException e) { + Log.d(LOG_TAG, "Class not found!"); + return; + } catch (NoSuchMethodException e) { + Log.d(LOG_TAG, "Method not found!"); + return; + } + } + + if (mAddToHistogram != null) { + try { + mAddToHistogram.invoke(null, key, value); + } + catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException e) { + Log.d(LOG_TAG, "Got exception invoking."); + } + } + } +} diff --git a/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/Zipper.java b/mobile/android/stumbler/java/org/mozilla/mozstumbler/service/utils/Zipper.java new file mode 100644 index 000000000..90e0ee7f5 --- /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(); + } +} diff --git a/mobile/android/stumbler/manifests/StumblerManifest_services.xml.in b/mobile/android/stumbler/manifests/StumblerManifest_services.xml.in new file mode 100644 index 000000000..400863187 --- /dev/null +++ b/mobile/android/stumbler/manifests/StumblerManifest_services.xml.in @@ -0,0 +1,32 @@ +<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" /> + +<!-- How Fennec and Stumbler interact: +- On start, Fennec broadcasts an empty STUMBLER_REGISTER_LOCAL_LISTENER intent, indicating that Stumbler should + start listening for a locally-broadcast Stumbler preferences. +- In response, Stumbler's SafeReceiver registers LocalPreferenceReceiver to listen for broadcasts + sent over LocalBroadcastManager which contain sensitive information. +- This registration happens only once, and SafeReceiver can't unregister the listener. +- LocalPreferenceReceiver responds to internal broadcasts with sensitive information, + and is able to start/stop StumblerService. +- Fennec startup (if stumbling is enabled) or Fennec stumbling preference adjustment will trigger + a local preference intent, and Stumbler's internal state will be adjusted via LocalPreferenceReceiver. +--> +<receiver android:exported="false" android:name="org.mozilla.mozstumbler.service.mainthread.SafeReceiver"> + <intent-filter> + <action android:name="org.mozilla.gecko.STUMBLER_REGISTER_LOCAL_LISTENER" /> + </intent-filter> +</receiver> + +<receiver android:exported="true" android:name="org.mozilla.mozstumbler.service.mainthread.SystemReceiver"> + <intent-filter> + <action android:name="android.intent.action.BOOT_COMPLETED" /> + <action android:name="android.intent.action.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE" /> + </intent-filter> +</receiver> + diff --git a/mobile/android/stumbler/moz.build b/mobile/android/stumbler/moz.build new file mode 100644 index 000000000..651cd18dd --- /dev/null +++ b/mobile/android/stumbler/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; 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/. + +include('stumbler_sources.mozbuild') + +stumbler_jar = add_java_jar('stumbler') +stumbler_jar.sources += stumbler_sources +stumbler_jar.extra_jars += [CONFIG['ANDROID_SUPPORT_V4_AAR_LIB']] +stumbler_jar.javac_flags += ['-Xlint:all'] diff --git a/mobile/android/stumbler/stumbler_sources.mozbuild b/mobile/android/stumbler/stumbler_sources.mozbuild new file mode 100644 index 000000000..63bc559f6 --- /dev/null +++ b/mobile/android/stumbler/stumbler_sources.mozbuild @@ -0,0 +1,36 @@ +# -*- Mode: python; 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/service/AppGlobals.java', + 'java/org/mozilla/mozstumbler/service/mainthread/LocalPreferenceReceiver.java', + 'java/org/mozilla/mozstumbler/service/mainthread/SafeReceiver.java', + 'java/org/mozilla/mozstumbler/service/mainthread/SystemReceiver.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/PersistedStats.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/CellScannerImplementation.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/TelemetryWrapper.java', + 'java/org/mozilla/mozstumbler/service/utils/Zipper.java', +] |