Merge "android-lib: Add VBeam class for sharing over NFC."
diff --git a/benchmarks/rpcbench/build.gradle b/benchmarks/rpcbench/build.gradle
index b6a19d5..939196d 100644
--- a/benchmarks/rpcbench/build.gradle
+++ b/benchmarks/rpcbench/build.gradle
@@ -26,13 +26,6 @@
     compile 'com.google.caliper:caliper:1.0-beta-2'
 }
 
-task copyLib(type: Copy) {
-    from([project(':lib').buildDir.getAbsolutePath(), 'libs', 'libv23.so'].join(File.separator))
-    destinationDir = new File(['src', 'main', 'resources'].join(File.separator))
-}
-
-tasks.'processResources'.dependsOn(copyLib)
-
 task wrapper(type: Wrapper) {
     gradleVersion = '2.7'
 }
@@ -44,3 +37,8 @@
 vdl {
     inputPaths += ['src/main/java']
 }
+
+def libProject = rootProject.findProject(':lib')
+
+tasks.run.dependsOn libProject.tasks.copyVanadiumLib
+tasks.run.jvmArgs "-Djava.library.path=${libProject.buildDir}/libs"
diff --git a/projects/moments/app/src/main/java/io/v/moments/ifc/Advertiser.java b/projects/moments/app/src/main/java/io/v/moments/ifc/Advertiser.java
index 36b70b0..f3e3f26 100644
--- a/projects/moments/app/src/main/java/io/v/moments/ifc/Advertiser.java
+++ b/projects/moments/app/src/main/java/io/v/moments/ifc/Advertiser.java
@@ -5,26 +5,34 @@
 package io.v.moments.ifc;
 
 import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.ListenableFuture;
 
 /**
- * Something needing to advertise itself will want an implementation of this.
+ * Advertiser controls, intended to be similar to Scanner controls.
  */
 public interface Advertiser {
     /**
-     * Asynchronously start advertising.  Callback executed on success or
-     * failure of advertising startup.  The future returned on successful
-     * startup should be given a callback to handle advertising shutdown.
+     * Asynchronously start advertising.
+     *
+     * Callbacks can be expected to run on the UX thread.
+     *
+     * @param startupCallback    executed on success or failure of advertising
+     *                           startup.
+     * @param completionCallback executed on success or failure of advertising
+     *                           completion.  An advertisement might shutdown
+     *                           for reasons other than a call to stop, e.g. a
+     *                           timeout.
      */
-    void advertiseStart(FutureCallback<ListenableFuture<Void>> callback);
+    void start(FutureCallback<Void> startupCallback,
+               FutureCallback<Void> completionCallback);
 
     /**
-     * True if advertiseStop could usefully be called.
+     * True if stop could usefully be called.
      */
     boolean isAdvertising();
 
     /**
-     * Synchronously stop advertising.
+     * Synchronously stop advertising.  Should result in execution of
+     * completionCallback.
      */
-    void advertiseStop();
+    void stop();
 }
diff --git a/projects/moments/app/src/main/java/io/v/moments/ifc/Scanner.java b/projects/moments/app/src/main/java/io/v/moments/ifc/Scanner.java
new file mode 100644
index 0000000..b9084d9
--- /dev/null
+++ b/projects/moments/app/src/main/java/io/v/moments/ifc/Scanner.java
@@ -0,0 +1,43 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.moments.ifc;
+
+import com.google.common.util.concurrent.FutureCallback;
+
+import io.v.v23.InputChannelCallback;
+import io.v.v23.discovery.Update;
+
+/**
+ * Scanner controls, intended to be similar to Advertiser controls.
+ */
+public interface Scanner {
+    /**
+     * Asynchronously start scanning.
+     *
+     * Callbacks can be expected to run on the UX thread.
+     *
+     * @param startupCallback    executed on success or failure of scan
+     *                           startup.
+     * @param updateCallback     executed on each scan update (each found or
+     *                           lost advertisement).
+     * @param completionCallback executed on success or failure of scan
+     *                           completion.  A scan might shutdown for reasons
+     *                           other than a call to stop, e.g. a timeout.
+     */
+    void start(FutureCallback<Void> startupCallback,
+               InputChannelCallback<Update> updateCallback,
+               FutureCallback<Void> completionCallback);
+
+    /**
+     * True if stop could usefully be called.
+     */
+    boolean isScanning();
+
+    /**
+     * Synchronously stop scanning.  Should result in execution of
+     * completionCallback.
+     */
+    void stop();
+}
diff --git a/projects/moments/app/src/main/java/io/v/moments/lib/V23Manager.java b/projects/moments/app/src/main/java/io/v/moments/lib/V23Manager.java
index 9d13e03..d627698 100644
--- a/projects/moments/app/src/main/java/io/v/moments/lib/V23Manager.java
+++ b/projects/moments/app/src/main/java/io/v/moments/lib/V23Manager.java
@@ -19,7 +19,6 @@
 
 import io.v.android.libs.security.BlessingsManager;
 import io.v.android.v23.V;
-import io.v.moments.ifc.ScanListener;
 import io.v.v23.InputChannelCallback;
 import io.v.v23.InputChannels;
 import io.v.v23.context.VContext;
@@ -33,7 +32,10 @@
 import io.v.v23.verror.VException;
 
 /**
- * Various static V23 utilities gathered in an injectable class.
+ * Various static V23 utilities gathered into an instantiatable class.
+ *
+ * This allows v23 usage to be injected and mocked.  The class is a singleton to
+ * avoid confusion about init, shutdown and the 'base context'.
  */
 public class V23Manager {
     private static final String TAG = "V23Manager";
@@ -47,50 +49,69 @@
     private V23Manager() {
     }
 
-    public VContext advertise(final Service service, FutureCallback<ListenableFuture<Void>> callback) {
+    public void scan(
+            String query,
+            Duration duration,
+            FutureCallback<VContext> startupCallback,
+            InputChannelCallback<Update> updateCallback,
+            FutureCallback<Void> completionCallback) {
+        Log.d(TAG, "Starting scan with q=[" + query + "]");
         if (mDiscovery == null) {
-            callback.onFailure(new IllegalStateException("Discovery not ready."));
-            return null;
+            startupCallback.onFailure(
+                    new IllegalStateException("Discovery not ready."));
+            return;
         }
-        VContext context = mV23Ctx.withTimeout(Duration.standardMinutes(5));
-        final ListenableFuture<ListenableFuture<Void>> fStart =
-                mDiscovery.advertise(context, service, NO_PATTERNS);
-        Futures.addCallback(fStart, callback);
-        Log.d(TAG, "Back from V.getDiscovery.advertise");
-        return context;
+        VContext context = contextWithTimeout(duration);
+        Futures.addCallback(
+                InputChannels.withCallback(
+                        mDiscovery.scan(context, query), updateCallback),
+                completionCallback);
+        startupCallback.onSuccess(context);
     }
 
-    public VContext scan(String query, final ScanListener listener) {
+    public void advertise(
+            Service advertisement,
+            Duration duration,
+            FutureCallback<VContext> startupCallback,
+            FutureCallback<Void> completionCallback) {
         if (mDiscovery == null) {
-            Log.d(TAG, "Discovery not ready.");
-            return null;
+            startupCallback.onFailure(
+                    new IllegalStateException("Discovery not ready."));
+            return;
         }
-        VContext context = mV23Ctx.withCancel();
-        Log.d(TAG, "Calling V.getDiscovery.scan with q=" + query);
-        final ListenableFuture<Void> fStart =
-                InputChannels.withCallback(mDiscovery.scan(context, query),
-                        new InputChannelCallback<Update>() {
-                            @Override
-                            public ListenableFuture<Void> onNext(Update result) {
-                                listener.scanUpdateReceived(result);
-                                return Futures.immediateFuture(null);
-                            }
-                        });
-        Futures.addCallback(fStart, new FutureCallback<Void>() {
+        VContext context = contextWithTimeout(duration);
+        ListenableFuture<ListenableFuture<Void>> hey =
+                mDiscovery.advertise(context, advertisement, NO_PATTERNS);
+        Futures.addCallback(hey, makeWrapped(context, startupCallback, completionCallback));
+    }
+
+    /**
+     * This exists to allow the scan and advertise interfaces to behave the same
+     * way to clients, and to deliver the context via the "success" callback
+     * so there's no chance of a race condition between usage of that context
+     * and code in the callback.
+     */
+    private FutureCallback<ListenableFuture<Void>> makeWrapped(
+            final VContext context,
+            final FutureCallback<VContext> startup,
+            final FutureCallback<Void> completion) {
+        return new FutureCallback<ListenableFuture<Void>>() {
             @Override
-            public void onSuccess(Void result) {
-                Log.d(TAG, "Scan started.");
+            public void onSuccess(ListenableFuture<Void> result) {
+                startup.onSuccess(context);
+                Futures.addCallback(result, completion);
             }
 
             @Override
-            public void onFailure(Throwable t) {
-                Log.d(TAG, "Failure to start scan.", t);
+            public void onFailure(final Throwable t) {
+                startup.onFailure(t);
             }
-        });
-        return context;
+        };
     }
 
-    public synchronized void init(Activity activity, FutureCallback<Blessings> future) {
+
+    public synchronized void init(
+            Activity activity, FutureCallback<Blessings> blessingCallback) {
         Log.d(TAG, "init");
         if (mAndroidCtx != null) {
             if (mAndroidCtx == activity.getApplicationContext()) {
@@ -108,7 +129,7 @@
         Log.d(TAG, "Attempting to get blessings.");
         ListenableFuture<Blessings> f = BlessingsManager.getBlessings(
                 mV23Ctx, activity, BLESSINGS_KEY, true);
-        Futures.addCallback(f, future);
+        Futures.addCallback(f, blessingCallback);
         try {
             mDiscovery = V.newDiscovery(mV23Ctx);
         } catch (VException e) {
@@ -126,7 +147,8 @@
         mAndroidCtx = null;
     }
 
-    public VContext makeServerContext(String mountName, Object server) throws VException {
+    public VContext makeServerContext(
+            String mountName, Object server) throws VException {
         return V.withNewServer(
                 mV23Ctx.withCancel(),
                 mountName,
diff --git a/projects/moments/app/src/main/java/io/v/moments/model/AdvertiserImpl.java b/projects/moments/app/src/main/java/io/v/moments/model/AdvertiserImpl.java
index f178285..5db8116 100644
--- a/projects/moments/app/src/main/java/io/v/moments/model/AdvertiserImpl.java
+++ b/projects/moments/app/src/main/java/io/v/moments/model/AdvertiserImpl.java
@@ -5,6 +5,7 @@
 package io.v.moments.model;
 
 import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
 import android.util.Log;
 
 import com.google.common.util.concurrent.FutureCallback;
@@ -30,10 +31,18 @@
 
 /**
  * Handles the advertising of a moment.
+ *
+ * Main role of class is to manage the advertising context and moment service
+ * lifetimes.  This code too complex to leak into the UX code, and too moment
+ * specific to be part of v23Manager. This class should have no knowledge of UX,
+ * and UX should have no access to VContext, because said context is under
+ * active development, and it's too complex to cover possible interactions with
+ * tests.  This approaches hides it all behind the simple Advertiser interface.
  */
 public class AdvertiserImpl implements Advertiser {
     static final String NO_MOUNT_NAME = "";
     private static final String TAG = "AdvertiserImpl";
+
     private final V23Manager mV23Manager;
     private final Moment mMoment;
 
@@ -52,57 +61,104 @@
     }
 
     @Override
-    public boolean isAdvertising() {
-        return mAdvCtx != null || mServerCtx != null;
-    }
-
-    @Override
     public String toString() {
         return mMoment.getCaption();
     }
 
     @Override
-    public void advertiseStart(FutureCallback<ListenableFuture<Void>> callback) {
-        Log.d(TAG, "Entering advertiseStart.");
+    public void start(
+            FutureCallback<Void> startupCallback,
+            FutureCallback<Void> completionCallback) {
+        Log.d(TAG, "Entering start.");
         if (isAdvertising()) {
-            callback.onFailure(new IllegalStateException("Already advertising."));
+            startupCallback.onFailure(
+                    new IllegalStateException("Already advertising."));
             return;
         }
-        Log.d(TAG, "Starting service for moment " + mMoment);
         try {
             mServerCtx = mV23Manager.makeServerContext(
                     NO_MOUNT_NAME, new MomentServer());
         } catch (VException e) {
             mServerCtx = null;
-            callback.onFailure(new IllegalStateException("Unable to start service.", e));
+            startupCallback.onFailure(
+                    new IllegalStateException("Unable to start service.", e));
             return;
         }
-        List<String> addresses = new ArrayList<>();
-        Endpoint[] points = mV23Manager.getServer(mServerCtx).getStatus().getEndpoints();
-        for (Endpoint point : points) {
-            addresses.add(point.toString());
-        }
-        Attributes attrs = mMoment.makeAttributes();
-        Log.d(TAG, "Starting advertisement of moment " + mMoment);
-        Service service = makeAdvertisement(attrs, addresses);
-        mAdvCtx = mV23Manager.advertise(service, callback);
-        Log.d(TAG, "Exiting advertiseStart.");
+        mV23Manager.advertise(
+                makeAdvertisement(
+                        mMoment.makeAttributes(), makeServerAddressList()),
+                Config.Discovery.DURATION,
+                makeWrapped(startupCallback), completionCallback);
+        Log.d(TAG, "Exiting start.");
     }
 
     @Override
-    public void advertiseStop() {
-        Log.d(TAG, "Entering advertiseStop");
+    public boolean isAdvertising() {
+        return mAdvCtx != null && !mAdvCtx.isCanceled();
+    }
+
+    @NonNull
+    private List<String> makeServerAddressList() {
+        List<String> addresses = new ArrayList<>();
+        Endpoint[] points = mV23Manager.getServer(
+                mServerCtx).getStatus().getEndpoints();
+        for (Endpoint point : points) {
+            addresses.add(point.toString());
+        }
+        return addresses;
+    }
+
+    /**
+     * A service must be started before advertising begins, which creates a
+     * cleanup problem should advertising fail to start. This callback accepts
+     * the advertising context on startup success, and cancels the already
+     * started service on failure. This wrapper necessary to cleanly kill the
+     * service should advertising fail to start.  This callback feeds info to
+     * the startup callback, which presumably has some effect on UX.
+     */
+    private FutureCallback<VContext> makeWrapped(
+            final FutureCallback<Void> startup) {
+        return new FutureCallback<VContext>() {
+            @Override
+            public void onSuccess(VContext context) {
+                mAdvCtx = context;
+                startup.onSuccess(null);
+            }
+
+            @Override
+            public void onFailure(final Throwable t) {
+                mAdvCtx = null;
+                cancelService();
+                startup.onFailure(t);
+            }
+        };
+    }
+
+    @Override
+    public void stop() {
+        if (!isAdvertising()) {
+            throw new IllegalStateException("Not advertising.");
+        }
+        Log.d(TAG, "Entering stop");
         if (mAdvCtx != null) {
             Log.d(TAG, "Cancelling advertising.");
-            mAdvCtx.cancel();
+            if (!mAdvCtx.isCanceled()) {
+                mAdvCtx.cancel();
+            }
             mAdvCtx = null;
         }
+        cancelService();
+        Log.d(TAG, "Exiting stop");
+    }
+
+    private void cancelService() {
         if (mServerCtx != null) {
             Log.d(TAG, "Cancelling service.");
-            mServerCtx.cancel();
+            if (!mServerCtx.isCanceled()) {
+                mServerCtx.cancel();
+            }
             mServerCtx = null;
         }
-        Log.d(TAG, "Exiting advertiseStop");
     }
 
     /**
@@ -114,7 +170,7 @@
         return new Service(
                 mMoment.getId().toString(),
                 mMoment.toString(),
-                Config.INTERFACE_NAME,
+                Config.Discovery.INTERFACE_NAME,
                 attrs,
                 addresses,
                 new Attachments());
diff --git a/projects/moments/app/src/main/java/io/v/moments/model/Config.java b/projects/moments/app/src/main/java/io/v/moments/model/Config.java
index 1e1a678..8761a52 100644
--- a/projects/moments/app/src/main/java/io/v/moments/model/Config.java
+++ b/projects/moments/app/src/main/java/io/v/moments/model/Config.java
@@ -10,6 +10,8 @@
 import android.os.Environment;
 import android.os.Handler;
 
+import org.joda.time.Duration;
+
 import java.io.File;
 
 import io.v.moments.R;
@@ -22,19 +24,13 @@
     // App can become slower as a result, e.g. a thumbnail transfer that would
     // be quick might be competing for bandwidth with a full size photo.
     public static final boolean DO_FULL_SIZE_TOO = true;
-
-    // Parent directory to all app storage.
+    /**
+     * Parent directory to all app storage.
+     */
     private static final File PHOTO_PARENT_DIR =
             Environment.getExternalStoragePublicDirectory(
                     Environment.DIRECTORY_PICTURES);
 
-    // Required type/interface name, probably a URL into a web-based
-    // ontology.  Necessary for querying.
-    static final String INTERFACE_NAME = "v.io/x/ref.Moments";
-
-    // To limit scans to see only this service.
-    public static final String QUERY = "v.InterfaceName=\"" + INTERFACE_NAME + "\"";
-
     /**
      * Returns purported image display area.
      *
@@ -64,4 +60,24 @@
                         R.dimen.moment_image_width)
         );
     }
+
+    /** Constants related to discovery. */
+    public static class Discovery {
+        /**
+         * Required type/interface name, probably a URL into a web-based
+         * ontology.  Necessary for querying.
+         */
+        public static final String INTERFACE_NAME = "v.io/x/ref.Moments";
+        /**
+         * To limit scans to see only this service.
+         */
+        public static final String QUERY = "v.InterfaceName=\"" + INTERFACE_NAME + "\"";
+
+        /**
+         * After this duration an advertisement or scan for an advertisement
+         * will automatically stop. Choice is arbitrary. A nice exercise would
+         * be to add this to a settings menu.
+         */
+        public static final Duration DURATION = Duration.standardMinutes(5);
+    }
 }
diff --git a/projects/moments/app/src/main/java/io/v/moments/model/ScannerImpl.java b/projects/moments/app/src/main/java/io/v/moments/model/ScannerImpl.java
new file mode 100644
index 0000000..82f14f3
--- /dev/null
+++ b/projects/moments/app/src/main/java/io/v/moments/model/ScannerImpl.java
@@ -0,0 +1,114 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.moments.model;
+
+import android.util.Log;
+
+import com.google.common.util.concurrent.FutureCallback;
+
+import io.v.moments.ifc.Scanner;
+import io.v.moments.lib.V23Manager;
+import io.v.v23.InputChannelCallback;
+import io.v.v23.context.VContext;
+import io.v.v23.discovery.Update;
+
+/**
+ * Handles scanning for moments.
+ *
+ * This class similar to AdvertiserImpl - see that class for more commentary -
+ * in that it functions as decoupling of the UX from the moving target that is
+ * the V23/VContext API
+ *
+ * At the moment, the complexity is far less than it is for advertising
+ * (scanning doesn't require a running service), so this class doesn't add much
+ * value over using an instance of V23Manager and VContext directly in whatever
+ * class uses a Scanner.  To make this class more useful in decoupling moments
+ * specific code from v23 specifics, this class could tease apart a scan Update,
+ * and feed advertisement data from the Update.Found and Update.Lost object
+ * directly into, say, the appropriate instances of java.util.function.Consumer<T>
+ * that were passed to #start en lieu of the InputChannelCallback<Update>
+ * updateCallback.  That way the caller would have no exposure to v23 classes.
+ */
+public class ScannerImpl implements Scanner {
+    private static final String TAG = "ScannerImpl";
+    private final V23Manager mV23Manager;
+    private final String mQuery;
+    private VContext mScanCtx;
+
+    public ScannerImpl(V23Manager v23Manager, String query) {
+        if (v23Manager == null) {
+            throw new IllegalArgumentException("Null v23Manager.");
+        }
+        if (query == null || query.isEmpty()) {
+            throw new IllegalArgumentException("Empty query.");
+        }
+        mV23Manager = v23Manager;
+        mQuery = query;
+    }
+
+    @Override
+    public String toString() {
+        return "scan(" + mQuery + "," + isScanning() + ")";
+    }
+
+    /**
+     * Accepts the scanning context on success (or assures that it's null on
+     * failure), then activates the callback supplied by the client (likely to
+     * change the UX).
+     */
+    private FutureCallback<VContext> makeWrapped(
+            final FutureCallback<Void> startup) {
+        return new FutureCallback<VContext>() {
+            @Override
+            public void onSuccess(VContext context) {
+                mScanCtx = context;
+                startup.onSuccess(null);
+            }
+
+            @Override
+            public void onFailure(final Throwable t) {
+                mScanCtx = null;
+                startup.onFailure(t);
+            }
+        };
+    }
+
+    @Override
+    public void start(
+            FutureCallback<Void> startupCallback,
+            InputChannelCallback<Update> updateCallback,
+            FutureCallback<Void> completionCallback) {
+        Log.d(TAG, "Entering start.");
+        if (isScanning()) {
+            startupCallback.onFailure(
+                    new IllegalStateException("Already scanning."));
+            return;
+        }
+        mV23Manager.scan(
+                mQuery, Config.Discovery.DURATION,
+                makeWrapped(startupCallback),
+                updateCallback,
+                completionCallback);
+        Log.d(TAG, "Exiting start.");
+    }
+
+    @Override
+    public boolean isScanning() {
+        return mScanCtx != null && !mScanCtx.isCanceled();
+    }
+
+    @Override
+    public void stop() {
+        Log.d(TAG, "Entering stop");
+        if (mScanCtx != null) {
+            Log.d(TAG, "Cancelling scan.");
+            if (!mScanCtx.isCanceled()) {
+                mScanCtx.cancel();
+            }
+            mScanCtx = null;
+        }
+        Log.d(TAG, "Exiting stop");
+    }
+}
diff --git a/projects/moments/app/src/main/java/io/v/moments/model/Toaster.java b/projects/moments/app/src/main/java/io/v/moments/model/Toaster.java
new file mode 100644
index 0000000..5e4e246
--- /dev/null
+++ b/projects/moments/app/src/main/java/io/v/moments/model/Toaster.java
@@ -0,0 +1,31 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.moments.model;
+
+import android.app.Activity;
+import android.widget.Toast;
+
+/**
+ * Wrapper allowing limited access to an activity.
+ */
+public class Toaster {
+
+    private final Activity mActivity;
+
+    public Toaster(Activity activity) {
+        mActivity = activity;
+    }
+
+    public boolean isDestroyed() {
+        return mActivity.isDestroyed();
+    }
+
+    /**
+     * Must be called from UX thread.
+     */
+    public void toast(String msg) {
+        Toast.makeText(mActivity, msg, Toast.LENGTH_SHORT).show();
+    }
+}
diff --git a/projects/moments/app/src/main/java/io/v/moments/ux/MainActivity.java b/projects/moments/app/src/main/java/io/v/moments/ux/MainActivity.java
index 2e6e21b..f1a91c0 100644
--- a/projects/moments/app/src/main/java/io/v/moments/ux/MainActivity.java
+++ b/projects/moments/app/src/main/java/io/v/moments/ux/MainActivity.java
@@ -24,7 +24,6 @@
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
-import android.widget.CompoundButton;
 import android.widget.Toast;
 
 import com.google.common.util.concurrent.FutureCallback;
@@ -38,6 +37,7 @@
 import io.v.moments.ifc.Advertiser;
 import io.v.moments.ifc.Moment;
 import io.v.moments.ifc.MomentFactory;
+import io.v.moments.ifc.Scanner;
 import io.v.moments.lib.DiscoveredList;
 import io.v.moments.lib.Id;
 import io.v.moments.lib.ObservedList;
@@ -49,8 +49,9 @@
 import io.v.moments.model.Config;
 import io.v.moments.model.FileUtil;
 import io.v.moments.model.MomentFactoryImpl;
+import io.v.moments.model.ScannerImpl;
 import io.v.moments.model.StateStore;
-import io.v.v23.context.VContext;
+import io.v.moments.model.Toaster;
 import io.v.v23.security.Blessings;
 
 /**
@@ -77,8 +78,14 @@
  * adding a (very large) photo to an advertisement as an attribute noticeably
  * increases the time taken between clicking 'advertise' on one device and
  * seeing any evidence of the advertisement on another device.
+ *
+ * TODO: when reloading from prefs, don't change advertise or scan state. only
+ * do that when reloading from bundle. TODO: ScannerImpl should handle the
+ * Update parsing currently done by DiscoveredList. TODO: unit tests. TODO:
+ * Add version number to prefs, ignore and overwrite state if old version (to
+ * avoid need to manually wipe data to avoid crashes).
  */
-public class MainActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {
+public class MainActivity extends AppCompatActivity {
     private static final String TAG = "MainActivity";
     // Android Marshmallow permissions list.
     private static final String[] PERMS = {
@@ -104,13 +111,14 @@
     // See wireUxToDataModel for discussion of the following.
     private StateStore mStateStore;
     private AdvertiserFactory mAdvertiserFactory;
+    private Scanner mScanner;
+    private ScanSwitchHolder mScanSwitchHolder;
     private MomentFactory mMomentFactory;
     private BitMapper mBitMapper;
     private ObservedList<Moment> mLocalMoments;
     private DiscoveredList<Moment> mRemoteMoments;
-    private VContext mScanCtx;
-    private boolean mShouldBeScanning;
     private Id mCurrentPhotoId;
+    private boolean mShouldBeScanning = false;
 
     /**
      * The number used in a moment's file name is called the moments 'ordinal'
@@ -202,6 +210,12 @@
         // represent local moments).
         mRemoteMoments = new DiscoveredList<>(converter, mAdvertiserFactory, mHandler);
 
+        Toaster toaster = new Toaster(this);
+
+        mScanner = new ScannerImpl(mV23Manager, Config.Discovery.QUERY);
+        mScanSwitchHolder = new ScanSwitchHolder(
+                toaster, mScanner, mRemoteMoments);
+
         // Stores app state to bundles, preferences, etc.  The mMomentFactory
         // needed to recreate moments.
         mStateStore = new StateStore(
@@ -217,8 +231,7 @@
         // for local moments when a user wants to advertise them.  The
         // serialExecutor is used to start/stop advertisements in the UX.
         MomentAdapter adapter = new MomentAdapter(
-                mRemoteMoments, mLocalMoments,
-                mAdvertiserFactory, mHandler);
+                mRemoteMoments, mLocalMoments, toaster, mAdvertiserFactory);
 
         // Lets the adapter speed up a bit.
         adapter.setHasStableIds(true);
@@ -292,16 +305,7 @@
                     Config.getWorkingDirectory(this));
             return;
         }
-        mHandler.post(new Runnable() {
-            @Override
-            public void run() {
-                Toast.makeText(
-                        MainActivity.this,
-                        R.string.need_permissions,
-                        Toast.LENGTH_LONG).show();
-                finish();
-            }
-        });
+        toast(getString(R.string.need_permissions));
     }
 
     /**
@@ -329,9 +333,6 @@
             Log.d(TAG, "Loading moments from prefs.");
             mStateStore.prefsLoad(mLocalMoments);
         }
-        if (mShouldBeScanning && !isScanning()) {
-            startScanning();
-        }
     }
 
     @Override
@@ -344,11 +345,14 @@
     protected void onDestroy() {
         super.onDestroy();
         logState("onDestroy");
-        if (isScanning()) {
-            stopScanning();
+        if (mScanner.isScanning()) {
+            mScanner.stop();
         }
         stopAllAdvertising();
+        mV23Manager.shutdown();
         Log.d(TAG, "Destruction complete.");
+        Log.d(TAG, " ");
+        Log.d(TAG, " ");
     }
 
     private void stopAllAdvertising() {
@@ -356,7 +360,7 @@
         for (Advertiser advertiser : mAdvertiserFactory.allAdvertisers()) {
             if (advertiser.isAdvertising()) {
                 try {
-                    advertiser.advertiseStop();
+                    advertiser.stop();
                     count++;
                 } catch (Exception e) {
                     e.printStackTrace();
@@ -366,10 +370,6 @@
                 Log.d(TAG, "A moment was not advertising");
             }
         }
-        if (count > 0) {
-            // This toast noisy if not debugging.
-            // toast("Stopped " + count + " advertisements.");
-        }
         Log.d(TAG, "Stopped " + count + " advertisements.");
     }
 
@@ -391,26 +391,6 @@
         return mgr;
     }
 
-    private boolean isScanning() {
-        return mScanCtx != null;
-    }
-
-    private Runnable setScanningSwitch(final boolean on) {
-        return new Runnable() {
-            @Override
-            public void run() {
-                SwitchCompat sw = (SwitchCompat) findViewById(R.id.action_scan);
-                if (sw != null) {
-                    sw.setChecked(on);
-                    if (on) {
-                        toast("Started scanning.");
-                    } else {
-                        toast("Stopped scanning.");
-                    }
-                }
-            }
-        };
-    }
 
     private void toast(final String msg) {
         mHandler.post(new Runnable() {
@@ -421,61 +401,17 @@
         });
     }
 
-    private void startScanning() {
-        if (isScanning()) {
-            throw new IllegalStateException("Already scanning.");
-        }
-        mSerialExecutor.submit(new Runnable() {
-            @Override
-            public void run() {
-                Log.d(TAG, "Starting scan.");
-                mScanCtx = mV23Manager.scan(Config.QUERY, mRemoteMoments);
-                runOnUiThread(setScanningSwitch(true));
-                Log.d(TAG, "Scan started.");
-            }
-        });
-    }
-
-    private void stopScanning() {
-        if (!isScanning()) {
-            throw new IllegalStateException("Not scanning.");
-        }
-        mSerialExecutor.submit(new Runnable() {
-            @Override
-            public void run() {
-                Log.d(TAG, "Stopping scan.");
-                mScanCtx.cancel();
-                mScanCtx = null;
-                mRemoteMoments.dropAll();
-                runOnUiThread(setScanningSwitch(false));
-                Log.d(TAG, "Scan stopped.");
-            }
-        });
-    }
-
-    @Override
-    public void onCheckedChanged(CompoundButton button, boolean isChecked) {
-        if (button.getId() != R.id.action_scan) {
-            throw new IllegalStateException("Bad scan wiring.");
-        }
-        if (isChecked) {
-            mShouldBeScanning = true;
-            if (!isScanning()) {
-                startScanning();
-            }
-        } else {
-            mShouldBeScanning = false;
-            if (isScanning()) {
-                stopScanning();
-            }
-        }
-    }
-
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+        logState("onCreateOptionsMenu");
         getMenuInflater().inflate(R.menu.menu_main, menu);
         MenuItem item = menu.findItem(R.id.action_scan);
-        ((SwitchCompat) MenuItemCompat.getActionView(item)).setOnCheckedChangeListener(this);
+        SwitchCompat sw = (SwitchCompat) MenuItemCompat.getActionView(item);
+        mScanSwitchHolder.setSwitch(sw);
+        if (mShouldBeScanning && !sw.isChecked()) {
+            sw.setChecked(true);
+        }
         return true;
     }
 
@@ -511,7 +447,7 @@
     protected void onSaveInstanceState(Bundle b) {
         super.onSaveInstanceState(b);
         logState("onSaveInstanceState");
-        b.putBoolean(B.SHOULD_BE_SCANNING, mShouldBeScanning);
+        b.putBoolean(B.SHOULD_BE_SCANNING, mScanner.isScanning());
         mStateStore.bundleSave(b, mRemoteMomentCache.values());
         mStateStore.prefsSave(mLocalMoments);
     }
@@ -520,7 +456,6 @@
         mStateStore.prefsLoad(mLocalMoments);
         if (b == null) {
             Log.d(TAG, "No bundle passed, starting fresh.");
-            mShouldBeScanning = false;
             mRemoteMomentCache.clear();
             return;
         }
diff --git a/projects/moments/app/src/main/java/io/v/moments/ux/MomentAdapter.java b/projects/moments/app/src/main/java/io/v/moments/ux/MomentAdapter.java
index 4edd069..3950771 100644
--- a/projects/moments/app/src/main/java/io/v/moments/ux/MomentAdapter.java
+++ b/projects/moments/app/src/main/java/io/v/moments/ux/MomentAdapter.java
@@ -16,6 +16,7 @@
 import io.v.moments.ifc.Moment;
 import io.v.moments.lib.ObservedList;
 import io.v.moments.model.AdvertiserFactory;
+import io.v.moments.model.Toaster;
 
 /**
  * Stacks two moment lists in a recycler view.
@@ -24,17 +25,17 @@
         implements ListObserver {
     private final ObservedList<Moment> mRemoteMoments;
     private final ObservedList<Moment> mLocalMoments;
+    private final Toaster mToaster;
     private final AdvertiserFactory mAdvertiserFactory;
-    private final Handler mHandler;
 
     public MomentAdapter(ObservedList<Moment> remoteMoments,
                          ObservedList<Moment> localMoments,
-                         AdvertiserFactory advertiserFactory,
-                         Handler handler) {
+                         Toaster toaster,
+                         AdvertiserFactory advertiserFactory) {
         mRemoteMoments = remoteMoments;
         mLocalMoments = localMoments;
+        mToaster = toaster;
         mAdvertiserFactory = advertiserFactory;
-        mHandler = handler;
     }
 
     public void beginObserving() {
@@ -67,7 +68,7 @@
         Context context = parent.getContext();
         LayoutInflater inflater = LayoutInflater.from(context);
         View view = inflater.inflate(R.layout.item_moment, parent, false);
-        return new MomentHolder(view, context, mHandler);
+        return new MomentHolder(view, context, mToaster);
     }
 
     private boolean isRemote(int position) {
diff --git a/projects/moments/app/src/main/java/io/v/moments/ux/MomentHolder.java b/projects/moments/app/src/main/java/io/v/moments/ux/MomentHolder.java
index 1227608..e172f1e 100644
--- a/projects/moments/app/src/main/java/io/v/moments/ux/MomentHolder.java
+++ b/projects/moments/app/src/main/java/io/v/moments/ux/MomentHolder.java
@@ -6,7 +6,6 @@
 
 import android.content.Context;
 import android.content.Intent;
-import android.os.Handler;
 import android.support.v7.widget.RecyclerView;
 import android.support.v7.widget.SwitchCompat;
 import android.util.Log;
@@ -17,14 +16,15 @@
 import android.widget.Toast;
 
 import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.concurrent.CancellationException;
 
 import io.v.moments.R;
 import io.v.moments.ifc.Advertiser;
 import io.v.moments.ifc.Moment;
 import io.v.moments.ifc.Moment.Kind;
 import io.v.moments.ifc.Moment.Style;
+import io.v.moments.model.Toaster;
 
 import static io.v.moments.ifc.Moment.AdState;
 
@@ -33,21 +33,21 @@
  */
 public class MomentHolder extends RecyclerView.ViewHolder {
     private static final String TAG = "MomentHolder";
-    private final TextView authorTextView;
-    private final TextView captionTextView;
-    private final SwitchCompat advertiseButton;
-    private final ImageView imageView;
+    private final TextView mAuthorTextView;
+    private final TextView mCaptionTextView;
+    private final SwitchCompat mAdvertiseButton;
+    private final ImageView mImageView;
     private final Context mContext;
-    private final Handler mHandler;
+    private final Toaster mToaster;
 
-    public MomentHolder(View itemView, Context context, Handler handler) {
+    public MomentHolder(View itemView, Context context, Toaster toaster) {
         super(itemView);
         mContext = context;
-        mHandler = handler;
-        authorTextView = (TextView) itemView.findViewById(R.id.moment_author);
-        captionTextView = (TextView) itemView.findViewById(R.id.moment_caption);
-        advertiseButton = (SwitchCompat) itemView.findViewById(R.id.advertise_button);
-        imageView = (ImageView) itemView.findViewById(R.id.moment_image);
+        mAuthorTextView = (TextView) itemView.findViewById(R.id.moment_author);
+        mCaptionTextView = (TextView) itemView.findViewById(R.id.moment_caption);
+        mAdvertiseButton = (SwitchCompat) itemView.findViewById(R.id.advertise_button);
+        mImageView = (ImageView) itemView.findViewById(R.id.moment_image);
+        mToaster = toaster;
     }
 
     /**
@@ -56,11 +56,11 @@
     public void bind(Moment moment, Advertiser advertiser) {
         final Kind kind = advertiser == null ? Kind.REMOTE : Kind.LOCAL;
         Log.d(TAG, "Binding " + kind + " " + moment);
-        authorTextView.setText(moment.getAuthor());
-        captionTextView.setText(moment.getCaption());
+        mAuthorTextView.setText(moment.getAuthor());
+        mCaptionTextView.setText(moment.getCaption());
         if (moment.hasPhoto(kind, Style.THUMB)) {
-            imageView.setImageBitmap(moment.getPhoto(kind, Style.THUMB));
-            imageView.setOnClickListener(showPhoto(moment, kind));
+            mImageView.setImageBitmap(moment.getPhoto(kind, Style.THUMB));
+            mImageView.setOnClickListener(showPhoto(moment, kind));
         }
         if (moment.hasPhoto(kind, Style.FULL)) {
             if (!moment.hasPhoto(kind, Style.THUMB)) {
@@ -69,14 +69,14 @@
             }
         }
         if (kind.equals(Kind.REMOTE)) {
-            advertiseButton.setVisibility(View.INVISIBLE);
+            mAdvertiseButton.setVisibility(View.INVISIBLE);
         } else {
-            advertiseButton.setText("");
-            advertiseButton.setVisibility(View.VISIBLE);
-            advertiseButton.setEnabled(true);
-            advertiseButton.setOnCheckedChangeListener(
+            mAdvertiseButton.setText("");
+            mAdvertiseButton.setVisibility(View.VISIBLE);
+            mAdvertiseButton.setEnabled(true);
+            mAdvertiseButton.setOnCheckedChangeListener(
                     toggleAdvertising(moment, advertiser));
-            advertiseButton.setChecked(
+            mAdvertiseButton.setChecked(
                     moment.getDesiredAdState().equals(AdState.ON));
         }
     }
@@ -103,99 +103,80 @@
             @Override
             public void onCheckedChanged(CompoundButton button, boolean isChecked) {
                 if (isChecked) {
-                    handleStartAdvertising(moment, advertiser);
+                    if (!advertiser.isAdvertising()) {
+                        advertiser.start(
+                                makeAdvertiseStartCallback(moment),
+                                makeAdvertiseStopCallback(moment));
+                    } else {
+                        Log.d(TAG, "Advertiser already on.");
+                    }
                 } else {
-                    handleStopAdvertising(moment, advertiser);
+                    if (advertiser.isAdvertising()) {
+                        advertiser.stop();
+                    } else {
+                        Log.d(TAG, "Advertiser already off.");
+                    }
                 }
             }
         };
     }
 
-    private void handleStartAdvertising(final Moment moment, final Advertiser advertiser) {
-        moment.setDesiredAdState(AdState.ON);
-        advertiser.advertiseStart(makeAdvertiseCallback(moment));
-    }
-
-    private void handleStopAdvertising(final Moment moment, final Advertiser advertiser) {
-        moment.setDesiredAdState(AdState.OFF);
-        if (advertiser.isAdvertising()) {
-            advertiser.advertiseStop();
-            toast("Stopped advertising " + moment.getCaption());
-        } else {
-            Log.d(TAG, "handleStopAdvertising called, but not advertising.");
-        }
-    }
-
-    private FutureCallback<ListenableFuture<Void>> makeAdvertiseCallback(final Moment moment) {
-        return new FutureCallback<ListenableFuture<Void>>() {
-            private void assureStopped() {
-                moment.setDesiredAdState(AdState.OFF);
-                if (advertiseButton.isChecked()) {
-                    advertiseButton.setChecked(false);
-                }
-            }
+    private FutureCallback<Void> makeAdvertiseStartCallback(final Moment moment) {
+        return new FutureCallback<Void>() {
 
             @Override
-            public void onSuccess(ListenableFuture<Void> result) {
-                mHandler.post(new Runnable() {
-                    @Override
-                    public void run() {
-                        moment.setDesiredAdState(AdState.ON);
-                        advertiseButton.setChecked(true);
-                    }
-                });
-                Futures.addCallback(
-                        result, new FutureCallback<Void>() {
-                            @Override
-                            public void onSuccess(Void result) {
-                                mHandler.post(new Runnable() {
-                                    @Override
-                                    public void run() {
-                                        assureStopped();
-                                    }
-                                });
-                            }
-
-                            @Override
-                            public void onFailure(final Throwable t) {
-                                mHandler.post(new Runnable() {
-                                    @Override
-                                    public void run() {
-                                        assureStopped();
-                                        if (t instanceof java.util.concurrent.CancellationException) {
-                                            // At the time of writing, the only way advertising ends
-                                            // is by throwing this exception, so this is actually
-                                            // a non-exceptional success case.
-                                        } else {
-                                            Log.d(TAG, "Failure to gracefully stop advertising.", t);
-
-                                        }
-                                    }
-                                });
-                            }
-                        }
-                );
+            public void onSuccess(Void result) {
+                Log.d(TAG, "start:onSuccess:" + moment);
+                moment.setDesiredAdState(AdState.ON);
+                if (!mAdvertiseButton.isChecked()) {
+                    mAdvertiseButton.setChecked(true);
+                }
+                mToaster.toast("Advertising " + moment.getCaption());
             }
 
             @Override
             public void onFailure(final Throwable t) {
-                mHandler.post(new Runnable() {
-                    @Override
-                    public void run() {
-                        assureStopped();
-                        Log.d(TAG, "Failure to start advertising " + moment, t);
-                    }
-                });
+                cleanUpPostStop(moment);
+                mToaster.toast("Failure to start advertising " + moment.getCaption());
+                Log.d(TAG, "Failure to start advertising " + moment, t);
             }
         };
     }
 
-    private void toast(final String msg) {
-        mHandler.post(new Runnable() {
+    /**
+     * Verify that advertising is off and that the UX reflects that fact.
+     */
+    private void cleanUpPostStop(final Moment moment) {
+        Log.d(TAG, "cleanUpPostStop");
+        if (mToaster.isDestroyed()) {
+            Log.d(TAG, "The activity is dead, no UX to fix.");
+            return;
+        }
+        moment.setDesiredAdState(AdState.OFF);
+        if (mAdvertiseButton.isChecked()) {
+            Log.d(TAG, "Advertising off, must cleanup UX.");
+            mAdvertiseButton.setChecked(false);
+        }
+    }
+
+    private FutureCallback<Void> makeAdvertiseStopCallback(final Moment moment) {
+        return new FutureCallback<Void>() {
             @Override
-            public void run() {
-                Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show();
+            public void onSuccess(Void result) {
+                cleanUpPostStop(moment);
             }
-        });
+
+            @Override
+            public void onFailure(final Throwable t) {
+                cleanUpPostStop(moment);
+                if (t instanceof CancellationException) {
+                    // At the time of writing, the only way advertising ends
+                    // is by throwing this exception, so this is actually
+                    // a non-exceptional success case.
+                } else {
+                    Log.d(TAG, "Failure to gracefully stop advertising.", t);
+                }
+            }
+        };
     }
 }
diff --git a/projects/moments/app/src/main/java/io/v/moments/ux/ScanSwitchHolder.java b/projects/moments/app/src/main/java/io/v/moments/ux/ScanSwitchHolder.java
new file mode 100644
index 0000000..c5187f4
--- /dev/null
+++ b/projects/moments/app/src/main/java/io/v/moments/ux/ScanSwitchHolder.java
@@ -0,0 +1,153 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.moments.ux;
+
+import android.app.Activity;
+import android.support.v7.widget.SwitchCompat;
+import android.util.Log;
+import android.widget.CompoundButton;
+import android.widget.Toast;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.concurrent.CancellationException;
+
+import io.v.moments.ifc.Moment;
+import io.v.moments.ifc.Scanner;
+import io.v.moments.lib.DiscoveredList;
+import io.v.moments.model.Toaster;
+import io.v.v23.InputChannelCallback;
+import io.v.v23.discovery.Update;
+
+/**
+ * Manages scanning UX.
+ *
+ * The callbacks provided will be run on the UX thread, so its safe to perform
+ * UX operations in the callbacks.
+ */
+public class ScanSwitchHolder implements CompoundButton.OnCheckedChangeListener {
+    private static final String TAG = "ScanSwitchHolder";
+    private final Scanner mScanner;
+    private final Toaster mToaster;
+    private final DiscoveredList<Moment> mRemoteMoments;
+    private SwitchCompat mSwitch;
+
+    public ScanSwitchHolder(
+            Toaster toaster, Scanner scanner,
+            DiscoveredList<Moment> remoteMoments) {
+        if (toaster == null) {
+            throw new IllegalArgumentException("Null activity.");
+        }
+        if (scanner == null) {
+            throw new IllegalArgumentException("Null scanner.");
+        }
+        if (remoteMoments == null) {
+            throw new IllegalArgumentException("Null remoteMoments.");
+        }
+        mToaster = toaster;
+        mScanner = scanner;
+        mRemoteMoments = remoteMoments;
+    }
+
+    @Override
+    public void onCheckedChanged(CompoundButton button, boolean isChecked) {
+        Log.d(TAG, "onCheckedChanged with checked = " + isChecked);
+        if (button.getId() != mSwitch.getId()) {
+            throw new IllegalStateException("Bad scan wiring.");
+        }
+        if (isChecked) {
+            if (mScanner.isScanning()) {
+                Log.d(TAG, "Asked to start scanning, but already scanning.");
+                return;
+            }
+            mScanner.start(makeStartupCallback(), makeUpdateCallback(), makeCompletionCallback());
+        } else {
+            if (!mScanner.isScanning()) {
+                Log.d(TAG, "Asked to stop scanning, but already not scanning.");
+                return;
+            }
+            mScanner.stop();
+        }
+    }
+
+    public void setSwitch(SwitchCompat theSwitch) {
+        mSwitch = theSwitch;
+        theSwitch.setOnCheckedChangeListener(this);
+    }
+
+    private FutureCallback<Void> makeStartupCallback() {
+        return new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+                Log.d(TAG, "Started scanning.");
+                mToaster.toast("Scanning.");
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                Log.d(TAG, "Unable to scan.", t);
+                if (mSwitch.isChecked()) {
+                    Log.d(TAG, "On failure settings switch to off.");
+                    mSwitch.setChecked(false);
+                }
+                mToaster.toast("Unable to scan.");
+            }
+        };
+    }
+
+    private InputChannelCallback<Update> makeUpdateCallback() {
+        return new InputChannelCallback<Update>() {
+            @Override
+            public ListenableFuture<Void> onNext(Update result) {
+                mRemoteMoments.scanUpdateReceived(result);
+                return Futures.immediateFuture(null);
+            }
+        };
+    }
+
+    /** Verify that scanning is off and that the UX reflects that fact. */
+    private void cleanUpPostStop() {
+        Log.d(TAG, "cleanUpPostStop");
+        if (mScanner.isScanning()) {
+            throw new IllegalStateException("Scanning should be off.");
+        }
+        if (mToaster.isDestroyed()) {
+            Log.d(TAG, "The activity is dead, no UX to fix.");
+            return;
+        }
+        mRemoteMoments.dropAll();
+        if (mSwitch.isChecked()) {
+            // The button might be checked if the scan expired.
+            // The following call will trigger onCheckedChanged(false), but
+            // shouldn't do more than change the UX because everything is
+            // already shutdown.
+            mSwitch.setChecked(false);
+        }
+    }
+
+    private FutureCallback<Void> makeCompletionCallback() {
+        return new FutureCallback<Void>() {
+            /** Likely this is never called. */
+            @Override
+            public void onSuccess(Void result) {
+                cleanUpPostStop();
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                if (t instanceof CancellationException) {
+                    // At the time of writing, the only way scanning ends
+                    // is by throwing this exception, so this is actually
+                    // a non-exceptional success case.
+                    cleanUpPostStop();
+                } else {
+                    Log.d(TAG, "Failure to gracefully stop scanning.", t);
+                }
+            }
+        };
+    }
+}
diff --git a/projects/moments/app/src/test/java/io/v/moments/model/AdvertiserImplTest.java b/projects/moments/app/src/test/java/io/v/moments/model/AdvertiserImplTest.java
index c97d9a4..485bdf7 100644
--- a/projects/moments/app/src/test/java/io/v/moments/model/AdvertiserImplTest.java
+++ b/projects/moments/app/src/test/java/io/v/moments/model/AdvertiserImplTest.java
@@ -4,6 +4,8 @@
 
 package io.v.moments.model;
 
+import com.google.common.util.concurrent.FutureCallback;
+
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -61,16 +63,36 @@
     ServerStatus mServerStatus;
     @Mock
     VContext mContext;
+    @Mock
+    FutureCallback<Void> mStartupCallback;
+    @Mock
+    FutureCallback<Void> mShutdownCallback;
+    @Mock
+    FutureCallback<Void> mStartupCallback2;
+    @Mock
+    FutureCallback<Void> mShutdownCallback2;
 
     @Captor
     ArgumentCaptor<Service> mAdvertisement;
     @Captor
     ArgumentCaptor<List<BlessingPattern>> mBlessingList;
+    @Captor
+    ArgumentCaptor<FutureCallback<VContext>> mV23StartupCallback;
+    @Captor
+    ArgumentCaptor<FutureCallback<Void>> mV23ShutdownCallback;
+    @Captor
+    ArgumentCaptor<Throwable> mThrowable;
 
     Attributes mAttrs;
 
     AdvertiserImpl mAdvertiser;
 
+    static Map<String, String> makeFakeAttributes() {
+        Map<String, String> result = new HashMap<>();
+        result.put("color", "teal");
+        return result;
+    }
+
     @Before
     public void setup() throws Exception {
         mAttrs = new Attributes(makeFakeAttributes());
@@ -90,11 +112,6 @@
         when(mMoment.toString()).thenReturn(MOMENT_NAME);
         when(mMoment.makeAttributes()).thenReturn(mAttrs);
 
-        List<BlessingPattern> list = any();
-        when(mV23Manager.advertise(
-                any(Service.class),
-                list)).thenReturn(mContext);
-
         mAdvertiser = new AdvertiserImpl(mV23Manager, mMoment);
     }
 
@@ -115,57 +132,68 @@
     @Test
     public void advertiseStartSuccess() {
         assertFalse(mAdvertiser.isAdvertising());
-        mAdvertiser.advertiseStart();
+        mAdvertiser.start(mStartupCallback, mShutdownCallback);
 
-        verify(mV23Manager).advertise(
-                mAdvertisement.capture(),
-                mBlessingList.capture());
-
-        assertEquals(0, mBlessingList.getValue().size());
+        verifyAdvertiseCall();
 
         Service service = mAdvertisement.getValue();
         assertEquals(ID.toString(), service.getInstanceId());
         assertEquals(MOMENT_NAME, service.getInstanceName());
-        assertEquals(Config.INTERFACE_NAME, service.getInterfaceName());
+        assertEquals(Config.Discovery.INTERFACE_NAME, service.getInterfaceName());
         assertSame(mAttrs, service.getAttrs());
 
         List<String> addresses = service.getAddrs();
         assertTrue(addresses.contains(ADDRESS));
         assertEquals(1, addresses.size());
 
+        assertSame(mV23ShutdownCallback.getValue(), mShutdownCallback);
+
+        assertFalse(mAdvertiser.isAdvertising());
+        activateAdvertising();
         assertTrue(mAdvertiser.isAdvertising());
     }
 
+    private void verifyAdvertiseCall() {
+        verify(mV23Manager).advertise(
+                mAdvertisement.capture(),
+                eq(Config.Discovery.DURATION),
+                mV23StartupCallback.capture(),
+                mV23ShutdownCallback.capture());
+    }
+
+    // Make the transition to advertising by returning a context from V23.
+    private void activateAdvertising() {
+        mV23StartupCallback.getValue().onSuccess(mContext);
+        // Verify that the UX will be impacted.
+        verify(mStartupCallback).onSuccess(null);
+    }
+
     @Test
     public void advertiseStartFailure() {
         assertFalse(mAdvertiser.isAdvertising());
-        mAdvertiser.advertiseStart();
+        mAdvertiser.start(mStartupCallback, mShutdownCallback);
+        verifyAdvertiseCall();
+        activateAdvertising();
         assertTrue(mAdvertiser.isAdvertising());
-        mThrown.expect(IllegalStateException.class);
-        mThrown.expectMessage("Already advertising.");
-        mAdvertiser.advertiseStart();
+        mAdvertiser.start(mStartupCallback2, mShutdownCallback2);
+        verify(mStartupCallback2).onFailure(mThrowable.capture());
+        assertEquals("Already advertising.", mThrowable.getValue().getMessage());
     }
 
     @Test
-    public void advertiseStopFailure() {
+    public void advertiseStopDoesNotThrowIfNotAdvertising() {
         mThrown.expect(IllegalStateException.class);
         mThrown.expectMessage("Not advertising.");
-        mAdvertiser.advertiseStop();
+        mAdvertiser.stop();
     }
 
     @Test
     public void advertiseStopSuccess() throws Exception {
         advertiseStartSuccess();
         assertTrue(mAdvertiser.isAdvertising());
-        mAdvertiser.advertiseStop();
+        mAdvertiser.stop();
         verify(mContext).cancel();
         verify(mServerContext).cancel();
         assertFalse(mAdvertiser.isAdvertising());
     }
-
-    static Map<String, String> makeFakeAttributes() {
-        Map<String, String> result = new HashMap<>();
-        result.put("color", "teal");
-        return result;
-    }
 }