/*
* 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;
}
}