/* * RapidPro Android Channel - Relay SMS messages where MNO connections aren't practical. * Copyright (C) 2014 Nyaruka, UNICEF * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package io.rapidpro.androidchannel; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.wifi.WifiManager; import android.os.Build; import android.preference.PreferenceManager; import android.provider.Settings; import android.util.Base64; import com.commonsware.cwac.wakeful.WakefulIntentService; import io.rapidpro.androidchannel.data.DBCommandHelper; import io.rapidpro.androidchannel.payload.*; import io.rapidpro.androidchannel.util.Http; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.UnsupportedEncodingException; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.List; /** * Syncs our messages with the server. */ public class SyncService extends WakefulIntentService { // how long we should go without a new message before going forward, prevents // us from contacting the server too often during periods of lots of activity public static final long QUIET_PERIOD = 15000; // how many messages before we ignore our quiet period public static final int QUIET_THRESHOLD = 10; // how long between receiving messages to attempt toggling airplane public static final long NO_INCOMING_FREQUENCY = 1000*60*20; // minimum time between us trying airplane mode shenanigans public static final long AIRPLANE_MODE_WAIT = 1000l * 60 * 10; public static String ENDPOINT = "https://rapidpro.io"; public SyncService(){ super(SyncService.class.getSimpleName()); } public String computeHash(String secret, String input) throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec((secret).getBytes(), "ASCII")); mac.update(input.getBytes("UTF-8")); byte[] byteData = mac.doFinal(); return Base64.encodeToString(byteData, Base64.URL_SAFE).trim(); } protected void showAsUnclaimed() { Intent statusIntent = new Intent(Intents.UPDATE_STATUS); statusIntent.putExtra(Intents.CLAIMED_EXTRA, false); sendBroadcast(statusIntent); } protected void updateStatus(String status){ Intent statusIntent = new Intent(Intents.UPDATE_STATUS); statusIntent.putExtra(Intents.STATUS_EXTRA, status); sendBroadcast(statusIntent); RapidPro.broadcastUpdatedCounts(this); } private void setDataEnabled(boolean enabled) { try{ ConnectivityManager conman = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo data = conman.getNetworkInfo(ConnectivityManager.TYPE_MOBILE); Class conmanClass = Class.forName(conman.getClass().getName()); Field iConnectivityManagerField = conmanClass.getDeclaredField("mService"); iConnectivityManagerField.setAccessible(true); Object iConnectivityManager = iConnectivityManagerField.get(conman); Class iConnectivityManagerClass = Class.forName(iConnectivityManager.getClass().getName()); Method setMobileDataEnabledMethod = iConnectivityManagerClass.getDeclaredMethod("setMobileDataEnabled", Boolean.TYPE); setMobileDataEnabledMethod.setAccessible(true); setMobileDataEnabledMethod.invoke(iConnectivityManager, enabled); // sleep up to 30 seconds for it to take effect int sleeps = 0; while (sleeps < 30 && data.isConnected() != enabled){ try{ Thread.sleep(1000); } catch (Throwable t){} data = conman.getNetworkInfo(ConnectivityManager.TYPE_MOBILE); sleeps++; } } catch (Throwable t){ RapidPro.LOG.d("Error trying to turn on mobile data"); } } private void setWifiEnabled(boolean enabled){ // enable wifi WifiManager manager = (WifiManager)this.getApplicationContext().getSystemService(Context.WIFI_SERVICE); manager.setWifiEnabled(enabled); // grab our connectivity manager to test whether it has worked ConnectivityManager connManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); NetworkInfo wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); // sleep up to 30 seconds for it to take effect int sleeps = 0; while (sleeps < 30 && wifi.isConnected() != enabled){ try{ Thread.sleep(1000); } catch (Throwable t){} wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); sleeps++; } } private void checkDataEnabled(boolean enable){ ConnectivityManager connManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); NetworkInfo data = connManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE); // if we aren't connected to mobile data, make sure it is enabled if (data.isConnected() != enable){ if (enable){ checkWifiEnabled(false); } setDataEnabled(enable); } } private void checkWifiEnabled(boolean enable){ WifiManager manager = (WifiManager)this.getApplicationContext().getSystemService(Context.WIFI_SERVICE); ConnectivityManager connManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); NetworkInfo wifi = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); // not connected, turn it on manually if (manager.isWifiEnabled() != enable){ setWifiEnabled(enable); } // our settings are right, but we aren't connected, try flipping things else if (enable && !wifi.isConnected()){ setWifiEnabled(false); setWifiEnabled(true); } } private void setNetworkType(String type){ if (type.equals("wifi")){ checkWifiEnabled(true); } else if (type.equals("data")){ checkDataEnabled(true); } else if (type.equals("none")){ // last case is a no-op, we don't change anything } } private boolean toggleConnection(){ WifiManager manager = (WifiManager)this.getApplicationContext().getSystemService(Context.WIFI_SERVICE); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this.getApplicationContext()); boolean wifiPossible = prefs.getBoolean(SettingsActivity.WIFI_ENABLED, true); boolean dataPossible = prefs.getBoolean(SettingsActivity.DATA_ENABLED, true); // wifi is on if (manager.isWifiEnabled()){ // if we can use data, flip to it if (dataPossible){ RapidPro.LOG.d("Toggling connection to data"); setNetworkType("data"); return true; } } // wifi is off else { // so flip to wifi if we allow it if (wifiPossible){ RapidPro.LOG.d("Toggling connection to wifi"); setNetworkType("wifi"); return true; } } RapidPro.LOG.d("Not toggling, no other choice available"); return false; } protected boolean isGoogleUp(){ HttpURLConnection conn = null; try{ URL url = new URL("http://google.com/"); conn = (HttpURLConnection) url.openConnection(); conn.setInstanceFollowRedirects(false); conn.setRequestMethod( "HEAD" ); conn.connect(); RapidPro.LOG.d("Google UP: " + conn.getResponseCode()); return true; } catch (Throwable t){ RapidPro.LOG.d("Google DOWN"); return false; } finally { conn.disconnect(); } } public void tickleAirplaneMode(){ try { RapidPro.LOG.d("Attempting airplane toggle"); // Don't attempt an airplane toggle if we are on 4.2 or higher if (Build.VERSION.SDK_INT >= 17) { return; } RapidPro.LOG.d("Toggling airplane mode"); Context context = this; Settings.System.putInt(context.getContentResolver(), Settings.System.AIRPLANE_MODE_ON, 1); // reload our settings to take effect Intent intent = new Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED); intent.putExtra("state", true); context.sendBroadcast(intent); // sleep 15 seconds for things to take effect try { Thread.sleep(15000); } catch (Throwable ignore){} // then toggle back Settings.System.putInt(context.getContentResolver(), Settings.System.AIRPLANE_MODE_ON, 0); // reload our settings to take effect intent = new Intent(Intent.ACTION_AIRPLANE_MODE_CHANGED); intent.putExtra("state", false); context.sendBroadcast(intent); // sleep 20 seconds for things to take effect try { Thread.sleep(20000); } catch (Throwable ignore){} SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); editor.putLong(SettingsActivity.LAST_AIRPLANE_TOGGLE, System.currentTimeMillis()); editor.commit(); } catch (Throwable t) { // on 4.2, we don't get to toggle airplane mode anymore } } protected boolean firePayload(SyncPayload payload, String url, String secret){ // whether we successfully synced boolean synced = false; Http http = new Http(); String json = payload.asJSON().toString(); SyncPayload response = null; boolean connectionOk = false; try{ if (secret != null){ String seconds = "" + (System.currentTimeMillis() / 1000); String signature = computeHash(secret+seconds, json); url += "?ts=" + seconds + "&signature=" + URLEncoder.encode(signature); } try{ response = new SyncPayload(http.fetchJSON(url, json)); connectionOk = true; synced = true; } catch (Throwable t){ RapidPro.LOG.e("First try error contacting server", t); // if google is down, toggle our connection if (!isGoogleUp()){ toggleConnection(); } response = new SyncPayload(http.fetchJSON(url, json)); connectionOk = true; synced = true; } } catch (Throwable t){ RapidPro.LOG.e("Second try error contacting server", t); } // we got a response.. execute our commands if (response != null){ if (response.errorId == SyncPayload.NO_ERROR){ for (Command cmd: response.commands){ try{ cmd.execute(getApplicationContext(), payload); } catch (Throwable t){ RapidPro.LOG.e("Error executing cmd: " + cmd, t); } } updateStatus(""); } // error 1 means invalid signing, which means our secret is wrong else if (response.errorId == SyncPayload.INVALID_SECRET) { RapidPro.LOG.d("ErrorId: " + response.errorId); updateStatus("Error"); // clear our secret and relayer payloadId SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()).edit(); editor.remove(SettingsActivity.RELAYER_SECRET); editor.remove(SettingsActivity.RELAYER_ID); editor.remove(SettingsActivity.RESET); editor.commit(); showAsUnclaimed(); } // error 3 means our clock is out of date compared to the server else if (response.errorId == SyncPayload.OLD_REQUEST){ updateStatus("Time Wrong"); connectionOk = isGoogleUp(); } // Other kind of application error else { updateStatus("App Error"); connectionOk = isGoogleUp(); } } else { updateStatus("Network Error"); connectionOk = isGoogleUp(); } SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()).edit(); editor.putBoolean(SettingsActivity.CONNECTION_UP, connectionOk); editor.commit(); return synced; } @Override protected void doWakefulWork (Intent intent) { // Stop if RapidPro is paused RapidPro.LOG.d("Paused: " + RapidPro.get().isPaused()); if (RapidPro.get().isPaused()) return; updateStatus("Syncing"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this.getApplicationContext()); String network = prefs.getString(SettingsActivity.DEFAULT_NETWORK, "none"); String gcmId = prefs.getString(SettingsActivity.GCM_ID, ""); boolean useAirplane = prefs.getBoolean(SettingsActivity.AIRPLANE_RESET, false); RapidPro.LOG.d("Use airplane: " + useAirplane); String uuid = RapidPro.get().getUUID(); // no gcm id? don't even try to sync if (gcmId.length() == 0){ return; } String relayerId = prefs.getString(SettingsActivity.RELAYER_ID, null); String secret = prefs.getString(SettingsActivity.RELAYER_SECRET, null); String endpoint = prefs.getString(SettingsActivity.SERVER, ENDPOINT); String ip = prefs.getString(SettingsActivity.IP_ADDRESS, null); boolean force = intent.getBooleanExtra(Intents.FORCE_EXTRA, false) || !RapidPro.get().isClaimed(); long syncTime = intent.getLongExtra(Intents.SYNC_TIME, 0); long lastSync = prefs.getLong(RapidProAlarmListener.LAST_SYNC_TIME, -1l); long lastAirplane = prefs.getLong(SettingsActivity.LAST_AIRPLANE_TOGGLE, -1l); long lastReceived = prefs.getLong(SettingsActivity.LAST_SMS_RECEIVED, 0); long now = System.currentTimeMillis(); // if this sync was before we actually synced, ignore if (syncTime < lastSync){ updateStatus(""); return; } // if our endpoint is an ip, add :8000 to it if (endpoint.startsWith("ip")){ endpoint = "http://" + ip; if (!ip.contains(":")) { endpoint += ":8000"; } } syncTime = System.currentTimeMillis(); List<Command> commands = DBCommandHelper.getPendingCommands(this, DBCommandHelper.OUT, DBCommandHelper.BORN, 50, null, false); // no commands to send out and not forcing? Return if (commands.size() == 0 && !force){ updateStatus(""); return; } // flip to whatever network is preferred by our user setNetworkType(network); SyncPayload payload = new SyncPayload(); payload.addCommand(new GCM(gcmId, uuid)); payload.addCommand(new StatusCommand(this)); boolean synced = false; // we've got everything we need to do proper syncing, go for it if (gcmId != null && relayerId != null && secret != null && secret.length() > 0){ for (Command command: commands){ payload.addCommand(command); } String url = endpoint + "/relayers/relayer/sync/" + relayerId + "/"; synced = firePayload(payload, url, secret); } // we don't know our secret or relayer payloadId, we should register else if (gcmId != null){ updateStatus("Registering"); String url = endpoint + "/relayers/relayer/register/"; synced = firePayload(payload, url, null); } // send any queued messages RapidPro.get().runCommands(); // if we successfully synced, then update our last sync time if (synced) { SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(this.getApplicationContext()).edit(); editor.putLong(RapidProAlarmListener.LAST_SYNC_TIME, syncTime); editor.commit(); } // if we have messages in an errored state and haven't sent a message in over 10 minutes, then toggle // our airplane mode if we haven't done so recently int retry = DBCommandHelper.getCommandCount(this, DBCommandHelper.IN, MTTextMessage.RETRY, MTTextMessage.CMD); long lastSMSSent = prefs.getLong(SettingsActivity.LAST_SMS_SENT, 0); // see whether we should use the airplane mode hack to keep the phone online if (useAirplane && now - lastAirplane > AIRPLANE_MODE_WAIT) { boolean toggleAirplane = false; // been too long since a successful sync toggleAirplane = now - lastSync > AIRPLANE_MODE_WAIT; // been too long since we've received a message if (!toggleAirplane) { toggleAirplane = now - lastReceived >= NO_INCOMING_FREQUENCY; } // haven't successfully sent an SMS in a while if (!toggleAirplane) { toggleAirplane = retry > 0 && now - lastSMSSent > AIRPLANE_MODE_WAIT; } if (toggleAirplane) { tickleAirplaneMode(); } } RapidPro.broadcastUpdatedCounts(this); // if there are more pending commands, force another sync to work our way through the queue commands = DBCommandHelper.getPendingCommands(this, DBCommandHelper.OUT, DBCommandHelper.BORN, 50, null, false); if (commands.size() > 10){ RapidPro.get().sync(true); } } }