package org.fdroid.fdroid.views.swap;

import android.app.Activity;
import android.app.PendingIntent;
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.ServiceConnection;
import android.net.Uri;
import android.net.wifi.WifiManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.IBinder;
import android.support.annotation.ColorRes;
import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;

import com.google.zxing.integration.android.IntentIntegrator;
import com.google.zxing.integration.android.IntentResult;

import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.NfcHelper;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.ApkProvider;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.NewRepoConfig;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.installer.Installer;
import org.fdroid.fdroid.localrepo.LocalRepoManager;
import org.fdroid.fdroid.localrepo.SwapService;
import org.fdroid.fdroid.localrepo.peers.Peer;

import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;

import cc.mvdan.accesspoint.WifiApControl;

/**
 * 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.
 */
public class SwapWorkflowActivity extends AppCompatActivity {

    /**
     * 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";
    public static final String EXTRA_CONFIRM = "EXTRA_CONFIRM";

    /**
     * Ensure that we don't try to handle specific intents more than once in onResume()
     * (e.g. the "Do you want to swap back with ..." intent).
     */
    public static final String EXTRA_SWAP_INTENT_HANDLED = "swapIntentHandled";

    private ViewGroup container;

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

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

        @SwapService.SwapStep int getPreviousStep();

        @ColorRes int getToolbarColour();

        String getToolbarTitle();
    }

    private static final String TAG = "SwapWorkflowActivity";

    private static final int CONNECT_TO_SWAP = 1;
    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 Toolbar toolbar;
    private InnerView currentView;
    private boolean hasPreparedLocalRepo;
    private PrepareSwapRepo updateSwappableAppsTask;
    private NewRepoConfig confirmSwapConfig;
    private LocalBroadcastManager localBroadcastManager;

    @NonNull
    private final ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName className, IBinder binder) {
            Utils.debugLog(TAG, "Swap service connected. Will hold onto it so we can talk to it regularly.");
            service = ((SwapService.Binder) binder).getService();
            showRelevantView();
        }

        // TODO: What causes this? Do we need to stop swapping explicitly when this is invoked?
        @Override
        public void onServiceDisconnected(ComponentName className) {
            Utils.debugLog(TAG, "Swap service disconnected");
            service = null;
            // TODO: What to do about the UI in this instance?
        }
    };

    @Nullable
    private SwapService service;

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

    @Override
    public void onBackPressed() {
        if (currentView.getStep() == SwapService.STEP_INTRO) {
            SwapService.stop(this);
            finish();
        } else {
            int nextStep = currentView.getPreviousStep();
            getService().setStep(nextStep);
            showRelevantView();
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

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

        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.fragment_container);

        localBroadcastManager = LocalBroadcastManager.getInstance(this);

        new SwapDebug().logStatus();
    }

    @Override
    protected void onDestroy() {
        unbindService(serviceConnection);
        super.onDestroy();
    }

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        menu.clear();
        boolean parent = super.onPrepareOptionsMenu(menu);
        boolean inner  = currentView != null && currentView.buildMenu(menu, getMenuInflater());
        return parent || inner;
    }

    @Override
    protected void onResume() {
        super.onResume();

        checkIncomingIntent();
        showRelevantView();
    }

    private void checkIncomingIntent() {
        Intent intent = getIntent();
        if (intent.getBooleanExtra(EXTRA_CONFIRM, false) && !intent.getBooleanExtra(EXTRA_SWAP_INTENT_HANDLED, false)) {
            // Storing config in this variable will ensure that when showRelevantView() is next
            // run, it will show the connect swap view (if the service is available).
            intent.putExtra(EXTRA_SWAP_INTENT_HANDLED, true);
            confirmSwapConfig = new NewRepoConfig(this, intent);
        }
    }

    public void promptToSelectWifiNetwork() {
        //
        // On Android >= 5.0, the neutral button is the one by itself off to the left of a dialog
        // (not the negative button). Thus, the layout of this dialogs buttons should be:
        //
        // |                                 |
        // +---------------------------------+
        // | Cancel           Hotspot   WiFi |
        // +---------------------------------+
        //
        // TODO: Investigate if this should be set dynamically for earlier APIs.
        //
        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) {
                        startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK));
                    }
                }
                ).setNegativeButton(R.string.wifi_ap, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        promptToSetupWifiAP();
                    }
                }
        ).create().show();
    }

    private void promptToSetupWifiAP() {
        WifiManager wifiManager = (WifiManager) getSystemService(Context.WIFI_SERVICE);
        WifiApControl ap = WifiApControl.getInstance(this);
        wifiManager.setWifiEnabled(false);
        if (!ap.enable()) {
            Log.e(TAG, "Could not enable WiFi AP.");
            // TODO: Feedback to user?
        } else {
            Utils.debugLog(TAG, "WiFi AP enabled.");
            // TODO: Seems to be broken some times...
        }
    }

    private void showRelevantView() {
        showRelevantView(false);
    }

    private void showRelevantView(boolean forceReload) {

        if (service == null) {
            showInitialLoading();
            return;
        }

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

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

        switch (service.getStep()) {
            case SwapService.STEP_INTRO:
                showIntro();
                break;
            case SwapService.STEP_SELECT_APPS:
                showSelectApps();
                break;
            case SwapService.STEP_SHOW_NFC:
                showNfc();
                break;
            case SwapService.STEP_JOIN_WIFI:
                showJoinWifi();
                break;
            case SwapService.STEP_WIFI_QR:
                showWifiQr();
                break;
            case SwapService.STEP_SUCCESS:
                showSwapConnected();
                break;
            case SwapService.STEP_CONNECTING:
                // TODO: Properly decide what to do here (i.e. returning to the activity after it was connecting)...
                inflateInnerView(R.layout.swap_blank);
                break;
        }
    }

    public SwapService getState() {
        return service;
    }

    private void showNfc() {
        if (!attemptToShowNfc()) {
            showWifiQr();
        }
    }

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

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

        toolbar.setBackgroundColor(getResources().getColor(currentView.getToolbarColour()));
        toolbar.setTitle(currentView.getToolbarTitle());
        toolbar.setNavigationIcon(R.drawable.ic_close_white);
        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onToolbarCancel();
            }
        });
        container.addView(view);
        supportInvalidateOptionsMenu();

        return currentView;
    }

    private void onToolbarCancel() {
        SwapService.stop(this);
        finish();
    }

    private void showInitialLoading() {
        inflateInnerView(R.layout.swap_initial_loading);
    }

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

        if (!getService().isEnabled()) {
            if (!LocalRepoManager.get(this).getIndexJar().exists()) {
                Utils.debugLog(TAG, "Preparing initial repo with only F-Droid, until we have allowed the user to configure their own repo.");
                new PrepareInitialSwapRepo().execute();
            }
        }

        inflateInnerView(R.layout.swap_blank);
    }

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

    public void startQrWorkflow() {
        if (!getService().isEnabled()) {
            new AlertDialog.Builder(this)
                    .setTitle(R.string.swap_not_enabled)
                    .setMessage(R.string.swap_not_enabled_description)
                    .setCancelable(true)
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            // Do nothing. The dialog will get dismissed anyway, which is all we ever wanted...
                        }
                    })
                    .create().show();
        } else {
            showSelectApps();
        }
    }

    public void showSelectApps() {
        inflateInnerView(R.layout.swap_select_apps);
    }

    public void sendFDroid() {
        // If Bluetooth has not been enabled/turned on, then enabling device discoverability
        // will automatically enable Bluetooth.
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        if (adapter != null) {
            if (adapter.getState() != BluetoothAdapter.STATE_ON) {
                Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
                discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 120);
                startActivityForResult(discoverBt, REQUEST_BLUETOOTH_ENABLE_FOR_SEND);
            } else {
                sendFDroidApk();
            }
        } else {
            new AlertDialog.Builder(this)
                    .setTitle(R.string.bluetooth_unavailable)
                    .setMessage(R.string.swap_cant_send_no_bluetooth)
                    .setNegativeButton(
                            R.string.cancel,
                            new DialogInterface.OnClickListener() {
                                @Override
                                public void onClick(DialogInterface dialog, int which) { }
                            }
                    ).create().show();
        }
    }

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

    // TODO: Figure out whether they have changed since last time UpdateAsyncTask 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 (updateSwappableAppsTask == null && !hasPreparedLocalRepo) {
            updateSwappableAppsTask = new PrepareSwapRepo(getService().getAppsToSwap());
            updateSwappableAppsTask.execute();
            getService().setStep(SwapService.STEP_CONNECTING);
            inflateInnerView(R.layout.swap_connecting);
        } else {
            onLocalRepoPrepared();
        }
    }

    /**
     * Once the UpdateAsyncTask has finished preparing our repository index, we can
     * show the next screen to the user. This will be one of two things:
     *  * If we directly selected a peer to swap with initially, we will skip straight to getting
     *    the list of apps from that device.
     *  * 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.
     */
    public void onLocalRepoPrepared() {
        updateSwappableAppsTask = null;
        hasPreparedLocalRepo = true;
        if (getService().isConnectingWithPeer()) {
            startSwappingWithPeer();
        } else if (!attemptToShowNfc()) {
            showWifiQr();
        }
    }

    private void startSwappingWithPeer() {
        getService().connectToPeer();
        inflateInnerView(R.layout.swap_connecting);
    }

    private void showJoinWifi() {
        inflateInnerView(R.layout.swap_join_wifi);
    }

    public void showWifiQr() {
        inflateInnerView(R.layout.swap_wifi_qr);
    }

    public void showSwapConnected() {
        inflateInnerView(R.layout.swap_success);
    }

    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));

        if (Preferences.get().showNfcDuringSwap() && nfcMessageReady) {
            inflateInnerView(R.layout.swap_nfc);
            return true;
        }
        return false;
    }

    public void swapWith(Peer peer) {
        getService().swapWith(peer);
        showSelectApps();
    }

    /**
     * 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 (getService().getStep() == SwapService.STEP_INTRO || getService().getStep() == SwapService.STEP_CONFIRM_SWAP) {
            // 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 {
            getService().swapWith(repoConfig.toPeer());
            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 == CONNECT_TO_SWAP && resultCode == Activity.RESULT_OK) {
            finish();
        } 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 {
                // Didn't enable bluetooth
                Utils.debugLog(TAG, "User chose not to enable Bluetooth, so doing nothing (i.e. sticking with wifi).");
            }

        } else if (requestCode == REQUEST_BLUETOOTH_DISCOVERABLE) {

            if (resultCode != RESULT_CANCELED) {
                Utils.debugLog(TAG, "User made Bluetooth discoverable, will proceed to start bluetooth server.");
                getState().getBluetoothSwap().startInBackground();
            } else {
                Utils.debugLog(TAG, "User chose not to make Bluetooth discoverable, so doing nothing (i.e. sticking with wifi).");
            }

        } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SEND) {
            sendFDroidApk();
        }
    }

    /**
     * The process for setting up bluetooth is as follows:
     *  * Assume we have bluetooth available (otherwise the button which allowed us to start
     *    the bluetooth process should not have been available).
     *  * Ask user to enable (if not enabled yet).
     *  * Start bluetooth server socket.
     *  * Enable bluetooth discoverability, so that people can connect to our server socket.
     *
     * 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() {

        Utils.debugLog(TAG, "Initiating Bluetooth swap, will ensure the Bluetooth devices is enabled and discoverable before starting server.");
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();

        if (adapter != null) {
            if (adapter.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.getDefaultAdapter().getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) {

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

            // 3600 is new maximum! TODO: What about when this expires? What if user manually disables discovery?
            final int discoverableTimeout = 3600;

            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, discoverableTimeout);
            startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE);
        }

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

        service.getBluetoothSwap().startInBackground();
    }

    class PrepareInitialSwapRepo extends PrepareSwapRepo {
        PrepareInitialSwapRepo() {
            super(new HashSet<>(Arrays.asList(new String[] {"org.fdroid.fdroid"})));
        }
    }

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

        public static final String ACTION = "PrepareSwapRepo.Action";
        public static final String EXTRA_MESSAGE = "PrepareSwapRepo.Status.Message";
        public static final String EXTRA_TYPE = "PrepareSwapRepo.Action.Type";
        public static final int TYPE_STATUS = 0;
        public static final int TYPE_COMPLETE = 1;
        public static final int TYPE_ERROR = 2;

        @NonNull
        protected final Set<String> selectedApps;

        @NonNull
        protected final Uri sharingUri;

        @NonNull
        protected final Context context;

        PrepareSwapRepo(@NonNull Set<String> apps) {
            context = SwapWorkflowActivity.this;
            selectedApps = apps;
            sharingUri = Utils.getSharingUri(FDroidApp.repo);
        }

        private void broadcast(int type) {
            broadcast(type, null);
        }

        private void broadcast(int type, String message) {
            Intent intent = new Intent(ACTION);
            intent.putExtra(EXTRA_TYPE, type);
            if (message != null) {
                Utils.debugLog(TAG, "Preparing swap: " + message);
                intent.putExtra(EXTRA_MESSAGE, message);
            }
            LocalBroadcastManager.getInstance(SwapWorkflowActivity.this).sendBroadcast(intent);
        }

        @Override
        protected Void doInBackground(Void... params) {
            try {
                final LocalRepoManager lrm = LocalRepoManager.get(context);
                broadcast(TYPE_STATUS, getString(R.string.deleting_repo));
                lrm.deleteRepo();
                for (String app : selectedApps) {
                    broadcast(TYPE_STATUS, String.format(getString(R.string.adding_apks_format), app));
                    lrm.addApp(context, app);
                }
                lrm.writeIndexPage(sharingUri.toString());
                broadcast(TYPE_STATUS, getString(R.string.writing_index_jar));
                lrm.writeIndexJar();
                broadcast(TYPE_STATUS, getString(R.string.linking_apks));
                lrm.copyApksToRepo();
                broadcast(TYPE_STATUS, getString(R.string.copying_icons));
                // run the icon copy without progress, its not a blocker
                new Thread() {
                    @Override
                    public void run() {
                        android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
                        lrm.copyIconsToRepo();
                    }
                }.start();

                broadcast(TYPE_COMPLETE);
            } catch (Exception e) {
                broadcast(TYPE_ERROR);
                Log.e(TAG, "", e);
            }
            return null;
        }
    }

    /**
     * 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.
     *
     * To view only this output from logcat:
     *
     *  adb logcat | grep 'Swap Status'
     *
     * To exclude this output from logcat (it is very noisy):
     *
     *  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 = service.getBluetoothSwap().isConnected() ? "Y" : " N";
                String wifi = service.getWifiSwap().isConnected() ? "Y" : " N";
                String mdns = service.getWifiSwap().getBonjour().isConnected() ? "Y" : " N";
                message += "Swap { BT: " + bluetooth + ", WiFi: " + wifi + ", mDNS: " + mdns + "}, ";

                BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
                bluetooth = "N/A";
                if (adapter != 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 = "\"" + adapter.getName() + "\" - " + scanModes.get(adapter.getScanMode());
                }

                message += "Find { BT: " + bluetooth + ", WiFi: " + wifi + "}";
            }

            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
            );
        }
    }

    public void install(@NonNull final App app) {
        final Apk apk = ApkProvider.Helper.find(this, app.packageName, app.suggestedVersionCode);
        Uri downloadUri = Uri.parse(apk.getUrl());
        localBroadcastManager.registerReceiver(installReceiver,
                Installer.getInstallIntentFilter(downloadUri));
        InstallManagerService.queue(this, app, apk);
    }

    private final BroadcastReceiver installReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            switch (intent.getAction()) {
                case Installer.ACTION_INSTALL_STARTED:
                    break;
                case Installer.ACTION_INSTALL_COMPLETE:
                    localBroadcastManager.unregisterReceiver(this);

                    showRelevantView(true);
                    break;
                case Installer.ACTION_INSTALL_INTERRUPTED:
                    localBroadcastManager.unregisterReceiver(this);
                    // TODO: handle errors!
                    break;
                case Installer.ACTION_INSTALL_USER_INTERACTION:
                    PendingIntent installPendingIntent =
                            intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI);

                    try {
                        installPendingIntent.send();
                    } catch (PendingIntent.CanceledException e) {
                        Log.e(TAG, "PI canceled", e);
                    }

                    break;
                default:
                    throw new RuntimeException("intent action not handled!");
            }
        }
    };

}
