package org.fdroid.fdroid.nearby; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.bluetooth.BluetoothAdapter; 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.wifi.WifiManager; import android.os.IBinder; import android.text.TextUtils; import android.util.Log; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.NotificationHelper; 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.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.nearby.peers.Peer; import org.fdroid.fdroid.net.DownloaderService; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.net.HttpURLConnection; import java.net.URL; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.Timer; import java.util.TimerTask; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.ServiceCompat; import androidx.core.content.ContextCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import cc.mvdan.accesspoint.WifiApControl; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Central service which manages all of the different moving parts of swap * which are required to enable p2p swapping of apps. This is the background * operations for {@link SwapWorkflowActivity}. */ 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"; private static final String KEY_HOTSPOT_ACTIVATED = "hotspotEnabled"; private static final String KEY_BLUETOOTH_ENABLED_BEFORE_SWAP = "bluetoothEnabledBeforeSwap"; private static final String KEY_BLUETOOTH_NAME_BEFORE_SWAP = "bluetoothNameBeforeSwap"; private static final String KEY_WIFI_ENABLED_BEFORE_SWAP = "wifiEnabledBeforeSwap"; private static final String KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP = "hotspotEnabledBeforeSwap"; @NonNull private final Set appsToSwap = new HashSet<>(); private final Set activePeers = new HashSet<>(); private static LocalBroadcastManager localBroadcastManager; private static SharedPreferences swapPreferences; private static BluetoothAdapter bluetoothAdapter; private static WifiManager wifiManager; private static Timer pollConnectedSwapRepoTimer; public static void stop(Context context) { Intent intent = new Intent(context, SwapService.class); context.stopService(intent); } @NonNull public Set getAppsToSwap() { return appsToSwap; } @NonNull public Set getActivePeers() { return activePeers; } public void connectToPeer() { if (getPeer() == null) { throw new IllegalStateException("Cannot connect to peer, no peer has been selected."); } connectTo(getPeer()); if (LocalHTTPDManager.isAlive() && getPeer().shouldPromptForSwapBack()) { askServerToSwapWithUs(peerRepo); } } public void connectTo(@NonNull Peer peer) { if (peer != this.peer) { Log.e(TAG, "Oops, got a different peer to swap with than initially planned."); } peerRepo = ensureRepoExists(peer); UpdateService.updateRepoNow(this, peer.getRepoAddress()); } 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, 1); 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; } // ================================================= // 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 void addCurrentPeerToActive() { activePeers.add(peer); } public void removeCurrentPeerFromActive() { activePeers.remove(peer); } public boolean isConnectingWithPeer() { return peer != null; } @Nullable public Peer getPeer() { return peer; } // ========================================== // Remember apps user wants to swap // ========================================== private void persistAppsToSwap() { swapPreferences.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 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 deserializePackages(String packages) { Set 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(); } public static boolean getBluetoothVisibleUserPreference() { return swapPreferences.getBoolean(SwapService.KEY_BLUETOOTH_ENABLED, false); } public static void putBluetoothVisibleUserPreference(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_BLUETOOTH_ENABLED, visible).apply(); } public static boolean getWifiVisibleUserPreference() { return swapPreferences.getBoolean(SwapService.KEY_WIFI_ENABLED, false); } public static void putWifiVisibleUserPreference(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_WIFI_ENABLED, visible).apply(); } public static boolean getHotspotActivatedUserPreference() { return swapPreferences.getBoolean(SwapService.KEY_HOTSPOT_ACTIVATED, false); } public static void putHotspotActivatedUserPreference(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_HOTSPOT_ACTIVATED, visible).apply(); } public static boolean wasBluetoothEnabledBeforeSwap() { return swapPreferences.getBoolean(SwapService.KEY_BLUETOOTH_ENABLED_BEFORE_SWAP, false); } public static void putBluetoothEnabledBeforeSwap(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_BLUETOOTH_ENABLED_BEFORE_SWAP, visible).apply(); } public static String getBluetoothNameBeforeSwap() { return swapPreferences.getString(SwapService.KEY_BLUETOOTH_NAME_BEFORE_SWAP, null); } public static void putBluetoothNameBeforeSwap(String name) { swapPreferences.edit().putString(SwapService.KEY_BLUETOOTH_NAME_BEFORE_SWAP, name).apply(); } public static boolean wasWifiEnabledBeforeSwap() { return swapPreferences.getBoolean(SwapService.KEY_WIFI_ENABLED_BEFORE_SWAP, false); } public static void putWifiEnabledBeforeSwap(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_WIFI_ENABLED_BEFORE_SWAP, visible).apply(); } public static boolean wasHotspotEnabledBeforeSwap() { return swapPreferences.getBoolean(SwapService.KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP, false); } public static void putHotspotEnabledBeforeSwap(boolean visible) { swapPreferences.edit().putBoolean(SwapService.KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP, visible).apply(); } private static final int NOTIFICATION = 1; private final Binder binder = new Binder(); 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; private final CompositeDisposable compositeDisposable = new CompositeDisposable(); public class Binder extends android.os.Binder { public SwapService getService() { return SwapService.this; } } @Override public void onCreate() { super.onCreate(); startForeground(NOTIFICATION, createNotification()); localBroadcastManager = LocalBroadcastManager.getInstance(this); swapPreferences = getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE); LocalHTTPDManager.start(this); bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); if (bluetoothAdapter != null) { SwapService.putBluetoothEnabledBeforeSwap(bluetoothAdapter.isEnabled()); if (bluetoothAdapter.isEnabled()) { BluetoothManager.start(this); } registerReceiver(bluetoothScanModeChanged, new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)); } wifiManager = ContextCompat.getSystemService(getApplicationContext(), WifiManager.class); if (wifiManager != null) { SwapService.putWifiEnabledBeforeSwap(wifiManager.isWifiEnabled()); } appsToSwap.addAll(deserializePackages(swapPreferences.getString(KEY_APPS_TO_SWAP, ""))); Preferences.get().registerLocalRepoHttpsListeners(httpsEnabledListener); localBroadcastManager.registerReceiver(onWifiChange, new IntentFilter(WifiStateChangeService.BROADCAST)); localBroadcastManager.registerReceiver(bluetoothStatus, new IntentFilter(BluetoothManager.ACTION_STATUS)); localBroadcastManager.registerReceiver(bluetoothPeerFound, new IntentFilter(BluetoothManager.ACTION_FOUND)); localBroadcastManager.registerReceiver(bonjourPeerFound, new IntentFilter(BonjourManager.ACTION_FOUND)); localBroadcastManager.registerReceiver(bonjourPeerRemoved, new IntentFilter(BonjourManager.ACTION_REMOVED)); localBroadcastManager.registerReceiver(localRepoStatus, new IntentFilter(LocalRepoService.ACTION_STATUS)); if (getHotspotActivatedUserPreference()) { WifiApControl wifiApControl = WifiApControl.getInstance(this); if (wifiApControl != null) { wifiApControl.enable(); } } else if (getWifiVisibleUserPreference()) { if (wifiManager != null) { wifiManager.setWifiEnabled(true); } } BonjourManager.start(this); BonjourManager.setVisible(this, getWifiVisibleUserPreference() || getHotspotActivatedUserPreference()); } private void askServerToSwapWithUs(final Repo repo) { compositeDisposable.add( Completable.fromAction(() -> { String swapBackUri = Utils.getLocalRepoUri(FDroidApp.repo).toString(); HttpURLConnection conn = null; try { URL url = new URL(repo.address.replace("/fdroid/repo", "/request-swap")); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setDoInput(true); conn.setDoOutput(true); try (OutputStream outputStream = conn.getOutputStream(); OutputStreamWriter writer = new OutputStreamWriter(outputStream)) { writer.write("repo=" + swapBackUri); writer.flush(); } int responseCode = conn.getResponseCode(); Utils.debugLog(TAG, "Asking server at " + repo.address + " to swap with us in return (by " + "POSTing to \"/request-swap\" with repo \"" + swapBackUri + "\"): " + responseCode); } finally { if (conn != null) { conn.disconnect(); } } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .onErrorComplete(e -> { Intent intent = new Intent(DownloaderService.ACTION_INTERRUPTED); intent.setData(Uri.parse(repo.address)); intent.putExtra(DownloaderService.EXTRA_ERROR_MESSAGE, e.getLocalizedMessage()); LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent); return true; }) .subscribe() ); } /** * This is for setting things up for when the {@code SwapService} was * started by the user clicking on the initial start button. The things * that must be run always on start-up go in {@link #onCreate()}. */ @Override public int onStartCommand(Intent intent, int flags, int startId) { deleteAllSwapRepos(); Intent startUiIntent = new Intent(this, SwapWorkflowActivity.class); startUiIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(startUiIntent); return START_NOT_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() { compositeDisposable.dispose(); Utils.debugLog(TAG, "Destroying service, will disable swapping if required, and unregister listeners."); Preferences.get().unregisterLocalRepoHttpsListeners(httpsEnabledListener); localBroadcastManager.unregisterReceiver(onWifiChange); localBroadcastManager.unregisterReceiver(bluetoothStatus); localBroadcastManager.unregisterReceiver(bluetoothPeerFound); localBroadcastManager.unregisterReceiver(bonjourPeerFound); localBroadcastManager.unregisterReceiver(bonjourPeerRemoved); if (bluetoothAdapter != null) { unregisterReceiver(bluetoothScanModeChanged); } BluetoothManager.stop(this); BonjourManager.stop(this); LocalHTTPDManager.stop(this); if (wifiManager != null && !wasWifiEnabledBeforeSwap()) { wifiManager.setWifiEnabled(false); } WifiApControl ap = WifiApControl.getInstance(this); if (ap != null) { try { if (wasHotspotEnabledBeforeSwap()) { ap.enable(); } else { ap.disable(); } } catch (Exception e) { e.printStackTrace(); } } stopPollingConnectedSwapRepo(); if (timer != null) { timer.cancel(); } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); deleteAllSwapRepos(); 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, NotificationHelper.CHANNEL_SWAPS) .setContentTitle(getText(R.string.local_repo_running)) .setContentText(getText(R.string.touch_to_configure_local_repo)) .setSmallIcon(R.drawable.ic_nearby) .setContentIntent(contentIntent) .build(); } /** * For now, swap repos are only trusted as long as swapping is active. They * should have a long lived trust based on the signing key, but that requires * that the repos are stored in the database by fingerprint, not by URL address. * * @see TOFU in swap * @see * signing key fingerprint should be sole ID for repos in the database */ private void deleteAllSwapRepos() { for (Repo repo : RepoProvider.Helper.all(this)) { if (repo.isSwap) { Utils.debugLog(TAG, "Removing stale swap repo: " + repo.address + " - " + repo.fingerprint); RepoProvider.Helper.remove(this, repo.getId()); } } } private void startPollingConnectedSwapRepo() { stopPollingConnectedSwapRepo(); pollConnectedSwapRepoTimer = new Timer("pollConnectedSwapRepoTimer", true); TimerTask timerTask = new TimerTask() { @Override public void run() { if (peer != null) { connectTo(peer); } } }; pollConnectedSwapRepoTimer.schedule(timerTask, 5000); } public void stopPollingConnectedSwapRepo() { if (pollConnectedSwapRepoTimer != null) { pollConnectedSwapRepoTimer.cancel(); pollConnectedSwapRepoTimer = null; } } /** * Sets or resets the idel timer for {@link #TIMEOUT}ms, once the timer * expires, this service and all things that rely on it will be stopped. */ public 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(TAG, true); timer.schedule(new TimerTask() { @Override public void run() { Utils.debugLog(TAG, "Disabling swap because " + TIMEOUT + "ms passed."); String msg = getString(R.string.swap_toast_closing_nearby_after_timeout); Utils.showToastFromService(SwapService.this, msg, android.widget.Toast.LENGTH_LONG); stop(SwapService.this); } }, TIMEOUT); } private void restartWiFiServices() { boolean hasIp = FDroidApp.ipAddressString != null; if (hasIp) { LocalHTTPDManager.restart(this); BonjourManager.restart(this); BonjourManager.setVisible(this, getWifiVisibleUserPreference() || getHotspotActivatedUserPreference()); } else { BonjourManager.stop(this); LocalHTTPDManager.stop(this); } } private final Preferences.ChangeListener httpsEnabledListener = new Preferences.ChangeListener() { @Override public void onPreferenceChange() { restartWiFiServices(); } }; private final BroadcastReceiver onWifiChange = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent i) { restartWiFiServices(); } }; private final BroadcastReceiver bluetoothStatus = new SwapStateChangeReceiver(); private final BroadcastReceiver localRepoStatus = new SwapStateChangeReceiver(); /** * When swapping is setup, then start the index polling. */ private class SwapStateChangeReceiver extends BroadcastReceiver { private final BroadcastReceiver pollForUpdatesReceiver = new PollForUpdatesReceiver(); @Override public void onReceive(Context context, Intent intent) { int bluetoothStatus = intent.getIntExtra(BluetoothManager.ACTION_STATUS, -1); int wifiStatus = intent.getIntExtra(LocalRepoService.EXTRA_STATUS, -1); if (bluetoothStatus == BluetoothManager.STATUS_STARTED || wifiStatus == LocalRepoService.STATUS_STARTED) { localBroadcastManager.registerReceiver(pollForUpdatesReceiver, new IntentFilter(UpdateService.LOCAL_ACTION_STATUS)); } else { localBroadcastManager.unregisterReceiver(pollForUpdatesReceiver); } } } /** * Reschedule an index update if the last one was successful. */ private class PollForUpdatesReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { switch (intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1)) { case UpdateService.STATUS_COMPLETE_AND_SAME: case UpdateService.STATUS_COMPLETE_WITH_CHANGES: startPollingConnectedSwapRepo(); break; } } } /** * Handle events if the user or system changes the Bluetooth setup outside of F-Droid. */ private final BroadcastReceiver bluetoothScanModeChanged = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { switch (intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1)) { case BluetoothAdapter.SCAN_MODE_NONE: BluetoothManager.stop(SwapService.this); break; case BluetoothAdapter.SCAN_MODE_CONNECTABLE: case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE: BluetoothManager.start(SwapService.this); break; } } }; private final BroadcastReceiver bluetoothPeerFound = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { activePeers.add((Peer) intent.getParcelableExtra(BluetoothManager.EXTRA_PEER)); } }; private final BroadcastReceiver bonjourPeerFound = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { activePeers.add((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); } }; private final BroadcastReceiver bonjourPeerRemoved = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { activePeers.remove((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); } }; }