package org.fdroid.fdroid.localrepo; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.net.Uri; import android.net.http.AndroidHttpClient; import android.os.AsyncTask; import android.os.IBinder; import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; import org.apache.http.HttpHost; import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.message.BasicNameValuePair; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.localrepo.peers.Peer; import org.fdroid.fdroid.localrepo.peers.PeerFinder; import org.fdroid.fdroid.localrepo.type.BluetoothSwap; import org.fdroid.fdroid.localrepo.type.SwapType; import org.fdroid.fdroid.localrepo.type.WifiSwap; import org.fdroid.fdroid.net.WifiStateChangeService; import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; 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.ConcurrentHashMap; import rx.Observable; import rx.Subscription; import rx.android.schedulers.AndroidSchedulers; import rx.schedulers.Schedulers; /** * Central service which manages all of the different moving parts of swap which are required * to enable p2p swapping of apps. */ public class SwapService extends Service { private static final String TAG = "SwapService"; private static final String SHARED_PREFERENCES = "swap-state"; private static final String KEY_APPS_TO_SWAP = "appsToSwap"; private static final String KEY_BLUETOOTH_ENABLED = "bluetoothEnabled"; private static final String KEY_WIFI_ENABLED = "wifiEnabled"; @NonNull private final Set<String> appsToSwap = new HashSet<>(); /** * A cache of parsed APKs from the file system. */ private static final ConcurrentHashMap<String, App> INSTALLED_APPS = new ConcurrentHashMap<>(); public static void stop(Context context) { Intent intent = new Intent(context, SwapService.class); context.stopService(intent); } static App getAppFromCache(String packageName) { return INSTALLED_APPS.get(packageName); } static void putAppInCache(String packageName, App app) { INSTALLED_APPS.put(packageName, app); } /** * Where relevant, the state of the swap process will be saved to disk using preferences. * Note that this is not always useful, for example saving the "current wifi network" is * bound to cause trouble when the user opens the swap process again and is connected to * a different network. */ private SharedPreferences persistence() { return getSharedPreferences(SHARED_PREFERENCES, MODE_APPEND); } // ========================================================== // Search for peers to swap // ========================================================== private Observable<Peer> peerFinder; /** * Call {@link Observable#subscribe()} on this in order to be notified of peers * which are found. Call {@link Subscription#unsubscribe()} on the resulting * subscription when finished and you no longer want to scan for peers. * * The returned object will scan for peers on a background thread, and emit * found peers on the mian thread. * * Invoking this in multiple places will return the same, cached, peer finder. * That is, if in the past it already found some peers, then you subscribe * to it in the future, the future subscriber will still receive the peers * that were found previously. * TODO: What about removing peers that no longer are present? */ public Observable<Peer> scanForPeers() { Utils.debugLog(TAG, "Scanning for nearby devices to swap with..."); if (peerFinder == null) { peerFinder = PeerFinder.createObservable(getApplicationContext()) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .distinct(); } return peerFinder; } // ========================================================== // Manage the current step // ("Step" refers to the current view being shown in the UI) // ========================================================== public static final int STEP_INTRO = 1; public static final int STEP_SELECT_APPS = 2; public static final int STEP_JOIN_WIFI = 3; public static final int STEP_SHOW_NFC = 4; public static final int STEP_WIFI_QR = 5; public static final int STEP_CONNECTING = 6; public static final int STEP_SUCCESS = 7; public static final int STEP_CONFIRM_SWAP = 8; /** * Special view, that we don't really want to actually store against the * {@link SwapService#step}. Rather, we use it for the purpose of specifying * we are in the state waiting for the {@link SwapService} to get started and * bound to the {@link SwapWorkflowActivity}. */ public static final int STEP_INITIAL_LOADING = 9; @SwapStep private int step = STEP_INTRO; /** * Current screen that the swap process is up to. * Will be one of the SwapState.STEP_* values. */ @SwapStep public int getStep() { return step; } public SwapService setStep(@SwapStep int step) { this.step = step; return this; } @NonNull public Set<String> getAppsToSwap() { return appsToSwap; } public void refreshSwap() { if (peer != null) { connectTo(peer, false); } } public void connectToPeer() { if (getPeer() == null) { throw new IllegalStateException("Cannot connect to peer, no peer has been selected."); } connectTo(getPeer(), getPeer().shouldPromptForSwapBack()); } public void connectTo(@NonNull Peer peer, boolean requestSwapBack) { if (peer != this.peer) { Log.e(TAG, "Oops, got a different peer to swap with than initially planned."); } peerRepo = ensureRepoExists(peer); // Only ask server to swap with us, if we are actually running a local repo service. // It is possible to have a swap initiated without first starting a swap, in which // case swapping back is pointless. if (isEnabled() && requestSwapBack) { askServerToSwapWithUs(peerRepo); } UpdateService.updateRepoNow(peer.getRepoAddress(), this); } private void askServerToSwapWithUs(final Repo repo) { askServerToSwapWithUs(repo.address); } private void askServerToSwapWithUs(final String address) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... args) { Uri repoUri = Uri.parse(address); String swapBackUri = Utils.getLocalRepoUri(FDroidApp.repo).toString(); AndroidHttpClient client = AndroidHttpClient.newInstance("F-Droid", SwapService.this); HttpPost request = new HttpPost("/request-swap"); HttpHost host = new HttpHost(repoUri.getHost(), repoUri.getPort(), repoUri.getScheme()); try { Utils.debugLog(TAG, "Asking server at " + address + " to swap with us in return (by POSTing to \"/request-swap\" with repo \"" + swapBackUri + "\")..."); populatePostParams(swapBackUri, request); client.execute(host, request); } catch (IOException e) { notifyOfErrorOnUiThread(); Log.e(TAG, "Error while asking server to swap with us", e); } finally { client.close(); } return null; } private void populatePostParams(String swapBackUri, HttpPost request) throws UnsupportedEncodingException { List<NameValuePair> params = new ArrayList<>(); params.add(new BasicNameValuePair("repo", swapBackUri)); UrlEncodedFormEntity encodedParams = new UrlEncodedFormEntity(params); request.setEntity(encodedParams); } private void notifyOfErrorOnUiThread() { // TODO: Broadcast error message so that whoever wants to can display a relevant // message in the UI. This service doesn't understand the concept of UI. /*runOnUiThread(new Runnable() { @Override public void run() { Toast.makeText( SwapService.this, R.string.swap_reciprocate_failed, Toast.LENGTH_LONG ).show(); } });*/ } }.execute(); } private Repo ensureRepoExists(@NonNull Peer peer) { // TODO: newRepoConfig.getParsedUri() will include a fingerprint, which may not match with // the repos address in the database. Not sure on best behaviour in this situation. Repo repo = RepoProvider.Helper.findByAddress(this, peer.getRepoAddress()); if (repo == null) { ContentValues values = new ContentValues(6); // The name/description is not really required, as swap repos are not shown in the // "Manage repos" UI on other device. Doesn't hurt to put something there though, // on the off chance that somebody is looking through the sqlite database which // contains the repos... values.put(Schema.RepoTable.Cols.NAME, peer.getName()); values.put(Schema.RepoTable.Cols.ADDRESS, peer.getRepoAddress()); values.put(Schema.RepoTable.Cols.DESCRIPTION, ""); String fingerprint = peer.getFingerprint(); if (!TextUtils.isEmpty(fingerprint)) { values.put(Schema.RepoTable.Cols.FINGERPRINT, peer.getFingerprint()); } values.put(Schema.RepoTable.Cols.IN_USE, true); values.put(Schema.RepoTable.Cols.IS_SWAP, true); Uri uri = RepoProvider.Helper.insert(this, values); repo = RepoProvider.Helper.get(this, uri); } return repo; } @Nullable public Repo getPeerRepo() { return peerRepo; } /** * Ensure that we don't get put into an incorrect state, by forcing people to pass valid * states to setStep. Ideally this would be done by requiring an enum or something to * be passed rather than in integer, however that is harder to persist on disk than an int. * This is the same as, e.g. {@link Context#getSystemService(String)} */ @IntDef({STEP_INTRO, STEP_SELECT_APPS, STEP_JOIN_WIFI, STEP_SHOW_NFC, STEP_WIFI_QR, STEP_CONNECTING, STEP_SUCCESS, STEP_CONFIRM_SWAP, STEP_INITIAL_LOADING}) @Retention(RetentionPolicy.SOURCE) public @interface SwapStep { } // ================================================= // Have selected a specific peer to swap with // (Rather than showing a generic QR code to scan) // ================================================= @Nullable private Peer peer; @Nullable private Repo peerRepo; public void swapWith(Peer peer) { this.peer = peer; } public boolean isConnectingWithPeer() { return peer != null; } @Nullable public Peer getPeer() { return peer; } // ========================================== // Remember apps user wants to swap // ========================================== private void persistAppsToSwap() { persistence().edit().putString(KEY_APPS_TO_SWAP, serializePackages(appsToSwap)).apply(); } /** * Replacement for {@link android.content.SharedPreferences.Editor#putStringSet(String, Set)} * which is only available in API >= 11. * Package names are reverse-DNS-style, so they should only have alpha numeric values. Thus, * this uses a comma as the separator. * @see SwapService#deserializePackages(String) */ private static String serializePackages(Set<String> packages) { StringBuilder sb = new StringBuilder(); for (String pkg : packages) { if (sb.length() > 0) { sb.append(','); } sb.append(pkg); } return sb.toString(); } /** * @see SwapService#deserializePackages(String) */ private static Set<String> deserializePackages(String packages) { Set<String> set = new HashSet<>(); if (!TextUtils.isEmpty(packages)) { Collections.addAll(set, packages.split(",")); } return set; } public void ensureFDroidSelected() { String fdroid = getPackageName(); if (!hasSelectedPackage(fdroid)) { selectPackage(fdroid); } } public boolean hasSelectedPackage(String packageName) { return appsToSwap.contains(packageName); } public void selectPackage(String packageName) { appsToSwap.add(packageName); persistAppsToSwap(); } public void deselectPackage(String packageName) { if (appsToSwap.contains(packageName)) { appsToSwap.remove(packageName); } persistAppsToSwap(); } // ============================================================= // Remember which swap technologies a user used in the past // ============================================================= private final BroadcastReceiver receiveSwapStatusChanged = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Utils.debugLog(TAG, "Remembering that Bluetooth swap " + (bluetoothSwap.isConnected() ? "IS" : "is NOT") + " connected and WiFi swap " + (wifiSwap.isConnected() ? "IS" : "is NOT") + " connected."); persistence().edit() .putBoolean(KEY_BLUETOOTH_ENABLED, bluetoothSwap.isConnected()) .putBoolean(KEY_WIFI_ENABLED, wifiSwap.isConnected()) .apply(); } }; /* private boolean wasBluetoothEnabled() { return persistence().getBoolean(KEY_BLUETOOTH_ENABLED, false); } */ private boolean wasWifiEnabled() { return persistence().getBoolean(KEY_WIFI_ENABLED, false); } /** * Handles checking if the {@link SwapService} is running, and only restarts it if it was running. */ public void stopWifiIfEnabled(final boolean restartAfterStopping) { if (wifiSwap.isConnected()) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { Utils.debugLog(TAG, "Stopping the currently running WiFi swap service (on background thread)"); wifiSwap.stop(); if (restartAfterStopping) { Utils.debugLog(TAG, "Restarting WiFi swap service after stopping (still on background thread)"); wifiSwap.start(); } return null; } }.execute(); } } public boolean isEnabled() { return bluetoothSwap.isConnected() || wifiSwap.isConnected(); } // ========================================== // Interacting with Bluetooth adapter // ========================================== public boolean isBluetoothDiscoverable() { return bluetoothSwap.isDiscoverable(); } public boolean isBonjourDiscoverable() { return wifiSwap.isConnected() && wifiSwap.getBonjour().isConnected(); } public static final String ACTION_PEER_FOUND = "org.fdroid.fdroid.SwapManager.ACTION_PEER_FOUND"; public static final String EXTRA_PEER = "EXTRA_PEER"; // =============================================================== // Old SwapService stuff being merged into that. // =============================================================== public static final String BONJOUR_STATE_CHANGE = "org.fdroid.fdroid.BONJOUR_STATE_CHANGE"; public static final String BLUETOOTH_STATE_CHANGE = "org.fdroid.fdroid.BLUETOOTH_STATE_CHANGE"; public static final String WIFI_STATE_CHANGE = "org.fdroid.fdroid.WIFI_STATE_CHANGE"; public static final String EXTRA_STARTING = "STARTING"; public static final String EXTRA_STARTED = "STARTED"; public static final String EXTRA_STOPPING = "STOPPING"; public static final String EXTRA_STOPPED = "STOPPED"; private static final int NOTIFICATION = 1; private final Binder binder = new Binder(); private SwapType bluetoothSwap; private WifiSwap wifiSwap; private static final int TIMEOUT = 15 * 60 * 1000; // 15 mins /** * Used to automatically turn of swapping after a defined amount of time (15 mins). */ @Nullable private Timer timer; public SwapType getBluetoothSwap() { return bluetoothSwap; } public WifiSwap getWifiSwap() { return wifiSwap; } public class Binder extends android.os.Binder { public SwapService getService() { return SwapService.this; } } public void onCreate() { super.onCreate(); Utils.debugLog(TAG, "Creating swap service."); startForeground(NOTIFICATION, createNotification()); CacheSwapAppsService.startCaching(this); SharedPreferences preferences = getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE); appsToSwap.addAll(deserializePackages(preferences.getString(KEY_APPS_TO_SWAP, ""))); bluetoothSwap = BluetoothSwap.create(this); wifiSwap = new WifiSwap(this); Preferences.get().registerLocalRepoHttpsListeners(httpsEnabledListener); LocalBroadcastManager.getInstance(this).registerReceiver(onWifiChange, new IntentFilter(WifiStateChangeService.BROADCAST)); IntentFilter filter = new IntentFilter(BLUETOOTH_STATE_CHANGE); filter.addAction(WIFI_STATE_CHANGE); LocalBroadcastManager.getInstance(this).registerReceiver(receiveSwapStatusChanged, filter); /* if (wasBluetoothEnabled()) { Utils.debugLog(TAG, "Previously the user enabled Bluetooth swap, so enabling again automatically."); bluetoothSwap.startInBackground(); } */ if (wasWifiEnabled()) { Utils.debugLog(TAG, "Previously the user enabled WiFi swap, so enabling again automatically."); wifiSwap.startInBackground(); } else { Utils.debugLog(TAG, "WiFi was NOT enabled last time user swapped, so starting with WiFi not visible."); } } @Override public int onStartCommand(Intent intent, int flags, int startId) { return START_STICKY; } @Override public IBinder onBind(Intent intent) { // reset the timer on each new connect, the user has come back initTimer(); return binder; } @Override public void onDestroy() { Utils.debugLog(TAG, "Destroying service, will disable swapping if required, and unregister listeners."); Preferences.get().unregisterLocalRepoHttpsListeners(httpsEnabledListener); LocalBroadcastManager.getInstance(this).unregisterReceiver(onWifiChange); LocalBroadcastManager.getInstance(this).unregisterReceiver(receiveSwapStatusChanged); //TODO getBluetoothSwap().stopInBackground(); getWifiSwap().stopInBackground(); if (timer != null) { timer.cancel(); } stopForeground(true); super.onDestroy(); } private Notification createNotification() { Intent intent = new Intent(this, SwapWorkflowActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); return new NotificationCompat.Builder(this) .setContentTitle(getText(R.string.local_repo_running)) .setContentText(getText(R.string.touch_to_configure_local_repo)) .setSmallIcon(R.drawable.ic_swap) .setContentIntent(contentIntent) .build(); } private void initTimer() { if (timer != null) { Utils.debugLog(TAG, "Cancelling existing timeout timer so timeout can be reset."); timer.cancel(); } Utils.debugLog(TAG, "Initializing swap timeout to " + TIMEOUT + "ms minutes"); timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { Utils.debugLog(TAG, "Disabling swap because " + TIMEOUT + "ms passed."); stop(SwapService.this); } }, TIMEOUT); } @SuppressWarnings("FieldCanBeLocal") // The constructor will get bloated if these are all local... private final Preferences.ChangeListener httpsEnabledListener = new Preferences.ChangeListener() { @Override public void onPreferenceChange() { Log.i(TAG, "Swap over HTTPS preference changed."); stopWifiIfEnabled(true); } }; @SuppressWarnings("FieldCanBeLocal") // The constructor will get bloated if these are all local... private final BroadcastReceiver onWifiChange = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent i) { boolean hasIp = FDroidApp.ipAddressString != null; stopWifiIfEnabled(hasIp); } }; }