/* * Copyright (C) 2012 Pixmob (http://github.com/pixmob) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.pixmob.freemobile.netstat; import static org.pixmob.freemobile.netstat.BuildConfig.DEBUG; import static org.pixmob.freemobile.netstat.Constants.SP_NAME; import static org.pixmob.freemobile.netstat.Constants.TAG; import android.app.AlarmManager; import android.app.IntentService; import android.app.PendingIntent; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.database.CharArrayBuffer; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Build; import android.os.PowerManager; import android.support.v4.util.LongSparseArray; import android.text.format.DateFormat; import android.util.Log; import java.io.IOException; import java.net.HttpURLConnection; import java.util.Calendar; import java.util.HashSet; import java.util.Random; import java.util.Set; import java.util.UUID; import org.json.JSONException; import org.json.JSONObject; import org.pixmob.freemobile.netstat.content.NetstatContract.Events; import org.pixmob.freemobile.netstat.util.DateUtils; import org.pixmob.httpclient.HttpClient; import org.pixmob.httpclient.HttpClientException; import org.pixmob.httpclient.HttpResponse; import org.pixmob.httpclient.HttpResponseHandler; /** * This background service synchronizes data with a remote server. * * @author Pixmob */ public class SyncService extends IntentService { private static final Random RANDOM = new Random(); private static final int SERVER_API_VERSION = 1; private static final String EXTRA_DEVICE_REG = "org.pixmob.freemobile.netstat.deviceReg"; private static final long DAY_IN_MILLISECONDS = 86400 * 1000; private static final int SYNC_UPLOADED = 1; private static final int SYNC_PENDING = 0; private static final int MAX_SYNC_ERRORS = 4; private static final String INTERNAL_SP_NAME = "sync"; private static final String INTERNAL_SP_KEY_SYNC_ERRORS = "syncErrors"; private static String httpUserAgent; private SharedPreferences prefs; private SharedPreferences internalPrefs; private SharedPreferences.Editor internalPrefsEditor; private ConnectivityManager cm; private PowerManager pm; private SQLiteOpenHelper dbHelper; public SyncService() { super("FreeMobileNetstat/Sync"); } public static void schedule(Context context, boolean enabled) { final Context appContext = context.getApplicationContext(); final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); final PendingIntent syncIntent = PendingIntent.getService(appContext, 0, new Intent(appContext, SyncService.class), PendingIntent.FLAG_CANCEL_CURRENT); am.cancel(syncIntent); if (enabled) { // Set the sync period. long period = AlarmManager.INTERVAL_HOUR; final int syncErrors = context.getSharedPreferences(INTERNAL_SP_NAME, MODE_PRIVATE) .getInt(INTERNAL_SP_KEY_SYNC_ERRORS, 0); if (syncErrors != 0) { // When there was a sync error, the sync period is longer. period = AlarmManager.INTERVAL_HOUR * Math.min(syncErrors, MAX_SYNC_ERRORS); } // Add a random time to prevent concurrent requests for the server. final long fuzz = RANDOM.nextInt(1000 * 60 * 30); period += fuzz; if (DEBUG) { Log.d(TAG, "Scheduling synchronization: next in " + (period / 1000 / 60) + " minutes"); } final long syncTime = System.currentTimeMillis() + period; am.set(AlarmManager.RTC_WAKEUP, syncTime, syncIntent); } else { if (DEBUG) { Log.d(TAG, "Synchronization schedule canceled"); } } } @Override public void onCreate() { super.onCreate(); prefs = getSharedPreferences(SP_NAME, MODE_PRIVATE); internalPrefs = getSharedPreferences(INTERNAL_SP_NAME, MODE_PRIVATE); internalPrefsEditor = internalPrefs.edit(); cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); pm = (PowerManager) getSystemService(POWER_SERVICE); dbHelper = new UploadDatabaseHelper(this); } @Override public void onDestroy() { if (dbHelper != null) { dbHelper.close(); } super.onDestroy(); } @Override protected void onHandleIntent(Intent intent) { // Check if statistics upload is enabled. if (!prefs.getBoolean(Constants.SP_KEY_UPLOAD_STATS, false)) { Log.d(TAG, "Synchronization is disabled: skip sync"); return; } // Check if an Internet connection is available. final NetworkInfo netInfo = cm.getActiveNetworkInfo(); if (netInfo == null || !netInfo.isAvailable() || !netInfo.isConnected()) { Log.d(TAG, "Network connectivity is not available: skip sync"); return; } final PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); SQLiteDatabase db = null; try { wl.acquire(); db = dbHelper.getWritableDatabase(); run(intent, db); // Sync was successful: reset sync error count. internalPrefsEditor.remove(INTERNAL_SP_KEY_SYNC_ERRORS).commit(); } catch (Exception e) { Log.e(TAG, "Failed to upload statistics", e); // Increment sync errors. final int syncErrors = internalPrefs.getInt(INTERNAL_SP_KEY_SYNC_ERRORS, 0); internalPrefsEditor.putInt(INTERNAL_SP_KEY_SYNC_ERRORS, syncErrors + 1).commit(); } finally { if (db != null) { db.close(); } wl.release(); // Reschedule this service according to the sync error count. schedule(this, true); } Log.i(TAG, "Statistics upload done"); } private void run(Intent intent, final SQLiteDatabase db) throws Exception { final long now = dateAtMidnight(System.currentTimeMillis()); Log.i(TAG, "Initializing statistics before uploading"); final LongSparseArray< DailyStat> stats = new LongSparseArray< DailyStat>(15); final Set< Long> uploadedStats = new HashSet< Long>(15); final long statTimestampStart = now - 7 * DAY_IN_MILLISECONDS; // Get pending uploads. Cursor c = db.query("daily_stat", new String[] {"stat_timestamp", "orange", "free_mobile", "sync" }, "stat_timestamp>=? AND stat_timestamp<?", new String[] {String.valueOf(statTimestampStart), String.valueOf(now) }, null, null, null); try { while (c.moveToNext()) { final long d = c.getLong(0); final int sync = c.getInt(3); if (SYNC_UPLOADED == sync) { uploadedStats.add(d); } else if (SYNC_PENDING == sync) { final DailyStat s = new DailyStat(); s.orange = c.getInt(1); s.freeMobile = c.getInt(2); stats.put(d, s); } } } finally { c.close(); } // Compute missing uploads. final ContentValues cv = new ContentValues(); db.beginTransaction(); try { for (long d = statTimestampStart; d < now; d += DAY_IN_MILLISECONDS) { if (stats.get(d) == null && !uploadedStats.contains(d)) { final DailyStat s = computeDailyStat(d); cv.put("stat_timestamp", d); cv.put("orange", s.orange); cv.put("free_mobile", s.freeMobile); cv.put("sync", SYNC_PENDING); db.insertOrThrow("daily_stat", null, cv); stats.put(d, s); } } db.setTransactionSuccessful(); } finally { db.endTransaction(); } // Delete old statistics. if (DEBUG) { Log.d(TAG, "Cleaning up upload database"); } db.delete("daily_stat", "stat_timestamp<?", new String[] {String.valueOf(statTimestampStart) }); // Check if there are any statistics to upload. final int statsLen = stats.size(); if (statsLen == 0) { Log.i(TAG, "Nothing to upload"); return; } // Check if the remote server is up. final HttpClient client = createHttpClient(); try { client.head(createServerUrl(null)).execute(); } catch (HttpClientException e) { Log.w(TAG, "Remote server is not available: cannot upload statistics", e); return; } // Upload statistics. Log.i(TAG, "Uploading statistics"); final JSONObject json = new JSONObject(); final String deviceId = getDeviceId(); final boolean deviceWasRegistered = intent.getBooleanExtra(EXTRA_DEVICE_REG, false); for (int i = 0; i < statsLen; ++i) { final long d = stats.keyAt(i); final DailyStat s = stats.get(d); try { json.put("timeOnOrange", s.orange); json.put("timeOnFreeMobile", s.freeMobile); } catch (JSONException e) { final IOException ioe = new IOException("Failed to prepare statistics upload"); ioe.initCause(e); throw ioe; } final String url = createServerUrl("/device/" + deviceId + "/daily/" + DateFormat.format("yyyyMMdd", d)); if (DEBUG) { Log.d(TAG, "Uploading statistics for " + DateUtils.formatDate(d) + " to: " + url); } final byte[] rawJson = json.toString().getBytes("UTF-8"); try { client.post(url).content(rawJson, "application/json").expect(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_NOT_FOUND).to(new HttpResponseHandler() { @Override public void onResponse(HttpResponse response) throws Exception { final int sc = response.getStatusCode(); if (HttpURLConnection.HTTP_NOT_FOUND == sc) { // Check if the device has just been // registered. if (deviceWasRegistered) { throw new IOException("Failed to upload statistics"); } else { // Got 404: the device does not exist. // We need to register this device. registerDevice(deviceId); // Restart this service. startService(new Intent(getApplicationContext(), SyncService.class).putExtra( EXTRA_DEVICE_REG, true)); } } else if (HttpURLConnection.HTTP_OK == sc) { // Update upload database. cv.clear(); cv.put("sync", SYNC_UPLOADED); db.update("daily_stat", cv, "stat_timestamp=?", new String[] {String.valueOf(d) }); if (DEBUG) { Log.d(TAG, "Upload done for " + DateUtils.formatDate(d)); } } } }).execute(); } catch (HttpClientException e) { final IOException ioe = new IOException("Failed to send request with statistics"); ioe.initCause(e); throw ioe; } } } private DailyStat computeDailyStat(long date) { long timeOnOrange = 0; long timeOnFreeMobile = 0; if (DEBUG) { Log.d(TAG, "Computing statistics for " + DateUtils.formatDate(date)); } final Cursor c = getContentResolver().query(Events.CONTENT_URI, new String[] {Events.TIMESTAMP, Events.MOBILE_OPERATOR }, Events.TIMESTAMP + ">=? AND " + Events.TIMESTAMP + "<=?", new String[] {String.valueOf(date), String.valueOf(date + 86400 * 1000) }, Events.TIMESTAMP); try { long t0 = 0; MobileOperator op0 = null; CharArrayBuffer cBuf = new CharArrayBuffer(6); while (c.moveToNext()) { final long t = c.getLong(0); c.copyStringToBuffer(1, cBuf); final MobileOperator op = MobileOperator.fromString(cBuf); if (t0 != 0) { if (op != null && op.equals(op0)) { final long dt = t - t0; if (MobileOperator.ORANGE.equals(op)) { timeOnOrange += dt; } else if (MobileOperator.FREE_MOBILE.equals(op)) { timeOnFreeMobile += dt; } } } t0 = t; op0 = op; } } finally { c.close(); } final DailyStat s = new DailyStat(); s.orange = timeOnOrange; s.freeMobile = timeOnFreeMobile; return s; } private void registerDevice(String deviceId) throws IOException { final JSONObject json = new JSONObject(); try { json.put("brand", Build.BRAND); json.put("model", Build.MODEL); } catch (JSONException e) { final IOException ioe = new IOException("Failed to prepare device registration request"); ioe.initCause(e); throw ioe; } final byte[] rawJson = json.toString().getBytes("UTF-8"); Log.i(TAG, "Registering device"); final String url = createServerUrl("/device/" + deviceId); final HttpClient client = createHttpClient(); try { client.put(url).expect(HttpURLConnection.HTTP_CREATED).content(rawJson, "application/json").execute(); } catch (HttpClientException e) { final IOException ioe = new IOException("Failed to register device " + deviceId); ioe.initCause(e); throw ioe; } } private String createServerUrl(String path) { final String safePath; if (path == null) { safePath = ""; } else if (path.startsWith("/")) { safePath = path; } else { safePath = "/" + path; } return "http://freemobilenetstat.appspot.com/" + SERVER_API_VERSION + safePath; } private HttpClient createHttpClient() { if (httpUserAgent == null) { final PackageManager pm = getPackageManager(); String applicationVersion = "0"; try { final PackageInfo pkgInfo = pm.getPackageInfo(getPackageName(), 0); applicationVersion = pkgInfo.versionName; } catch (NameNotFoundException e) { } httpUserAgent = "FreeMobileNetstat/" + applicationVersion + " Android/" + Build.VERSION.SDK_INT; } final HttpClient client = new HttpClient(this); client.setConnectTimeout(10000); client.setReadTimeout(20000); client.setUserAgent(httpUserAgent); return client; } private String getDeviceId() { final SQLiteDatabase db = dbHelper.getWritableDatabase(); final Cursor c = db.query("device", new String[] {"device_id" }, null, null, null, null, null); String deviceId = null; try { if (c.moveToNext()) { deviceId = c.getString(0); } } finally { c.close(); } if (deviceId == null) { // Generate a new device identifier. deviceId = UUID.randomUUID().toString(); // Store this device identifier in the database. final ContentValues cv = new ContentValues(1); cv.put("device_id", deviceId); db.insertOrThrow("device", null, cv); } return deviceId; } private static long dateAtMidnight(long d) { final Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(d); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); return cal.getTimeInMillis(); } private static class UploadDatabaseHelper extends SQLiteOpenHelper { public UploadDatabaseHelper(final Context context) { super(context, "upload.db", null, 1); } @Override public void onCreate(SQLiteDatabase db) { if (!db.isReadOnly()) { String req = "CREATE TABLE daily_stat (stat_timestamp TIMESTAMP PRIMARY KEY, " + "orange INTEGER NOT NULL, free_mobile INTEGER NOT NULL, sync INTEGER NOT NULL)"; db.execSQL(req); req = "CREATE TABLE device (device_id TEXT PRIMARY KEY)"; db.execSQL(req); } } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (!db.isReadOnly()) { db.execSQL("DROP TABLE IF EXISTS daily_stat"); db.execSQL("DROP TABLE IF EXISTS device"); onCreate(db); } } } private static class DailyStat { public long orange; public long freeMobile; } }