Statistics
| Branch: | Tag: | Revision:

fdroidclient / F-Droid / src / org / fdroid / fdroid / views / swap / SwapWorkflowActivity.java @ 9848816d

History | View | Annotate | Download (32.1 KB)

1
package org.fdroid.fdroid.views.swap;
2

    
3
import android.app.Activity;
4
import android.content.ComponentName;
5
import android.bluetooth.BluetoothAdapter;
6
import android.content.Context;
7
import android.content.DialogInterface;
8
import android.content.Intent;
9
import android.content.ServiceConnection;
10
import android.net.Uri;
11
import android.net.wifi.WifiManager;
12
import android.os.AsyncTask;
13
import android.os.Bundle;
14
import android.os.IBinder;
15
import android.support.annotation.ColorRes;
16
import android.support.annotation.LayoutRes;
17
import android.support.annotation.NonNull;
18
import android.support.annotation.Nullable;
19
import android.support.v4.content.LocalBroadcastManager;
20
import android.support.v7.app.AlertDialog;
21
import android.support.v7.app.AppCompatActivity;
22
import android.support.v7.widget.Toolbar;
23
import android.util.Log;
24
import android.view.LayoutInflater;
25
import android.view.Menu;
26
import android.view.MenuInflater;
27
import android.view.View;
28
import android.view.ViewGroup;
29
import android.widget.Toast;
30

    
31
import com.google.zxing.integration.android.IntentIntegrator;
32
import com.google.zxing.integration.android.IntentResult;
33

    
34
import org.fdroid.fdroid.FDroidApp;
35
import org.fdroid.fdroid.NfcHelper;
36
import org.fdroid.fdroid.Preferences;
37
import org.fdroid.fdroid.ProgressListener;
38
import org.fdroid.fdroid.R;
39
import org.fdroid.fdroid.Utils;
40
import org.fdroid.fdroid.data.Apk;
41
import org.fdroid.fdroid.data.ApkProvider;
42
import org.fdroid.fdroid.data.App;
43
import org.fdroid.fdroid.data.NewRepoConfig;
44
import org.fdroid.fdroid.installer.Installer;
45
import org.fdroid.fdroid.localrepo.LocalRepoManager;
46
import org.fdroid.fdroid.localrepo.SwapService;
47
import org.fdroid.fdroid.localrepo.peers.Peer;
48
import org.fdroid.fdroid.net.ApkDownloader;
49

    
50
import java.io.File;
51
import java.util.Arrays;
52
import java.util.Date;
53
import java.util.HashMap;
54
import java.util.HashSet;
55
import java.util.Map;
56
import java.util.Set;
57
import java.util.Timer;
58
import java.util.TimerTask;
59

    
60
import cc.mvdan.accesspoint.WifiApControl;
61

    
62
/**
63
 * This activity will do its best to show the most relevant screen about swapping to the user.
64
 * The problem comes when there are two competing goals - 1) Show the user a list of apps from another
65
 * device to download and install, and 2) Prepare your own list of apps to share.
66
 */
67
public class SwapWorkflowActivity extends AppCompatActivity {
68

    
69
    /**
70
     * When connecting to a swap, we then go and initiate a connection with that
71
     * device and ask if it would like to swap with us. Upon receiving that request
72
     * and agreeing, we don't then want to be asked whether we want to swap back.
73
     * This flag protects against two devices continually going back and forth
74
     * among each other offering swaps.
75
     */
76
    public static final String EXTRA_PREVENT_FURTHER_SWAP_REQUESTS = "preventFurtherSwap";
77
    public static final String EXTRA_CONFIRM = "EXTRA_CONFIRM";
78
    public static final String EXTRA_REPO_ID = "repoId";
79

    
80
    /**
81
     * Ensure that we don't try to handle specific intents more than once in onResume()
82
     * (e.g. the "Do you want to swap back with ..." intent).
83
     */
84
    public static final String EXTRA_HANDLED = "handled";
85

    
86
    private ViewGroup container;
87

    
88
    /**
89
     * A UI component (subclass of {@link View}) which forms part of the swap workflow.
90
     * There is a one to one mapping between an {@link org.fdroid.fdroid.views.swap.SwapWorkflowActivity.InnerView}
91
     * and a {@link SwapService.SwapStep}, and these views know what
92
     * the previous view before them should be.
93
     */
94
    public interface InnerView {
95
        /** @return True if the menu should be shown. */
96
        boolean buildMenu(Menu menu, @NonNull MenuInflater inflater);
97

    
98
        /** @return The step that this view represents. */
99
        @SwapService.SwapStep int getStep();
100

    
101
        @SwapService.SwapStep int getPreviousStep();
102

    
103
        @ColorRes int getToolbarColour();
104

    
105
        String getToolbarTitle();
106
    }
107

    
108
    private static final String TAG = "SwapWorkflowActivity";
109

    
110
    private static final int CONNECT_TO_SWAP = 1;
111
    private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SWAP = 2;
112
    private static final int REQUEST_BLUETOOTH_DISCOVERABLE = 3;
113
    private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SEND = 4;
114

    
115
    private Toolbar toolbar;
116
    private InnerView currentView;
117
    private boolean hasPreparedLocalRepo = false;
118
    private PrepareSwapRepo updateSwappableAppsTask = null;
119
    private NewRepoConfig confirmSwapConfig = null;
120

    
121
    @NonNull
122
    private final ServiceConnection serviceConnection = new ServiceConnection() {
123
        @Override
124
        public void onServiceConnected(ComponentName className, IBinder binder) {
125
            Log.d(TAG, "Swap service connected. Will hold onto it so we can talk to it regularly.");
126
            service = ((SwapService.Binder)binder).getService();
127
            showRelevantView();
128
        }
129

    
130
        // TODO: What causes this? Do we need to stop swapping explicitly when this is invoked?
131
        @Override
132
        public void onServiceDisconnected(ComponentName className) {
133
            Log.d(TAG, "Swap service disconnected");
134
            service = null;
135
            // TODO: What to do about the UI in this instance?
136
        }
137
    };
138

    
139
    @Nullable
140
    private SwapService service = null;
141

    
142
    @NonNull
143
    public SwapService getService() {
144
        if (service == null) {
145
            // *Slightly* more informative than a null-pointer error that would otherwise happen.
146
            throw new IllegalStateException("Trying to access swap service before it was initialized.");
147
        }
148
        return service;
149
    }
150

    
151
    @Override
152
    public void onBackPressed() {
153
        if (currentView.getStep() == SwapService.STEP_INTRO) {
154
            if (service != null) {
155
                service.disableAllSwapping();
156
            }
157
            finish();
158
        } else {
159
            int nextStep = currentView.getPreviousStep();
160
            getService().setStep(nextStep);
161
            showRelevantView();
162
        }
163
    }
164

    
165
    @Override
166
    protected void onCreate(Bundle savedInstanceState) {
167
        super.onCreate(savedInstanceState);
168

    
169
        // The server should not be doing anything or occupying any (noticeable) resources
170
        // until we actually ask it to enable swapping. Therefore, we will start it nice and
171
        // early so we don't have to wait until it is connected later.
172
        Intent service = new Intent(this, SwapService.class);
173
        if (bindService(service, serviceConnection, Context.BIND_AUTO_CREATE)) {
174
            startService(service);
175
        }
176

    
177
        setContentView(R.layout.swap_activity);
178

    
179
        toolbar = (Toolbar) findViewById(R.id.toolbar);
180
        toolbar.setTitleTextAppearance(getApplicationContext(), R.style.SwapTheme_Wizard_Text_Toolbar);
181
        setSupportActionBar(toolbar);
182

    
183
        container = (ViewGroup) findViewById(R.id.fragment_container);
184

    
185
        new SwapDebug().logStatus();
186
    }
187

    
188
    @Override
189
    protected void onDestroy() {
190
        unbindService(serviceConnection);
191
        super.onDestroy();
192
    }
193

    
194
    @Override
195
    public boolean onPrepareOptionsMenu(Menu menu) {
196
        menu.clear();
197
        boolean parent = super.onPrepareOptionsMenu(menu);
198
        boolean inner  = currentView != null && currentView.buildMenu(menu, getMenuInflater());
199
        return parent || inner;
200
    }
201

    
202
    @Override
203
    protected void onResume() {
204
        super.onResume();
205

    
206
        checkIncomingIntent();
207
        showRelevantView();
208
    }
209

    
210
    private void checkIncomingIntent() {
211
        Intent intent = getIntent();
212
        if (intent.getBooleanExtra(EXTRA_CONFIRM, false) && !intent.getBooleanExtra(EXTRA_HANDLED, false)) {
213
            // Storing config in this variable will ensure that when showRelevantView() is next
214
            // run, it will show the connect swap view (if the service is available).
215
            intent.putExtra(EXTRA_HANDLED, true);
216
            confirmSwapConfig = new NewRepoConfig(this, intent);
217
        }
218
    }
219

    
220
    public void promptToSelectWifiNetwork() {
221
        //
222
        // On Android >= 5.0, the neutral button is the one by itself off to the left of a dialog
223
        // (not the negative button). Thus, the layout of this dialogs buttons should be:
224
        //
225
        // |                                 |
226
        // +---------------------------------+
227
        // | Cancel           Hotspot   WiFi |
228
        // +---------------------------------+
229
        //
230
        // TODO: Investigate if this should be set dynamically for earlier APIs.
231
        //
232
        new AlertDialog.Builder(this)
233
                .setTitle(R.string.swap_join_same_wifi)
234
                .setMessage(R.string.swap_join_same_wifi_desc)
235
                .setNeutralButton(R.string.cancel, new DialogInterface.OnClickListener() {
236
                            @Override
237
                            public void onClick(DialogInterface dialog, int which) {
238
                                // Do nothing
239
                            }
240
                        }
241
                ).setPositiveButton(R.string.wifi, new DialogInterface.OnClickListener() {
242
                        @Override
243
                        public void onClick(DialogInterface dialog, int which) {
244
                            startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK));
245
                        }
246
                    }
247
                ).setNegativeButton(R.string.wifi_ap, new DialogInterface.OnClickListener() {
248
                    @Override
249
                    public void onClick(DialogInterface dialog, int which) {
250
                        promptToSetupWifiAP();
251
                    }
252
                }
253
        ).create().show();
254
    }
255

    
256
    private void promptToSetupWifiAP() {
257
        WifiManager wifiManager = (WifiManager) getSystemService(Context.WIFI_SERVICE);
258
        WifiApControl ap = WifiApControl.getInstance(this);
259
        wifiManager.setWifiEnabled(false);
260
        if (!ap.enable()) {
261
            Log.e(TAG, "Could not enable WiFi AP.");
262
            // TODO: Feedback to user?
263
        } else {
264
            Log.d(TAG, "WiFi AP enabled.");
265
            // TODO: Seems to be broken some times...
266
        }
267
    }
268

    
269
    private void showRelevantView() {
270
        showRelevantView(false);
271
    }
272

    
273
    private void showRelevantView(boolean forceReload) {
274

    
275
        if (service == null) {
276
            showInitialLoading();
277
            return;
278
        }
279

    
280
        // This is separate from the switch statement below, because it is usually populated
281
        // during onResume, when there is a high probability of not having a swap service
282
        // available. Thus, we were unable to set the state of the swap service appropriately.
283
        if (confirmSwapConfig != null) {
284
            showConfirmSwap(confirmSwapConfig);
285
            confirmSwapConfig = null;
286
            return;
287
        }
288

    
289
        if (!forceReload) {
290
            if (container.getVisibility() == View.GONE || currentView != null && currentView.getStep() == service.getStep()) {
291
                // Already showing the correct step, so don't bother changing anything.
292
                return;
293
            }
294
        }
295

    
296
        switch(service.getStep()) {
297
            case SwapService.STEP_INTRO:
298
                showIntro();
299
                break;
300
            case SwapService.STEP_SELECT_APPS:
301
                showSelectApps();
302
                break;
303
            case SwapService.STEP_SHOW_NFC:
304
                showNfc();
305
                break;
306
            case SwapService.STEP_JOIN_WIFI:
307
                showJoinWifi();
308
                break;
309
            case SwapService.STEP_WIFI_QR:
310
                showWifiQr();
311
                break;
312
            case SwapService.STEP_SUCCESS:
313
                showSwapConnected();
314
                break;
315
            case SwapService.STEP_CONNECTING:
316
                // TODO: Properly decide what to do here (i.e. returning to the activity after it was connecting)...
317
                inflateInnerView(R.layout.swap_blank);
318
                break;
319
        }
320
    }
321

    
322
    public SwapService getState() {
323
        return service;
324
    }
325

    
326
    private void showNfc() {
327
        if (!attemptToShowNfc()) {
328
            showWifiQr();
329
        }
330
    }
331

    
332
    private InnerView inflateInnerView(@LayoutRes int viewRes) {
333
        container.removeAllViews();
334
        View view = ((LayoutInflater)getSystemService(LAYOUT_INFLATER_SERVICE)).inflate(viewRes, container, false);
335
        currentView = (InnerView)view;
336

    
337
        // Don't actually set the step to STEP_INITIAL_LOADING, as we are going to use this view
338
        // purely as a placeholder for _whatever view is meant to be shown_.
339
        if (currentView.getStep() != SwapService.STEP_INITIAL_LOADING) {
340
            if (service == null) {
341
                throw new IllegalStateException("We are not in the STEP_INITIAL_LOADING state, but the service is not ready.");
342
            }
343
            service.setStep(currentView.getStep());
344
        }
345

    
346
        toolbar.setBackgroundColor(getResources().getColor(currentView.getToolbarColour()));
347
        toolbar.setTitle(currentView.getToolbarTitle());
348
        toolbar.setNavigationIcon(R.drawable.ic_close_white);
349
        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
350
            @Override
351
            public void onClick(View v) {
352
                onToolbarCancel();
353
            }
354
        });
355
        container.addView(view);
356
        supportInvalidateOptionsMenu();
357

    
358
        return currentView;
359
    }
360

    
361
    private void onToolbarCancel() {
362
        getService().disableAllSwapping();
363
        finish();
364
    }
365

    
366
    private void showInitialLoading() {
367
        inflateInnerView(R.layout.swap_initial_loading);
368
    }
369

    
370
    public void showIntro() {
371
        // If we were previously swapping with a specific client, forget that we were doing that,
372
        // as we are starting over now.
373
        getService().swapWith(null);
374

    
375
        if (!getService().isEnabled()) {
376
            prepareInitialRepo();
377
        }
378
        getService().scanForPeers();
379
        inflateInnerView(R.layout.swap_blank);
380
    }
381

    
382
    private void showConfirmSwap(@NonNull NewRepoConfig config) {
383
        ((ConfirmReceive)inflateInnerView(R.layout.swap_confirm_receive)).setup(config);
384
    }
385

    
386
    public void startQrWorkflow() {
387
        if (!getService().isEnabled()) {
388
            new AlertDialog.Builder(this)
389
                    .setTitle(R.string.swap_not_enabled)
390
                    .setMessage(R.string.swap_not_enabled_description)
391
                    .setCancelable(true)
392
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
393
                        @Override
394
                        public void onClick(DialogInterface dialog, int which) {
395
                            // Do nothing. The dialog will get dismissed anyway, which is all we ever wanted...
396
                        }
397
                    })
398
                    .create().show();
399
        } else {
400
            showSelectApps();
401
        }
402
    }
403

    
404
    public void showSelectApps() {
405
        inflateInnerView(R.layout.swap_select_apps);
406
    }
407

    
408
    public void sendFDroid() {
409
        // If Bluetooth has not been enabled/turned on, then enabling device discoverability
410
        // will automatically enable Bluetooth.
411
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
412
        if (adapter != null) {
413
            if (adapter.getState() != BluetoothAdapter.STATE_ON) {
414
                Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
415
                discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 120);
416
                startActivityForResult(discoverBt, REQUEST_BLUETOOTH_ENABLE_FOR_SEND);
417
            } else {
418
                sendFDroidApk();
419
            }
420
        } else {
421
            new AlertDialog.Builder(this)
422
                    .setTitle(R.string.bluetooth_unavailable)
423
                    .setMessage(R.string.swap_cant_send_no_bluetooth)
424
                    .setNegativeButton(
425
                            R.string.cancel,
426
                            new DialogInterface.OnClickListener() {
427
                                @Override
428
                                public void onClick(DialogInterface dialog, int which) {}
429
                            }
430
                    ).create().show();
431
        }
432
    }
433

    
434
    private void sendFDroidApk() {
435
        ((FDroidApp) getApplication()).sendViaBluetooth(this, Activity.RESULT_OK, "org.fdroid.fdroid");
436
    }
437

    
438
    // TODO: Figure out whether they have changed since last time UpdateAsyncTask was run.
439
    // If the local repo is running, then we can ask it what apps it is swapping and compare with that.
440
    // Otherwise, probably will need to scan the file system.
441
    public void onAppsSelected() {
442
        if (updateSwappableAppsTask == null && !hasPreparedLocalRepo) {
443
            updateSwappableAppsTask = new PrepareSwapRepo(getService().getAppsToSwap());
444
            updateSwappableAppsTask.execute();
445
            getService().setStep(SwapService.STEP_CONNECTING);
446
            inflateInnerView(R.layout.swap_connecting);
447
        } else {
448
            onLocalRepoPrepared();
449
        }
450
    }
451

    
452
    private void prepareInitialRepo() {
453
        // TODO: Make it so that this and updateSwappableAppsTask (the _real_ swap repo task)
454
        // don't stomp on eachothers toes. The other one should wait for this to finish, or cancel
455
        // this, but this should never take precedence over the other.
456
        // TODO: Also don't allow this to run multiple times (e.g. if a user keeps navigating back
457
        // to the main screen.
458
        Log.d(TAG, "Preparing initial repo with only F-Droid, until we have allowed the user to configure their own repo.");
459
        new PrepareInitialSwapRepo().execute();
460
    }
461

    
462
    /**
463
     * Once the UpdateAsyncTask has finished preparing our repository index, we can
464
     * show the next screen to the user. This will be one of two things:
465
     *  * If we directly selected a peer to swap with initially, we will skip straight to getting
466
     *    the list of apps from that device.
467
     *  * Alternatively, if we didn't have a person to connect to, and instead clicked "Scan QR Code",
468
     *    then we want to show a QR code or NFC dialog.
469
     */
470
    public void onLocalRepoPrepared() {
471
        updateSwappableAppsTask = null;
472
        hasPreparedLocalRepo = true;
473
        if (getService().isConnectingWithPeer()) {
474
            startSwappingWithPeer();
475
        } else if (!attemptToShowNfc()) {
476
            showWifiQr();
477
        }
478
    }
479

    
480
    private void startSwappingWithPeer() {
481
        getService().connectToPeer();
482
        inflateInnerView(R.layout.swap_connecting);
483
    }
484

    
485
    private void showJoinWifi() {
486
        inflateInnerView(R.layout.swap_join_wifi);
487
    }
488

    
489
    public void showWifiQr() {
490
        inflateInnerView(R.layout.swap_wifi_qr);
491
    }
492

    
493
    public void showSwapConnected() {
494
        inflateInnerView(R.layout.swap_success);
495
    }
496

    
497
    private boolean attemptToShowNfc() {
498
        // TODO: What if NFC is disabled? Hook up with NfcNotEnabledActivity? Or maybe only if they
499
        // click a relevant button?
500

    
501
        // Even if they opted to skip the message which says "Touch devices to swap",
502
        // we still want to actually enable the feature, so that they could touch
503
        // during the wifi qr code being shown too.
504
        boolean nfcMessageReady = NfcHelper.setPushMessage(this, Utils.getSharingUri(FDroidApp.repo));
505

    
506
        if (Preferences.get().showNfcDuringSwap() && nfcMessageReady) {
507
            inflateInnerView(R.layout.swap_nfc);
508
            return true;
509
        }
510
        return false;
511
    }
512

    
513
    public void swapWith(Peer peer) {
514
        getService().stopScanningForPeers();
515
        getService().swapWith(peer);
516
        showSelectApps();
517
    }
518

    
519
    /**
520
     * This is for when we initiate a swap by viewing the "Are you sure you want to swap with" view
521
     * This can arise either:
522
     *   * As a result of scanning a QR code (in which case we likely already have a repo setup) or
523
     *   * As a result of the other device selecting our device in the "start swap" screen, in which
524
     *     case we are likely just sitting on the start swap screen also, and haven't configured
525
     *     anything yet.
526
     */
527
    public void swapWith(NewRepoConfig repoConfig) {
528
        Peer peer = repoConfig.toPeer();
529
        if (getService().getStep() == SwapService.STEP_INTRO || getService().getStep() == SwapService.STEP_CONFIRM_SWAP) {
530
            // This will force the "Select apps to swap" workflow to begin.
531
            // TODO: Find a better way to decide whether we need to select the apps. Not sure if we
532
            //       can or cannot be in STEP_INTRO with a full blown repo ready to swap.
533
            swapWith(peer);
534
        } else {
535
            getService().swapWith(repoConfig.toPeer());
536
            startSwappingWithPeer();
537
        }
538
    }
539

    
540
    public void denySwap() {
541
        showIntro();
542
    }
543

    
544
    /**
545
     * Attempts to open a QR code scanner, in the hope a user will then scan the QR code of another
546
     * device configured to swapp apps with us. Delegates to the zxing library to do so.
547
     */
548
    public void initiateQrScan() {
549
        IntentIntegrator integrator = new IntentIntegrator(this);
550
        integrator.initiateScan();
551
    }
552

    
553
    @Override
554
    public void onActivityResult(int requestCode, int resultCode, Intent intent) {
555
        IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent);
556
        if (scanResult != null) {
557
            if (scanResult.getContents() != null) {
558
                NewRepoConfig repoConfig = new NewRepoConfig(this, scanResult.getContents());
559
                if (repoConfig.isValidRepo()) {
560
                    confirmSwapConfig = repoConfig;
561
                    showRelevantView();
562
                } else {
563
                    Toast.makeText(this, R.string.swap_qr_isnt_for_swap, Toast.LENGTH_SHORT).show();
564
                }
565
            }
566
        } else if (requestCode == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
567
            finish();
568
        } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SWAP) {
569

    
570
            if (resultCode == RESULT_OK) {
571
                Log.d(TAG, "User enabled Bluetooth, will make sure we are discoverable.");
572
                ensureBluetoothDiscoverableThenStart();
573
            } else {
574
                // Didn't enable bluetooth
575
                Log.d(TAG, "User chose not to enable Bluetooth, so doing nothing (i.e. sticking with wifi).");
576
            }
577

    
578
        } else if (requestCode == REQUEST_BLUETOOTH_DISCOVERABLE) {
579

    
580
            if (resultCode != RESULT_CANCELED) {
581
                Log.d(TAG, "User made Bluetooth discoverable, will proceed to start bluetooth server.");
582
                getState().getBluetoothSwap().startInBackground();
583
            } else {
584
                Log.d(TAG, "User chose not to make Bluetooth discoverable, so doing nothing (i.e. sticking with wifi).");
585
            }
586

    
587
        } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SEND) {
588
            sendFDroidApk();
589
        }
590
    }
591

    
592
    /**
593
     * The process for setting up bluetooth is as follows:
594
     *  * Assume we have bluetooth available (otherwise the button which allowed us to start
595
     *    the bluetooth process should not have been available).
596
     *  * Ask user to enable (if not enabled yet).
597
     *  * Start bluetooth server socket.
598
     *  * Enable bluetooth discoverability, so that people can connect to our server socket.
599
     *
600
     * Note that this is a little different than the usual process for bluetooth _clients_, which
601
     * involves pairing and connecting with other devices.
602
     */
603
    public void startBluetoothSwap() {
604

    
605
        Log.d(TAG, "Initiating Bluetooth swap, will ensure the Bluetooth devices is enabled and discoverable before starting server.");
606
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
607

    
608
        if (adapter != null)
609
            if (adapter.isEnabled()) {
610
                Log.d(TAG, "Bluetooth enabled, will check if device is discoverable with device.");
611
                ensureBluetoothDiscoverableThenStart();
612
            } else {
613
                Log.d(TAG, "Bluetooth disabled, asking user to enable it.");
614
                Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
615
                startActivityForResult(enableBtIntent, REQUEST_BLUETOOTH_ENABLE_FOR_SWAP);
616
            }
617
    }
618

    
619
    private void ensureBluetoothDiscoverableThenStart() {
620
        Log.d(TAG, "Ensuring Bluetooth is in discoverable mode.");
621
        if (BluetoothAdapter.getDefaultAdapter().getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
622

    
623
            // TODO: Listen for BluetoothAdapter.ACTION_SCAN_MODE_CHANGED and respond if discovery
624
            // is cancelled prematurely.
625

    
626
            Log.d(TAG, "Not currently in discoverable mode, so prompting user to enable.");
627
            Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
628
            intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300); // TODO: What about when this expires? What if user manually disables discovery?
629
            startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE);
630
        }
631

    
632
        if (service == null) {
633
            throw new IllegalStateException("Can't start Bluetooth swap because service is null for some strange reason.");
634
        }
635

    
636
        service.getBluetoothSwap().startInBackground();
637
    }
638

    
639
    class PrepareInitialSwapRepo extends PrepareSwapRepo {
640
        public PrepareInitialSwapRepo() {
641
            super(new HashSet<>(Arrays.asList(new String[] { "org.fdroid.fdroid" })));
642
        }
643
    }
644

    
645
    class PrepareSwapRepo extends AsyncTask<Void, Void, Void> {
646

    
647
        public static final String ACTION = "PrepareSwapRepo.Action";
648
        public static final String EXTRA_MESSAGE = "PrepareSwapRepo.Status.Message";
649
        public static final String EXTRA_TYPE = "PrepareSwapRepo.Action.Type";
650
        public static final int TYPE_STATUS = 0;
651
        public static final int TYPE_COMPLETE = 1;
652
        public static final int TYPE_ERROR = 2;
653

    
654
        @SuppressWarnings("UnusedDeclaration")
655
        private static final String TAG = "UpdateAsyncTask";
656

    
657
        @NonNull
658
        protected final Set<String> selectedApps;
659

    
660
        @NonNull
661
        protected final Uri sharingUri;
662

    
663
        @NonNull
664
        protected final Context context;
665

    
666
        public PrepareSwapRepo(@NonNull Set<String> apps) {
667
            context = SwapWorkflowActivity.this;
668
            selectedApps = apps;
669
            sharingUri = Utils.getSharingUri(FDroidApp.repo);
670
        }
671

    
672
        private void broadcast(int type) {
673
            broadcast(type, null);
674
        }
675

    
676
        private void broadcast(int type, String message) {
677
            Intent intent = new Intent(ACTION);
678
            intent.putExtra(EXTRA_TYPE, type);
679
            if (message != null) {
680
                Log.d(TAG, "Preparing swap: " + message);
681
                intent.putExtra(EXTRA_MESSAGE, message);
682
            }
683
            LocalBroadcastManager.getInstance(SwapWorkflowActivity.this).sendBroadcast(intent);
684
        }
685

    
686
        @Override
687
        protected Void doInBackground(Void... params) {
688
            try {
689
                final LocalRepoManager lrm = LocalRepoManager.get(context);
690
                broadcast(TYPE_STATUS, getString(R.string.deleting_repo));
691
                lrm.deleteRepo();
692
                for (String app : selectedApps) {
693
                    broadcast(TYPE_STATUS, String.format(getString(R.string.adding_apks_format), app));
694
                    lrm.addApp(context, app);
695
                }
696
                lrm.writeIndexPage(sharingUri.toString());
697
                broadcast(TYPE_STATUS, getString(R.string.writing_index_jar));
698
                lrm.writeIndexJar();
699
                broadcast(TYPE_STATUS, getString(R.string.linking_apks));
700
                lrm.copyApksToRepo();
701
                broadcast(TYPE_STATUS, getString(R.string.copying_icons));
702
                // run the icon copy without progress, its not a blocker
703
                // TODO: Fix lint error about this being run from a worker thread, says it should be
704
                // run on a main thread.
705
                new AsyncTask<Void, Void, Void>() {
706

    
707
                    @Override
708
                    protected Void doInBackground(Void... params) {
709
                        lrm.copyIconsToRepo();
710
                        return null;
711
                    }
712
                }.execute();
713

    
714
                broadcast(TYPE_COMPLETE);
715
            } catch (Exception e) {
716
                broadcast(TYPE_ERROR);
717
                e.printStackTrace();
718
            }
719
            return null;
720
        }
721
    }
722

    
723
    /**
724
     * Helper class to try and make sense of what the swap workflow is currently doing.
725
     * The more technologies are involved in the process (e.g. Bluetooth/Wifi/NFC/etc)
726
     * the harder it becomes to reason about and debug the whole thing. Thus,this class
727
     * will periodically dump the state to logcat so that it is easier to see when certain
728
     * protocols are enabled/disabled.
729
     *
730
     * To view only this output from logcat:
731
     *
732
     *  adb logcat | grep 'Swap Status'
733
     *
734
     * To exclude this output from logcat (it is very noisy):
735
     *
736
     *  adb logcat | grep -v 'Swap Status'
737
     *
738
     */
739
    class SwapDebug {
740

    
741
        public void logStatus() {
742

    
743
            if (true) return;
744

    
745
            String message = "";
746
            if (service == null) {
747
                message = "No swap service";
748
            } else {
749
                {
750
                    String bluetooth = service.getBluetoothSwap().isConnected() ? "Y" : " N";
751
                    String wifi = service.getWifiSwap().isConnected() ? "Y" : " N";
752
                    String mdns = service.getWifiSwap().getBonjour().isConnected() ? "Y" : " N";
753
                     message += "Swap { BT: " + bluetooth + ", WiFi: " + wifi + ", mDNS: " + mdns + "}, ";
754
                }
755

    
756
                {
757
                    BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
758
                    String bluetooth = "N/A";
759
                    if (adapter != null) {
760
                        Map<Integer, String> scanModes = new HashMap<>(3);
761
                        scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE, "CON");
762
                        scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, "CON_DISC");
763
                        scanModes.put(BluetoothAdapter.SCAN_MODE_NONE, "NONE");
764
                        bluetooth = "\"" + adapter.getName() + "\" - " + scanModes.get(adapter.getScanMode());
765
                    }
766

    
767
                    String wifi = service.getBonjourFinder().isScanning() ? "Y" : " N";
768
                    message += "Find { BT: " + bluetooth + ", WiFi: " + wifi + "}";
769
                }
770
            }
771

    
772
            Date now = new Date();
773
            Log.d("Swap Status", now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds() + " " + message);
774

    
775
            new Timer().schedule(new TimerTask() {
776
                    @Override
777
                    public void run() {
778
                        new SwapDebug().logStatus();
779
                    }
780
                },
781
                1000
782
            );
783
        }
784
    }
785

    
786
    public void install(@NonNull final App app) {
787
        final Apk apkToInstall = ApkProvider.Helper.find(this, app.id, app.suggestedVercode);
788
        final ApkDownloader downloader = new ApkDownloader(this, app, apkToInstall, apkToInstall.repoAddress);
789
        downloader.setProgressListener(new ProgressListener() {
790
            @Override
791
            public void onProgress(Event event) {
792
                switch (event.type) {
793
                    case ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE:
794
                        handleDownloadComplete(downloader.localFile());
795
                        break;
796
                    case ApkDownloader.EVENT_ERROR:
797
                        break;
798
                }
799
            }
800
        });
801
        downloader.download();
802
    }
803

    
804
    private void handleDownloadComplete(File apkFile) {
805

    
806
        try {
807
            Installer.getActivityInstaller(SwapWorkflowActivity.this, new Installer.InstallerCallback() {
808
                @Override
809
                public void onSuccess(int operation) {
810
                    // TODO: Don't reload the view weely-neely, but rather get the view to listen
811
                    // for broadcasts that say the install was complete.
812
                    showRelevantView(true);
813
                }
814

    
815
                @Override
816
                public void onError(int operation, int errorCode) {
817
                    // TODO: Boo!
818
                }
819
            }).installPackage(apkFile);
820
        } catch (Installer.AndroidNotCompatibleException e) {
821
            // TODO: Handle exception properly
822
        }
823
    }
824

    
825
}