package org.fdroid.fdroid.nearby; import android.annotation.TargetApi; import android.app.Activity; import android.bluetooth.BluetoothAdapter; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; import android.graphics.LightingColorFilter; import android.net.Uri; import android.net.wifi.WifiManager; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.provider.Settings; import android.support.annotation.LayoutRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.MenuItemCompat; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.SearchView; import android.support.v7.widget.SwitchCompat; import android.support.v7.widget.Toolbar; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.ImageView; import android.widget.ListView; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import cc.mvdan.accesspoint.WifiApControl; import com.google.zxing.integration.android.IntentIntegrator; import com.google.zxing.integration.android.IntentResult; import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.NfcHelper; 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.NewRepoConfig; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.nearby.peers.BluetoothPeer; import org.fdroid.fdroid.nearby.peers.Peer; import org.fdroid.fdroid.net.BluetoothDownloader; import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.HttpDownloader; import org.fdroid.fdroid.qr.CameraCharacteristicsChecker; import org.fdroid.fdroid.qr.QrGenAsyncTask; import org.fdroid.fdroid.views.main.MainActivity; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.Timer; import java.util.TimerTask; import static org.fdroid.fdroid.views.main.MainActivity.ACTION_REQUEST_SWAP; /** * This activity will do its best to show the most relevant screen about swapping to the user. * The problem comes when there are two competing goals - 1) Show the user a list of apps from another * device to download and install, and 2) Prepare your own list of apps to share. */ @SuppressWarnings("LineLength") public class SwapWorkflowActivity extends AppCompatActivity { private static final String TAG = "SwapWorkflowActivity"; /** * When connecting to a swap, we then go and initiate a connection with that * device and ask if it would like to swap with us. Upon receiving that request * and agreeing, we don't then want to be asked whether we want to swap back. * This flag protects against two devices continually going back and forth * among each other offering swaps. */ public static final String EXTRA_PREVENT_FURTHER_SWAP_REQUESTS = "preventFurtherSwap"; private ViewGroup container; private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SWAP = 2; private static final int REQUEST_BLUETOOTH_DISCOVERABLE = 3; private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SEND = 4; private static final int REQUEST_WRITE_SETTINGS_PERMISSION = 5; private static final int STEP_INTRO = 1; // TODO remove this special case, only use layoutResIds private Toolbar toolbar; private SwapView currentView; private boolean hasPreparedLocalRepo; private boolean newIntent; private NewRepoConfig confirmSwapConfig; private LocalBroadcastManager localBroadcastManager; private WifiManager wifiManager; private WifiApControl wifiApControl; private BluetoothAdapter bluetoothAdapter; @LayoutRes private int currentSwapViewLayoutRes = STEP_INTRO; public static void requestSwap(Context context, String repo) { requestSwap(context, Uri.parse(repo)); } public static void requestSwap(Context context, Uri uri) { Intent intent = new Intent(MainActivity.ACTION_REQUEST_SWAP, uri, context, SwapWorkflowActivity.class); intent.putExtra(EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } @NonNull private final ServiceConnection serviceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName className, IBinder binder) { service = ((SwapService.Binder) binder).getService(); showRelevantView(); } @Override public void onServiceDisconnected(ComponentName className) { finish(); service = null; } }; @Nullable private SwapService service; @NonNull public SwapService getSwapService() { return service; } @Override public void onBackPressed() { if (currentView.getLayoutResId() == STEP_INTRO) { SwapService.stop(this); finish(); } else { // TODO: Currently StartSwapView is handleed by the SwapWorkflowActivity as a special case, where // if getLayoutResId is STEP_INTRO, don't even bother asking for getPreviousStep. But that is a // bit messy. It would be nicer if this was handled using the same mechanism as everything // else. int nextStep = -1; switch (currentView.getLayoutResId()) { case R.layout.swap_confirm_receive: nextStep = STEP_INTRO; break; case R.layout.swap_connecting: nextStep = R.layout.swap_select_apps; break; case R.layout.swap_join_wifi: nextStep = STEP_INTRO; break; case R.layout.swap_nfc: nextStep = R.layout.swap_join_wifi; break; case R.layout.swap_select_apps: nextStep = getSwapService().isConnectingWithPeer() ? STEP_INTRO : R.layout.swap_join_wifi; break; case R.layout.swap_send_fdroid: nextStep = STEP_INTRO; break; case R.layout.swap_start_swap: nextStep = STEP_INTRO; break; case R.layout.swap_success: nextStep = STEP_INTRO; break; case R.layout.swap_wifi_qr: nextStep = R.layout.swap_join_wifi; break; } currentSwapViewLayoutRes = nextStep; showRelevantView(); } } @Override protected void onCreate(Bundle savedInstanceState) { ((FDroidApp) getApplication()).setSecureWindow(this); super.onCreate(savedInstanceState); currentView = new SwapView(this); // dummy placeholder to avoid NullPointerExceptions; if (!bindService(new Intent(this, SwapService.class), serviceConnection, BIND_ABOVE_CLIENT | BIND_IMPORTANT)) { Toast.makeText(this, "ERROR: cannot bind to SwapService!", Toast.LENGTH_LONG).show(); finish(); } setContentView(R.layout.swap_activity); toolbar = (Toolbar) findViewById(R.id.toolbar); toolbar.setTitleTextAppearance(getApplicationContext(), R.style.SwapTheme_Wizard_Text_Toolbar); setSupportActionBar(toolbar); container = (ViewGroup) findViewById(R.id.container); localBroadcastManager = LocalBroadcastManager.getInstance(this); localBroadcastManager.registerReceiver(downloaderInterruptedReceiver, new IntentFilter(Downloader.ACTION_INTERRUPTED)); wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); wifiApControl = WifiApControl.getInstance(this); bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); new SwapDebug().logStatus(); } @Override protected void onDestroy() { localBroadcastManager.unregisterReceiver(downloaderInterruptedReceiver); unbindService(serviceConnection); super.onDestroy(); } @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.clear(); MenuInflater menuInflater = getMenuInflater(); switch (currentView.getLayoutResId()) { case R.layout.swap_select_apps: menuInflater.inflate(R.menu.swap_next_search, menu); setUpNextButton(menu, R.string.next); setUpSearchView(menu); return true; case R.layout.swap_success: menuInflater.inflate(R.menu.swap_search, menu); setUpSearchView(menu); return true; case R.layout.swap_join_wifi: menuInflater.inflate(R.menu.swap_next, menu); setUpNextButton(menu, R.string.next); return true; case R.layout.swap_nfc: menuInflater.inflate(R.menu.swap_next, menu); setUpNextButton(menu, R.string.skip); return true; } return super.onPrepareOptionsMenu(menu); } private void setUpNextButton(Menu menu, @StringRes int titleResId) { MenuItem next = menu.findItem(R.id.action_next); CharSequence title = getString(titleResId); next.setTitle(title); next.setTitleCondensed(title); MenuItemCompat.setShowAsAction(next, MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); next.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem item) { sendNext(); return true; } }); } void sendNext() { int currentLayoutResId = currentView.getLayoutResId(); switch (currentLayoutResId) { case R.layout.swap_select_apps: onAppsSelected(); break; case R.layout.swap_join_wifi: inflateSwapView(R.layout.swap_select_apps); break; case R.layout.swap_nfc: inflateSwapView(R.layout.swap_wifi_qr); break; } } private void setUpSearchView(Menu menu) { SearchView searchView = new SearchView(this); MenuItem searchMenuItem = menu.findItem(R.id.action_search); MenuItemCompat.setActionView(searchMenuItem, searchView); MenuItemCompat.setShowAsAction(searchMenuItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String newText) { String currentFilterString = currentView.getCurrentFilterString(); String newFilter = !TextUtils.isEmpty(newText) ? newText : null; if (currentFilterString == null && newFilter == null) { return true; } if (currentFilterString != null && currentFilterString.equals(newFilter)) { return true; } currentView.setCurrentFilterString(newFilter); if (currentView instanceof SelectAppsView) { getSupportLoaderManager().restartLoader(currentView.getLayoutResId(), null, (SelectAppsView) currentView); } else if (currentView instanceof SwapSuccessView) { getSupportLoaderManager().restartLoader(currentView.getLayoutResId(), null, (SwapSuccessView) currentView); } else { throw new IllegalStateException(currentView.getClass() + " does not have Loader!"); } return true; } @Override public boolean onQueryTextChange(String s) { return true; } }); } @Override protected void onResume() { super.onResume(); localBroadcastManager.registerReceiver(onWifiStateChanged, new IntentFilter(WifiStateChangeService.BROADCAST)); localBroadcastManager.registerReceiver(localRepoStatus, new IntentFilter(LocalRepoService.ACTION_STATUS)); localBroadcastManager.registerReceiver(repoUpdateReceiver, new IntentFilter(UpdateService.LOCAL_ACTION_STATUS)); localBroadcastManager.registerReceiver(bonjourFound, new IntentFilter(BonjourManager.ACTION_FOUND)); localBroadcastManager.registerReceiver(bonjourRemoved, new IntentFilter(BonjourManager.ACTION_REMOVED)); localBroadcastManager.registerReceiver(bonjourStatus, new IntentFilter(BonjourManager.ACTION_STATUS)); localBroadcastManager.registerReceiver(bluetoothFound, new IntentFilter(BluetoothManager.ACTION_FOUND)); localBroadcastManager.registerReceiver(bluetoothStatus, new IntentFilter(BluetoothManager.ACTION_STATUS)); registerReceiver(bluetoothScanModeChanged, new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)); checkIncomingIntent(); if (newIntent) { showRelevantView(); newIntent = false; } } @Override protected void onPause() { super.onPause(); unregisterReceiver(bluetoothScanModeChanged); localBroadcastManager.unregisterReceiver(onWifiStateChanged); localBroadcastManager.unregisterReceiver(localRepoStatus); localBroadcastManager.unregisterReceiver(repoUpdateReceiver); localBroadcastManager.unregisterReceiver(bonjourFound); localBroadcastManager.unregisterReceiver(bonjourRemoved); localBroadcastManager.unregisterReceiver(bonjourStatus); localBroadcastManager.unregisterReceiver(bluetoothFound); localBroadcastManager.unregisterReceiver(bluetoothStatus); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); newIntent = true; } /** * Check whether incoming {@link Intent} is a swap repo, and ensure that * it is a valid swap URL. The hostname can only be either an IP or * Bluetooth address. */ private void checkIncomingIntent() { Intent intent = getIntent(); if (!ACTION_REQUEST_SWAP.equals(intent.getAction())) { return; } Uri uri = intent.getData(); if (uri != null && !HttpDownloader.isSwapUrl(uri) && !BluetoothDownloader.isBluetoothUri(uri)) { String msg = getString(R.string.swap_toast_invalid_url, uri); Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); return; } confirmSwapConfig = new NewRepoConfig(this, intent); } public void promptToSelectWifiNetwork() { new AlertDialog.Builder(this) .setTitle(R.string.swap_join_same_wifi) .setMessage(R.string.swap_join_same_wifi_desc) .setNeutralButton(R.string.cancel, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { // Do nothing } }) .setPositiveButton(R.string.wifi, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { SwapService.putWifiEnabledBeforeSwap(wifiManager.isWifiEnabled()); wifiManager.setWifiEnabled(true); Intent intent = new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); } }) .setNegativeButton(R.string.wifi_ap, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (Build.VERSION.SDK_INT >= 26) { showTetheringSettings(); } else if (Build.VERSION.SDK_INT >= 23 && !Settings.System.canWrite(getBaseContext())) { requestWriteSettingsPermission(); } else { setupWifiAP(); } } }) .create().show(); } private void setupWifiAP() { if (wifiApControl == null) { Log.e(TAG, "WiFi AP is null"); Toast.makeText(this, R.string.swap_toast_could_not_enable_hotspot, Toast.LENGTH_LONG).show(); return; } SwapService.putHotspotEnabledBeforeSwap(wifiApControl.isEnabled()); wifiManager.setWifiEnabled(false); if (wifiApControl.enable()) { Toast.makeText(this, R.string.swap_toast_hotspot_enabled, Toast.LENGTH_SHORT).show(); SwapService.putHotspotActivatedUserPreference(true); } else { Toast.makeText(this, R.string.swap_toast_could_not_enable_hotspot, Toast.LENGTH_LONG).show(); SwapService.putHotspotActivatedUserPreference(false); Log.e(TAG, "Could not enable WiFi AP."); } } private void showRelevantView() { if (confirmSwapConfig != null) { inflateSwapView(R.layout.swap_confirm_receive); setUpConfirmReceive(); confirmSwapConfig = null; return; } switch (currentSwapViewLayoutRes) { case STEP_INTRO: showIntro(); return; case R.layout.swap_nfc: if (!attemptToShowNfc()) { inflateSwapView(R.layout.swap_wifi_qr); return; } break; case R.layout.swap_connecting: // TODO: Properly decide what to do here (i.e. returning to the activity after it was connecting)... inflateSwapView(R.layout.swap_start_swap); return; } inflateSwapView(currentSwapViewLayoutRes); } public void inflateSwapView(@LayoutRes int viewRes) { getSwapService().initTimer(); container.removeAllViews(); View view = ((LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(viewRes, container, false); currentView = (SwapView) view; currentView.setLayoutResId(viewRes); currentSwapViewLayoutRes = viewRes; toolbar.setBackgroundColor(currentView.getToolbarColour()); toolbar.setTitle(currentView.getToolbarTitle()); toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp); toolbar.setNavigationOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { onToolbarCancel(); } }); container.addView(view); supportInvalidateOptionsMenu(); switch (currentView.getLayoutResId()) { case R.layout.swap_send_fdroid: setUpFromWifi(); setUpUseBluetoothButton(); break; case R.layout.swap_wifi_qr: setUpFromWifi(); setUpQrScannerButton(); break; case R.layout.swap_nfc: setUpNfcView(); break; case R.layout.swap_select_apps: LocalRepoService.create(this, getSwapService().getAppsToSwap()); break; case R.layout.swap_connecting: setUpConnectingView(); break; case R.layout.swap_start_swap: setUpStartVisibility(); break; } } private void onToolbarCancel() { SwapService.stop(this); finish(); } public void showIntro() { // If we were previously swapping with a specific client, forget that we were doing that, // as we are starting over now. getSwapService().swapWith(null); LocalRepoService.create(this); inflateSwapView(R.layout.swap_start_swap); } /** * On {@code android-26}, only apps with privileges can access * {@code WRITE_SETTINGS}. So this just shows the tethering settings * for the user to do it themselves. */ public void showTetheringSettings() { final Intent intent = new Intent(Intent.ACTION_MAIN, null); intent.addCategory(Intent.CATEGORY_LAUNCHER); final ComponentName cn = new ComponentName("com.android.settings", "com.android.settings.TetherSettings"); intent.setComponent(cn); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); } @TargetApi(23) public void requestWriteSettingsPermission() { Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:" + getPackageName())); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivityForResult(intent, REQUEST_WRITE_SETTINGS_PERMISSION); } public void sendFDroid() { if (bluetoothAdapter == null || Build.VERSION.SDK_INT >= 23 // TODO make Bluetooth work with content:// URIs || (!bluetoothAdapter.isEnabled() && LocalHTTPDManager.isAlive())) { inflateSwapView(R.layout.swap_send_fdroid); } else { sendFDroidBluetooth(); } } /** * Send the F-Droid APK via Bluetooth. If Bluetooth has not been * enabled/turned on, then enabling device discoverability will * automatically enable Bluetooth. */ public void sendFDroidBluetooth() { if (bluetoothAdapter.isEnabled()) { sendFDroidApk(); } else { Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 120); startActivityForResult(discoverBt, REQUEST_BLUETOOTH_ENABLE_FOR_SEND); } } private void sendFDroidApk() { ((FDroidApp) getApplication()).sendViaBluetooth(this, Activity.RESULT_OK, BuildConfig.APPLICATION_ID); } /** * TODO: Figure out whether they have changed since last time LocalRepoService * was run. If the local repo is running, then we can ask it what apps it is * swapping and compare with that. Otherwise, probably will need to scan the * file system. */ public void onAppsSelected() { if (hasPreparedLocalRepo) { onLocalRepoPrepared(); } else { LocalRepoService.create(this, getSwapService().getAppsToSwap()); currentSwapViewLayoutRes = R.layout.swap_connecting; inflateSwapView(R.layout.swap_connecting); } } /** * Once the LocalRepoService has finished preparing our repository index, we can * show the next screen to the user. This will be one of two things: * <ol> * <li>If we directly selected a peer to swap with initially, we will skip straight to getting * the list of apps from that device.</li> * <li>Alternatively, if we didn't have a person to connect to, and instead clicked "Scan QR Code", * then we want to show a QR code or NFC dialog.</li> * </ol> */ public void onLocalRepoPrepared() { // TODO ditch this, use a message from LocalRepoService. Maybe? hasPreparedLocalRepo = true; if (getSwapService().isConnectingWithPeer()) { startSwappingWithPeer(); } else if (!attemptToShowNfc()) { inflateSwapView(R.layout.swap_wifi_qr); } } private void startSwappingWithPeer() { getSwapService().connectToPeer(); inflateSwapView(R.layout.swap_connecting); } private boolean attemptToShowNfc() { // TODO: What if NFC is disabled? Hook up with NfcNotEnabledActivity? Or maybe only if they // click a relevant button? // Even if they opted to skip the message which says "Touch devices to swap", // we still want to actually enable the feature, so that they could touch // during the wifi qr code being shown too. boolean nfcMessageReady = NfcHelper.setPushMessage(this, Utils.getSharingUri(FDroidApp.repo)); // TODO move all swap-specific preferences to a SharedPreferences instance for SwapWorkflowActivity if (Preferences.get().showNfcDuringSwap() && nfcMessageReady) { inflateSwapView(R.layout.swap_nfc); return true; } return false; } public void swapWith(Peer peer) { getSwapService().swapWith(peer); inflateSwapView(R.layout.swap_select_apps); } /** * This is for when we initiate a swap by viewing the "Are you sure you want to swap with" view * This can arise either: * * As a result of scanning a QR code (in which case we likely already have a repo setup) or * * As a result of the other device selecting our device in the "start swap" screen, in which * case we are likely just sitting on the start swap screen also, and haven't configured * anything yet. */ public void swapWith(NewRepoConfig repoConfig) { Peer peer = repoConfig.toPeer(); if (currentSwapViewLayoutRes == STEP_INTRO || currentSwapViewLayoutRes == R.layout.swap_confirm_receive) { // This will force the "Select apps to swap" workflow to begin. // TODO: Find a better way to decide whether we need to select the apps. Not sure if we // can or cannot be in STEP_INTRO with a full blown repo ready to swap. swapWith(peer); } else { getSwapService().swapWith(peer); startSwappingWithPeer(); } } public void denySwap() { showIntro(); } /** * Attempts to open a QR code scanner, in the hope a user will then scan the QR code of another * device configured to swapp apps with us. Delegates to the zxing library to do so. */ public void initiateQrScan() { IntentIntegrator integrator = new IntentIntegrator(this); integrator.initiateScan(); } @Override public void onActivityResult(int requestCode, int resultCode, Intent intent) { IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); if (scanResult != null) { if (scanResult.getContents() != null) { NewRepoConfig repoConfig = new NewRepoConfig(this, scanResult.getContents()); if (repoConfig.isValidRepo()) { confirmSwapConfig = repoConfig; showRelevantView(); } else { Toast.makeText(this, R.string.swap_qr_isnt_for_swap, Toast.LENGTH_SHORT).show(); } } } else if (requestCode == REQUEST_WRITE_SETTINGS_PERMISSION) { if (Build.VERSION.SDK_INT >= 23 && Settings.System.canWrite(this)) { setupWifiAP(); } } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SWAP) { if (resultCode == RESULT_OK) { Utils.debugLog(TAG, "User enabled Bluetooth, will make sure we are discoverable."); ensureBluetoothDiscoverableThenStart(); } else { Utils.debugLog(TAG, "User chose not to enable Bluetooth, so doing nothing"); SwapService.putBluetoothVisibleUserPreference(false); } } else if (requestCode == REQUEST_BLUETOOTH_DISCOVERABLE) { if (resultCode != RESULT_CANCELED) { Utils.debugLog(TAG, "User made Bluetooth discoverable, will proceed to start bluetooth server."); BluetoothManager.start(this); } else { Utils.debugLog(TAG, "User chose not to make Bluetooth discoverable, so doing nothing"); SwapService.putBluetoothVisibleUserPreference(false); } } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SEND) { sendFDroidApk(); } } /** * The process for setting up bluetooth is as follows: * <ul> * <li>Assume we have bluetooth available (otherwise the button which allowed us to start * the bluetooth process should not have been available)</li> * <li>Ask user to enable (if not enabled yet)</li> * <li>Start bluetooth server socket</li> * <li>Enable bluetooth discoverability, so that people can connect to our server socket.</li> * </ul> * Note that this is a little different than the usual process for bluetooth _clients_, which * involves pairing and connecting with other devices. */ public void startBluetoothSwap() { if (bluetoothAdapter != null) { if (bluetoothAdapter.isEnabled()) { Utils.debugLog(TAG, "Bluetooth enabled, will check if device is discoverable with device."); ensureBluetoothDiscoverableThenStart(); } else { Utils.debugLog(TAG, "Bluetooth disabled, asking user to enable it."); Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, REQUEST_BLUETOOTH_ENABLE_FOR_SWAP); } } } private void ensureBluetoothDiscoverableThenStart() { Utils.debugLog(TAG, "Ensuring Bluetooth is in discoverable mode."); if (bluetoothAdapter.getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) { Utils.debugLog(TAG, "Not currently in discoverable mode, so prompting user to enable."); Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 3600); // 1 hour startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE); } BluetoothManager.start(this); } private final BroadcastReceiver bluetoothScanModeChanged = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { SwitchCompat bluetoothSwitch = container.findViewById(R.id.switch_bluetooth); TextView textBluetoothVisible = container.findViewById(R.id.bluetooth_visible); if (bluetoothSwitch == null || textBluetoothVisible == null || !BluetoothManager.ACTION_STATUS.equals(intent.getAction())) { return; } switch (intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1)) { case BluetoothAdapter.SCAN_MODE_NONE: textBluetoothVisible.setText(R.string.disabled); bluetoothSwitch.setEnabled(true); break; case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE: textBluetoothVisible.setText(R.string.swap_visible_bluetooth); bluetoothSwitch.setEnabled(true); break; case BluetoothAdapter.SCAN_MODE_CONNECTABLE: textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth); bluetoothSwitch.setEnabled(true); break; } } }; /** * Helper class to try and make sense of what the swap workflow is currently doing. * The more technologies are involved in the process (e.g. Bluetooth/Wifi/NFC/etc) * the harder it becomes to reason about and debug the whole thing. Thus,this class * will periodically dump the state to logcat so that it is easier to see when certain * protocols are enabled/disabled. * <p> * To view only this output from logcat: * <p> * adb logcat | grep 'Swap Status' * <p> * To exclude this output from logcat (it is very noisy): * <p> * adb logcat | grep -v 'Swap Status' */ class SwapDebug { public void logStatus() { if (true) return; // NOPMD String message = ""; if (service == null) { message = "No swap service"; } else { String bluetooth; bluetooth = "N/A"; if (bluetoothAdapter != null) { Map<Integer, String> scanModes = new HashMap<>(3); scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE, "CON"); scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, "CON_DISC"); scanModes.put(BluetoothAdapter.SCAN_MODE_NONE, "NONE"); bluetooth = "\"" + bluetoothAdapter.getName() + "\" - " + scanModes.get(bluetoothAdapter.getScanMode()); } } Date now = new Date(); Utils.debugLog("SWAP_STATUS", now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds() + " " + message); new Timer().schedule(new TimerTask() { @Override public void run() { new SwapDebug().logStatus(); } }, 1000 ); } } private final BroadcastReceiver onWifiStateChanged = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { setUpFromWifi(); int wifiStatus = -1; TextView textWifiVisible = container.findViewById(R.id.wifi_visible); if (textWifiVisible != null) { intent.getIntExtra(WifiStateChangeService.EXTRA_STATUS, -1); } switch (wifiStatus) { case WifiManager.WIFI_STATE_ENABLING: textWifiVisible.setText(R.string.swap_setting_up_wifi); break; case WifiManager.WIFI_STATE_ENABLED: textWifiVisible.setText(R.string.swap_not_visible_wifi); break; case WifiManager.WIFI_STATE_DISABLING: case WifiManager.WIFI_STATE_DISABLED: textWifiVisible.setText(R.string.swap_stopping_wifi); break; case WifiManager.WIFI_STATE_UNKNOWN: break; } } }; private void setUpFromWifi() { String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https://" : "http://"; // the fingerprint is not useful on the button label String buttonLabel = scheme + FDroidApp.ipAddressString + ":" + FDroidApp.port; TextView ipAddressView = container.findViewById(R.id.device_ip_address); if (ipAddressView != null) { ipAddressView.setText(buttonLabel); } String qrUriString = null; switch (currentView.getLayoutResId()) { case R.layout.swap_join_wifi: setUpJoinWifi(); return; case R.layout.swap_send_fdroid: qrUriString = buttonLabel; break; case R.layout.swap_wifi_qr: Uri sharingUri = Utils.getSharingUri(FDroidApp.repo); StringBuilder qrUrlBuilder = new StringBuilder(scheme); qrUrlBuilder.append(sharingUri.getHost()); if (sharingUri.getPort() != 80) { qrUrlBuilder.append(':'); qrUrlBuilder.append(sharingUri.getPort()); } qrUrlBuilder.append(sharingUri.getPath()); boolean first = true; Set<String> names = sharingUri.getQueryParameterNames(); for (String name : names) { if (!"ssid".equals(name)) { if (first) { qrUrlBuilder.append('?'); first = false; } else { qrUrlBuilder.append('&'); } qrUrlBuilder.append(name.toUpperCase(Locale.ENGLISH)); qrUrlBuilder.append('='); qrUrlBuilder.append(sharingUri.getQueryParameter(name).toUpperCase(Locale.ENGLISH)); } } qrUriString = qrUrlBuilder.toString(); break; } ImageView qrImage = container.findViewById(R.id.wifi_qr_code); if (qrUriString != null && qrImage != null) { Utils.debugLog(TAG, "Encoded swap URI in QR Code: " + qrUriString); new QrGenAsyncTask(SwapWorkflowActivity.this, R.id.wifi_qr_code).execute(qrUriString); // Replace all blacks with the background blue. qrImage.setColorFilter(new LightingColorFilter(0xffffffff, getResources().getColor(R.color.swap_blue))); final View qrWarningMessage = container.findViewById(R.id.warning_qr_scanner); if (CameraCharacteristicsChecker.getInstance(this).hasAutofocus()) { qrWarningMessage.setVisibility(View.GONE); } else { qrWarningMessage.setVisibility(View.VISIBLE); } } } // TODO: Listen for "Connecting..." state and reflect that in the view too. private void setUpJoinWifi() { currentView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK)); } }); TextView descriptionView = container.findViewById(R.id.text_description); ImageView wifiIcon = container.findViewById(R.id.wifi_icon); TextView ssidView = container.findViewById(R.id.wifi_ssid); TextView tapView = container.findViewById(R.id.wifi_available_networks_prompt); if (TextUtils.isEmpty(FDroidApp.bssid) && !TextUtils.isEmpty(FDroidApp.ipAddressString)) { // empty bssid with an ipAddress means hotspot mode descriptionView.setText(R.string.swap_join_this_hotspot); wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.hotspot)); ssidView.setText(R.string.swap_active_hotspot); tapView.setText(R.string.swap_switch_to_wifi); } else if (TextUtils.isEmpty(FDroidApp.ssid)) { // not connected to or setup with any wifi network descriptionView.setText(R.string.swap_join_same_wifi); wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi)); ssidView.setText(R.string.swap_no_wifi_network); tapView.setText(R.string.swap_view_available_networks); } else { // connected to a regular wifi network descriptionView.setText(R.string.swap_join_same_wifi); wifiIcon.setImageDrawable(getResources().getDrawable(R.drawable.wifi)); ssidView.setText(FDroidApp.ssid); tapView.setText(R.string.swap_view_available_networks); } } private void setUpStartVisibility() { TextView viewWifiNetwork = findViewById(R.id.wifi_network); viewWifiNetwork.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { promptToSelectWifiNetwork(); } }); SwitchCompat wifiSwitch = findViewById(R.id.switch_wifi); wifiSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { Context context = getApplicationContext(); if (isChecked) { if (wifiApControl != null && wifiApControl.isEnabled()) { setupWifiAP(); } else { wifiManager.setWifiEnabled(true); } BonjourManager.start(context); } BonjourManager.setVisible(context, isChecked); SwapService.putWifiVisibleUserPreference(isChecked); } }); findViewById(R.id.btn_scan_qr).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { inflateSwapView(R.layout.swap_wifi_qr); } }); if (SwapService.getWifiVisibleUserPreference()) { wifiSwitch.setChecked(true); } else { wifiSwitch.setChecked(false); } } private final BroadcastReceiver bonjourStatus = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { TextView textWifiVisible = container.findViewById(R.id.wifi_visible); TextView peopleNearbyText = container.findViewById(R.id.text_people_nearby); ProgressBar peopleNearbyProgress = container.findViewById(R.id.searching_people_nearby); if (textWifiVisible == null || peopleNearbyText == null || peopleNearbyProgress == null || !BonjourManager.ACTION_STATUS.equals(intent.getAction())) { return; } int status = intent.getIntExtra(BonjourManager.EXTRA_STATUS, -1); Log.i(TAG, "BonjourManager.EXTRA_STATUS: " + status); switch (status) { case BonjourManager.STATUS_STARTING: textWifiVisible.setText(R.string.swap_setting_up_wifi); peopleNearbyText.setText(R.string.swap_starting); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BonjourManager.STATUS_STARTED: textWifiVisible.setText(R.string.swap_not_visible_wifi); peopleNearbyText.setText(R.string.swap_scanning_for_peers); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BonjourManager.STATUS_NOT_VISIBLE: textWifiVisible.setText(R.string.swap_not_visible_wifi); peopleNearbyText.setText(R.string.swap_scanning_for_peers); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BonjourManager.STATUS_VISIBLE: if (wifiApControl != null && wifiApControl.isEnabled()) { textWifiVisible.setText(R.string.swap_visible_hotspot); } else { textWifiVisible.setText(R.string.swap_visible_wifi); } peopleNearbyText.setText(R.string.swap_scanning_for_peers); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BonjourManager.STATUS_STOPPING: textWifiVisible.setText(R.string.swap_stopping_wifi); if (!BluetoothManager.isAlive()) { peopleNearbyText.setText(R.string.swap_stopping); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); } break; case BonjourManager.STATUS_STOPPED: textWifiVisible.setText(R.string.swap_not_visible_wifi); if (!BluetoothManager.isAlive()) { peopleNearbyText.setVisibility(View.GONE); peopleNearbyProgress.setVisibility(View.GONE); } break; case BonjourManager.STATUS_ERROR: textWifiVisible.setText(R.string.swap_not_visible_wifi); peopleNearbyText.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.GONE); default: throw new IllegalArgumentException("Bad intent: " + intent); } } }; private final BroadcastReceiver bonjourFound = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); if (peopleNearbyList != null) { ArrayAdapter<Peer> peopleNearbyAdapter = (ArrayAdapter<Peer>) peopleNearbyList.getAdapter(); peopleNearbyAdapter.add((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); } } }; private final BroadcastReceiver bonjourRemoved = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); if (peopleNearbyList != null) { ArrayAdapter<Peer> peopleNearbyAdapter = (ArrayAdapter<Peer>) peopleNearbyList.getAdapter(); peopleNearbyAdapter.remove((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); } } }; private final BroadcastReceiver bluetoothStatus = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { SwitchCompat bluetoothSwitch = container.findViewById(R.id.switch_bluetooth); TextView textBluetoothVisible = container.findViewById(R.id.bluetooth_visible); TextView textDeviceIdBluetooth = container.findViewById(R.id.device_id_bluetooth); TextView peopleNearbyText = container.findViewById(R.id.text_people_nearby); ProgressBar peopleNearbyProgress = container.findViewById(R.id.searching_people_nearby); if (bluetoothSwitch == null || textBluetoothVisible == null || textDeviceIdBluetooth == null || peopleNearbyText == null || peopleNearbyProgress == null || !BluetoothManager.ACTION_STATUS.equals(intent.getAction())) { return; } int status = intent.getIntExtra(BluetoothManager.EXTRA_STATUS, -1); Log.i(TAG, "BluetoothManager.EXTRA_STATUS: " + status); switch (status) { case BluetoothManager.STATUS_STARTING: bluetoothSwitch.setEnabled(false); textBluetoothVisible.setText(R.string.swap_setting_up_bluetooth); textDeviceIdBluetooth.setVisibility(View.VISIBLE); peopleNearbyText.setText(R.string.swap_scanning_for_peers); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BluetoothManager.STATUS_STARTED: bluetoothSwitch.setEnabled(true); textBluetoothVisible.setText(R.string.swap_visible_bluetooth); textDeviceIdBluetooth.setVisibility(View.VISIBLE); peopleNearbyText.setText(R.string.swap_scanning_for_peers); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); break; case BluetoothManager.STATUS_STOPPING: bluetoothSwitch.setEnabled(false); textBluetoothVisible.setText(R.string.swap_stopping); textDeviceIdBluetooth.setVisibility(View.GONE); if (!BonjourManager.isAlive()) { peopleNearbyText.setText(R.string.swap_stopping); peopleNearbyText.setVisibility(View.VISIBLE); peopleNearbyProgress.setVisibility(View.VISIBLE); } break; case BluetoothManager.STATUS_STOPPED: bluetoothSwitch.setEnabled(true); textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth); textDeviceIdBluetooth.setVisibility(View.GONE); if (!BonjourManager.isAlive()) { peopleNearbyText.setVisibility(View.GONE); peopleNearbyProgress.setVisibility(View.GONE); } ListView peopleNearbyView = container.findViewById(R.id.list_people_nearby); if (peopleNearbyView == null) { break; } ArrayAdapter peopleNearbyAdapter = (ArrayAdapter) peopleNearbyView.getAdapter(); for (int i = 0; i < peopleNearbyAdapter.getCount(); i++) { Peer peer = (Peer) peopleNearbyAdapter.getItem(i); if (peer.getClass().equals(BluetoothPeer.class)) { Utils.debugLog(TAG, "Removing bluetooth peer: " + peer.getName()); peopleNearbyAdapter.remove(peer); } } break; case BluetoothManager.STATUS_ERROR: bluetoothSwitch.setEnabled(true); textBluetoothVisible.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); textDeviceIdBluetooth.setVisibility(View.VISIBLE); break; default: throw new IllegalArgumentException("Bad intent: " + intent); } } }; private final BroadcastReceiver bluetoothFound = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); if (peopleNearbyList != null) { ArrayAdapter<Peer> peopleNearbyAdapter = (ArrayAdapter<Peer>) peopleNearbyList.getAdapter(); peopleNearbyAdapter.add((Peer) intent.getParcelableExtra(BluetoothManager.EXTRA_PEER)); } } }; private void setUpUseBluetoothButton() { Button useBluetooth = findViewById(R.id.btn_use_bluetooth); if (useBluetooth != null) { useBluetooth.setOnClickListener(new Button.OnClickListener() { @Override public void onClick(View v) { showIntro(); sendFDroidBluetooth(); } }); } } private void setUpQrScannerButton() { Button openQr = findViewById(R.id.btn_qr_scanner); if (openQr != null) { openQr.setOnClickListener(new Button.OnClickListener() { @Override public void onClick(View v) { initiateQrScan(); } }); } } private void setUpConfirmReceive() { TextView descriptionTextView = findViewById(R.id.text_description); if (descriptionTextView != null) { descriptionTextView.setText(getString(R.string.swap_confirm_connect, confirmSwapConfig.getHost())); } Button confirmReceiveYes = container.findViewById(R.id.confirm_receive_yes); if (confirmReceiveYes != null) { findViewById(R.id.confirm_receive_yes).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { denySwap(); } }); } Button confirmReceiveNo = container.findViewById(R.id.confirm_receive_no); if (confirmReceiveNo != null) { findViewById(R.id.confirm_receive_no).setOnClickListener(new View.OnClickListener() { private final NewRepoConfig config = confirmSwapConfig; @Override public void onClick(View v) { swapWith(config); } }); } } private void setUpNfcView() { CheckBox dontShowAgain = container.findViewById(R.id.checkbox_dont_show); dontShowAgain.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { Preferences.get().setShowNfcDuringSwap(!isChecked); } }); } private void setUpConnectingProgressText(String message) { TextView progressText = container.findViewById(R.id.progress_text); if (progressText != null && message != null) { progressText.setVisibility(View.VISIBLE); progressText.setText(message); } } /** * Listens for feedback about a local repository being prepared, like APK * files copied to the LocalHTTPD webroot, the {@code index.html} generated, * etc. Icons will be copied to the webroot in the background and so are * not part of this process. */ private final BroadcastReceiver localRepoStatus = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { setUpConnectingProgressText(intent.getStringExtra(Intent.EXTRA_TEXT)); ProgressBar progressBar = container.findViewById(R.id.progress_bar); Button tryAgainButton = container.findViewById(R.id.try_again); if (progressBar == null || tryAgainButton == null) { Utils.debugLog(TAG, "prepareSwapReceiver received intent without view: " + intent); return; } switch (intent.getIntExtra(LocalRepoService.EXTRA_STATUS, -1)) { case LocalRepoService.STATUS_PROGRESS: progressBar.setVisibility(View.VISIBLE); tryAgainButton.setVisibility(View.GONE); break; case LocalRepoService.STATUS_STARTED: progressBar.setVisibility(View.VISIBLE); tryAgainButton.setVisibility(View.GONE); onLocalRepoPrepared(); break; case LocalRepoService.STATUS_ERROR: progressBar.setVisibility(View.GONE); tryAgainButton.setVisibility(View.VISIBLE); break; default: throw new IllegalArgumentException("Bogus intent: " + intent); } } }; /** * Listens for feedback about a repo update process taking place. * Tracks an index.jar download and show the progress messages */ private final BroadcastReceiver repoUpdateReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String message = intent.getStringExtra(UpdateService.EXTRA_MESSAGE); if (message == null) { CharSequence[] repoErrors = intent.getCharSequenceArrayExtra(UpdateService.EXTRA_REPO_ERRORS); if (repoErrors != null) { StringBuilder msgBuilder = new StringBuilder(); for (CharSequence error : repoErrors) { if (msgBuilder.length() > 0) { msgBuilder.append(" + "); } msgBuilder.append(error); } message = msgBuilder.toString(); } } setUpConnectingProgressText(message); ProgressBar progressBar = container.findViewById(R.id.progress_bar); Button tryAgainButton = container.findViewById(R.id.try_again); if (progressBar == null || tryAgainButton == null) { Utils.debugLog(TAG, "repoUpdateReceiver received intent without view: " + intent); return; } int status = intent.getIntExtra(UpdateService.EXTRA_STATUS_CODE, -1); if (status == UpdateService.STATUS_ERROR_GLOBAL || status == UpdateService.STATUS_ERROR_LOCAL || status == UpdateService.STATUS_ERROR_LOCAL_SMALL) { progressBar.setVisibility(View.GONE); tryAgainButton.setVisibility(View.VISIBLE); getSwapService().removeCurrentPeerFromActive(); return; } else { progressBar.setVisibility(View.VISIBLE); tryAgainButton.setVisibility(View.GONE); getSwapService().addCurrentPeerToActive(); } if (status == UpdateService.STATUS_COMPLETE_AND_SAME || status == UpdateService.STATUS_COMPLETE_WITH_CHANGES) { inflateSwapView(R.layout.swap_success); } } }; private final BroadcastReceiver downloaderInterruptedReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { Repo repo = RepoProvider.Helper.findByUrl(context, intent.getData(), null); if (repo != null && repo.isSwap) { setUpConnectingProgressText(intent.getStringExtra(Downloader.EXTRA_ERROR_MESSAGE)); } } }; private void setUpConnectingView() { TextView heading = container.findViewById(R.id.progress_text); heading.setText(R.string.swap_connecting); container.findViewById(R.id.try_again).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { onAppsSelected(); } }); } }