Merge "syncslides: Implement discovery (mostly)."
diff --git a/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java b/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
index e3284f9..a3b3cca 100644
--- a/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
+++ b/android/app/src/main/java/io/v/syncslides/DeckChooserFragment.java
@@ -38,6 +38,7 @@
 import java.util.UUID;
 
 import io.v.syncslides.db.DB;
+import io.v.syncslides.discovery.RpcPresentationDiscovery;
 import io.v.syncslides.lib.DeckImporter;
 import io.v.syncslides.model.Deck;
 import io.v.syncslides.model.DeckImpl;
@@ -98,7 +99,9 @@
                         mLayoutManager.requestLayout();
                     }
                 });
-        mAdapter = new DeckListAdapter(DB.Singleton.get());
+        mAdapter = new DeckListAdapter(
+                DB.Singleton.get(),
+                new RpcPresentationDiscovery(V23.Singleton.get().getVContext()));
 
         return rootView;
     }
diff --git a/android/app/src/main/java/io/v/syncslides/DeckListAdapter.java b/android/app/src/main/java/io/v/syncslides/DeckListAdapter.java
index 81e59e1..58adc47 100644
--- a/android/app/src/main/java/io/v/syncslides/DeckListAdapter.java
+++ b/android/app/src/main/java/io/v/syncslides/DeckListAdapter.java
@@ -21,9 +21,11 @@
 import java.util.Locale;
 
 import io.v.syncslides.db.DB;
+import io.v.syncslides.discovery.PresentationDiscovery;
 import io.v.syncslides.model.Deck;
 import io.v.syncslides.model.DynamicList;
 import io.v.syncslides.model.ListListener;
+import io.v.syncslides.model.PresentationAdvertisement;
 import io.v.v23.verror.VException;
 
 /**
@@ -35,25 +37,35 @@
     private static final String TAG = "DeckListAdapter";
 
     private final DB mDB;
+    private final PresentationDiscovery mDiscovery;
+    private DynamicList<PresentationAdvertisement> mLiveDecks;
     private DynamicList<Deck> mDecks;
+    private OffsetListener mOffsetListener;
 
-    public DeckListAdapter(DB db) {
+    public DeckListAdapter(DB db, PresentationDiscovery discovery) {
         mDB = db;
+        mDiscovery = discovery;
     }
 
     /**
      * Starts background monitoring of the underlying data.
      */
     public void start() {
+        mLiveDecks = mDiscovery.scan();
+        mLiveDecks.addListener(this);
         mDecks = mDB.getDecks();
-        mDecks.addListener(this);
+        mOffsetListener = new OffsetListener();
+        mDecks.addListener(mOffsetListener);
     }
 
     /**
      * Stops any background monitoring of the underlying data.
      */
     public void stop() {
-        mDecks.removeListener(this);
+        mLiveDecks.removeListener(this);
+        mLiveDecks = null;
+        mDecks.removeListener(mOffsetListener);
+        mOffsetListener = null;
         mDecks = null;
     }
 
@@ -66,44 +78,55 @@
 
     @Override
     public void onBindViewHolder(final ViewHolder holder, final int deckIndex) {
-        final Deck deck = mDecks.get(deckIndex);
+        final Deck deck;
+        // If the position is less than the number of live presentation decks, get deck card from
+        // there (and don't allow the user to delete the deck). If not, get the card from the DB.
+        if (deckIndex < mLiveDecks.getItemCount()) {
+            deck = mLiveDecks.get(deckIndex).getDeck();
+            holder.mToolbarLiveNow.setVisibility(View.VISIBLE);
+            holder.mToolbarLastOpened.setVisibility(View.GONE);
+            holder.mToolbar.getMenu().clear();
+            // TODO(kash): Click handler to join the presentation's syncgroup and start the
+            // PresentationActivity.
+        } else {
+            deck = mDecks.get(deckIndex - mLiveDecks.getItemCount());
 
-        // TODO(afergan): Set actual date here.
-        final Calendar cal = Calendar.getInstance();
-        holder.mToolbarLastOpened.setText("Opened on "
-                + cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.US) + " "
-                + cal.get(Calendar.DAY_OF_MONTH) + ", " + cal.get(Calendar.YEAR));
+            // TODO(afergan): Set actual date here.
+            final Calendar cal = Calendar.getInstance();
+            holder.mToolbarLastOpened.setText("Opened on "
+                    + cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.US) + " "
+                    + cal.get(Calendar.DAY_OF_MONTH) + ", " + cal.get(Calendar.YEAR));
 
-        holder.mToolbarLastOpened.setVisibility(View.VISIBLE);
-        holder.mToolbarLiveNow.setVisibility(View.GONE);
-        holder.mToolbar.setOnMenuItemClickListener(item -> {
-            switch (item.getItemId()) {
-                case R.id.action_delete_deck:
-                    // TODO(kash): Implement delete.
-                    // mDB.deleteDeck(deck.getId());
-                    return true;
-            }
-            return false;
-        });
-
+            holder.mToolbarLastOpened.setVisibility(View.VISIBLE);
+            holder.mToolbarLiveNow.setVisibility(View.GONE);
+            holder.mToolbar.setOnMenuItemClickListener(item -> {
+                switch (item.getItemId()) {
+                    case R.id.action_delete_deck:
+                        // TODO(kash): Implement delete.
+                        // mDB.deleteDeck(deck.getId());
+                        return true;
+                }
+                return false;
+            });
+            holder.mThumb.setOnClickListener(v -> {
+                Log.d(TAG, "Clicking through to PresentationActivity.");
+                String sessionId;
+                try {
+                    sessionId = mDB.createSession(deck.getId());
+                } catch (VException e) {
+                    handleError(v.getContext(), "Could not view deck.", e);
+                    return;
+                }
+                startPresentationActivity(v.getContext(), sessionId);
+            });
+        }
         holder.mToolbarTitle.setText(deck.getTitle());
         holder.mThumb.setImageBitmap(deck.getThumb());
-        holder.mThumb.setOnClickListener(v -> {
-            Log.d(TAG, "Clicking through to PresentationActivity.");
-            String sessionId;
-            try {
-                sessionId = mDB.createSession(deck.getId());
-            } catch (VException e) {
-                handleError(v.getContext(), "Could not view deck.", e);
-                return;
-            }
-            startPresentationActivity(v.getContext(), sessionId);
-        });
     }
 
     @Override
     public int getItemCount() {
-        return mDecks.getItemCount();
+        return mLiveDecks.getItemCount() + mDecks.getItemCount();
     }
 
     @Override
@@ -131,6 +154,36 @@
         }
     }
 
+    /**
+     * Offsets the position notifications from mDecks by the size of mLiveDecks.
+     */
+    private class OffsetListener implements ListListener {
+        @Override
+        public void notifyDataSetChanged() {
+            DeckListAdapter.this.notifyDataSetChanged();
+        }
+
+        @Override
+        public void notifyItemChanged(int position) {
+            DeckListAdapter.this.notifyItemChanged(mLiveDecks.getItemCount() + position);
+        }
+
+        @Override
+        public void notifyItemInserted(int position) {
+            DeckListAdapter.this.notifyItemInserted(mLiveDecks.getItemCount() + position);
+        }
+
+        @Override
+        public void notifyItemRemoved(int position) {
+            DeckListAdapter.this.notifyItemRemoved(mLiveDecks.getItemCount() + position);
+        }
+
+        @Override
+        public void onError(Exception e) {
+            DeckListAdapter.this.onError(e);
+        }
+    }
+
     private void startPresentationActivity(Context ctx, String sessionId) {
         Intent intent = new Intent(ctx, PresentationActivity.class);
         intent.putExtra(PresentationActivity.SESSION_ID_KEY, sessionId);
diff --git a/android/app/src/main/java/io/v/syncslides/PresentationActivity.java b/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
index c56fe3e..afd5af9 100644
--- a/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
+++ b/android/app/src/main/java/io/v/syncslides/PresentationActivity.java
@@ -12,8 +12,16 @@
 import android.view.View;
 import android.widget.Toast;
 
+import java.util.UUID;
+
 import io.v.syncslides.db.DB;
+import io.v.syncslides.discovery.PresentationDiscovery;
+import io.v.syncslides.discovery.RpcPresentationDiscovery;
+import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.Person;
+import io.v.syncslides.model.PresentationAdvertisement;
 import io.v.syncslides.model.Session;
+import io.v.v23.context.VContext;
 import io.v.v23.verror.VException;
 
 /**
@@ -139,6 +147,29 @@
         transaction.commit();
     }
 
+    public void startPresentation() {
+        try {
+            // TODO(kash): Move this into Session so that the advertisement happens automatically
+            // when the session is loaded.  This is a big hack just so I can test advertise/scan
+            // functionality.
+            String presentationId = UUID.randomUUID().toString();
+            Person person = new Person(
+                    V23.Singleton.get().getBlessings().toString(),
+                    SignInActivity.getUserName(this));
+            Deck deck = DB.Singleton.get().getDeck(mSession.getDeckId());
+            PresentationAdvertisement advertisement = new PresentationAdvertisement(
+                    presentationId, person, deck, "dummy syncgroup name");
+            V23 v23 = V23.Singleton.get();
+            // TODO(kash): Session needs to stop advertising.
+            VContext advertiseContext = v23.getVContext().withCancel();
+            RpcPresentationDiscovery discovery = new RpcPresentationDiscovery(v23.getVContext());
+            discovery.advertise(advertiseContext, advertisement);
+        } catch (VException e) {
+            handleError("Could not start presentation", e);
+            return;
+        }
+    }
+
     private void handleError(String msg, Throwable throwable) {
         Log.e(TAG, msg + ": " + Log.getStackTraceString(throwable));
         Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
diff --git a/android/app/src/main/java/io/v/syncslides/SlideListFragment.java b/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
index 9c382c3..a967967 100644
--- a/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
+++ b/android/app/src/main/java/io/v/syncslides/SlideListFragment.java
@@ -66,16 +66,14 @@
         // If there is not already a presentation for this session,
         // make the FAB visible so that clicking it will start a new
         // presentation.
-        if (mSession.getPresentationId() == null) {
+        if (mSession.getPresentationId() == null || mSession.getPresentationId().equals("")) {
             final FloatingActionButton fab = (FloatingActionButton) rootView.findViewById(
                     R.id.play_presentation_fab);
             fab.setVisibility(View.VISIBLE);
             fab.setOnClickListener(v -> {
-                // TODO(kash): Implement me.
-//                    mRole = Role.PRESENTER;
-//                    fab.setVisibility(View.INVISIBLE);
-//                    PresentationActivity activity = (PresentationActivity) v.getContext();
-//                    activity.startPresentation();
+                fab.setVisibility(View.INVISIBLE);
+                PresentationActivity activity = (PresentationActivity) v.getContext();
+                activity.startPresentation();
             });
         }
         mRecyclerView = (RecyclerView) rootView.findViewById(R.id.slide_list);
diff --git a/android/app/src/main/java/io/v/syncslides/db/DB.java b/android/app/src/main/java/io/v/syncslides/db/DB.java
index f437455..1e9f2e3 100644
--- a/android/app/src/main/java/io/v/syncslides/db/DB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/DB.java
@@ -73,4 +73,6 @@
      */
     ListenableFuture<Void> importDeck(Deck deck, Slide[] slides);
 
+    // TODO(kash): Remove this when moving advertisement functionality into Session.
+    Deck getDeck(String deckId) throws VException;
 }
diff --git a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
index a319459..6047a77 100644
--- a/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
+++ b/android/app/src/main/java/io/v/syncslides/db/SyncbaseDB.java
@@ -33,6 +33,7 @@
 import io.v.syncslides.InitException;
 import io.v.syncslides.V23;
 import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.DeckImpl;
 import io.v.syncslides.model.DynamicList;
 import io.v.syncslides.model.NoopList;
 import io.v.syncslides.model.Session;
@@ -207,6 +208,14 @@
         });
     }
 
+    @Override
+    public Deck getDeck(String deckId) throws VException {
+        Table decks = mDB.getTable(SyncbaseDB.DECKS_TABLE);
+        VDeck vDeck = (VDeck) sync(decks.get(mVContext, deckId, VDeck.class));
+        return new DeckImpl(vDeck.getTitle(), vDeck.getThumbnail(), deckId);
+    }
+
+
     private void putDeck(Deck deck) throws VException {
         Log.i(TAG, String.format("Adding deck %s, %s", deck.getId(), deck.getTitle()));
         Table decks = mDB.getTable(DECKS_TABLE);
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/ClientFactory.java b/android/app/src/main/java/io/v/syncslides/discovery/ClientFactory.java
new file mode 100644
index 0000000..4396ef0
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/ClientFactory.java
@@ -0,0 +1,12 @@
+// 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.syncslides.discovery;
+
+/**
+ * Makes clients to LivePresentation services.
+ */
+interface ClientFactory {
+    LivePresentationClient make(String name);
+}
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/PresentationDiscovery.java b/android/app/src/main/java/io/v/syncslides/discovery/PresentationDiscovery.java
index b9357ee..b7a9c29 100644
--- a/android/app/src/main/java/io/v/syncslides/discovery/PresentationDiscovery.java
+++ b/android/app/src/main/java/io/v/syncslides/discovery/PresentationDiscovery.java
@@ -7,6 +7,7 @@
 import io.v.syncslides.model.DynamicList;
 import io.v.syncslides.model.PresentationAdvertisement;
 import io.v.v23.context.VContext;
+import io.v.v23.verror.VException;
 
 /**
  * Handles advertising and scanning for live presentations.
@@ -24,5 +25,5 @@
      *                 to stop advertising.
      * @param advertisement details of the presentation.
      */
-    void advertise(VContext vContext, PresentationAdvertisement advertisement);
+    void advertise(VContext vContext, PresentationAdvertisement advertisement) throws VException;
 }
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/RpcAdvertiser.java b/android/app/src/main/java/io/v/syncslides/discovery/RpcAdvertiser.java
new file mode 100644
index 0000000..47d5caf
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/RpcAdvertiser.java
@@ -0,0 +1,81 @@
+// 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.syncslides.discovery;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.v.android.v23.V;
+import io.v.syncslides.db.VDeck;
+import io.v.syncslides.db.VPerson;
+import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.Person;
+import io.v.syncslides.model.PresentationAdvertisement;
+import io.v.v23.context.VContext;
+import io.v.v23.discovery.Attributes;
+import io.v.v23.discovery.Service;
+import io.v.v23.discovery.VDiscovery;
+import io.v.v23.naming.Endpoint;
+import io.v.v23.rpc.Server;
+import io.v.v23.rpc.ServerCall;
+import io.v.v23.security.BlessingPattern;
+import io.v.v23.security.VSecurity;
+import io.v.v23.verror.VException;
+
+/**
+ * Advertises a live presentation using Vanadium Discovery.  Additionally, it starts an
+ * RPC service to provide extra details about the presentation to scanners.
+ */
+class RpcAdvertiser {
+    private static final List<BlessingPattern> NO_PATTERNS = new ArrayList<>();
+    private static final String NO_MOUNT = "";
+
+    private final VContext mVContext;
+    private final VDiscovery mDiscovery;
+    private final PresentationAdvertisement mAdvertisement;
+
+    RpcAdvertiser(VContext vContext, VDiscovery discovery,
+                  PresentationAdvertisement advertisement) {
+        mVContext = vContext;
+        mDiscovery = discovery;
+        mAdvertisement = advertisement;
+    }
+
+    void start() throws VException {
+        Server server = V.getServer(
+                V.withNewServer(
+                        mVContext, NO_MOUNT, new MyLivePresentationServer(),
+                        VSecurity.newAllowEveryoneAuthorizer()));
+        List<String> addresses = new ArrayList<>();
+        for (Endpoint point : server.getStatus().getEndpoints()) {
+            addresses.add(point.toString());
+        }
+        // InstanceId and InstanceName are left unset.
+        Service service = new Service();
+        service.setAddrs(addresses);
+        service.setInterfaceName(RpcPresentationDiscovery.INTERFACE_NAME);
+        Attributes attrs = new Attributes();
+        // RpcScanner will filter out any advertisements that have the same device id
+        // because the user doesn't want to see his own advertisements.
+        attrs.put(RpcPresentationDiscovery.DEVICE_ID_ATTRIBUTE,
+                RpcPresentationDiscovery.DEVICE_ID);
+        service.setAttrs(attrs);
+        mDiscovery.advertise(mVContext, service, NO_PATTERNS);
+    }
+
+    private class MyLivePresentationServer implements LivePresentationServer {
+        @Override
+        public PresentationInfo getInfo(VContext ctx, ServerCall call) throws VException {
+            Person person = mAdvertisement.getPresenter();
+            VPerson vPerson = new VPerson(person.getBlessing(), person.getName());
+
+            Deck deck = mAdvertisement.getDeck();
+            VDeck vDeck = new VDeck(deck.getTitle(), deck.getThumbData());
+
+            return new PresentationInfo(
+                    vPerson, deck.getId(), vDeck, mAdvertisement.getSyncgroupName());
+        }
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/RpcClientFactory.java b/android/app/src/main/java/io/v/syncslides/discovery/RpcClientFactory.java
new file mode 100644
index 0000000..078f50c
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/RpcClientFactory.java
@@ -0,0 +1,12 @@
+// 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.syncslides.discovery;
+
+class RpcClientFactory implements ClientFactory {
+    @Override
+    public LivePresentationClient make(String name) {
+        return LivePresentationClientFactory.getLivePresentationClient(name);
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/RpcPresentationDiscovery.java b/android/app/src/main/java/io/v/syncslides/discovery/RpcPresentationDiscovery.java
new file mode 100644
index 0000000..a29932e
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/RpcPresentationDiscovery.java
@@ -0,0 +1,48 @@
+// 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.syncslides.discovery;
+
+import java.util.UUID;
+
+import io.v.android.v23.V;
+import io.v.syncslides.model.DynamicList;
+import io.v.syncslides.model.PresentationAdvertisement;
+import io.v.v23.context.VContext;
+import io.v.v23.verror.VException;
+
+/**
+ * Uses a combination of Vanadium Discovery and RPC to advertise/scan for
+ * live presentations.
+ */
+public class RpcPresentationDiscovery implements PresentationDiscovery {
+
+    static final String INTERFACE_NAME = "v.io/syncslides/discovery.LivePresentation";
+
+    /**
+     * Passed to both RpcScanner and RpcAdvertiser to ensure that advertisements from this
+     * device don't show up in the scan.
+     */
+    static final String DEVICE_ID_ATTRIBUTE = "device_id";
+    static final String DEVICE_ID = UUID.randomUUID().toString();
+
+    private final VContext mContext;
+
+    public RpcPresentationDiscovery(VContext context) {
+        mContext = context;
+    }
+
+    @Override
+    public DynamicList<PresentationAdvertisement> scan() {
+        return new RpcScanner(mContext, V.getDiscovery(mContext), new RpcClientFactory());
+    }
+
+    @Override
+    public void advertise(VContext vContext, PresentationAdvertisement advertisement)
+            throws VException {
+        RpcAdvertiser advertiser =
+                new RpcAdvertiser(vContext, V.getDiscovery(vContext), advertisement);
+        advertiser.start();
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/RpcScanner.java b/android/app/src/main/java/io/v/syncslides/discovery/RpcScanner.java
new file mode 100644
index 0000000..1656b60
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/RpcScanner.java
@@ -0,0 +1,173 @@
+// 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.syncslides.discovery;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import io.v.syncslides.db.VDeck;
+import io.v.syncslides.model.Deck;
+import io.v.syncslides.model.DeckImpl;
+import io.v.syncslides.model.DynamicList;
+import io.v.syncslides.model.ListListener;
+import io.v.syncslides.model.Person;
+import io.v.syncslides.model.PresentationAdvertisement;
+import io.v.v23.InputChannel;
+import io.v.v23.InputChannels;
+import io.v.v23.VIterable;
+import io.v.v23.context.CancelableVContext;
+import io.v.v23.context.VContext;
+import io.v.v23.discovery.Attributes;
+import io.v.v23.discovery.Service;
+import io.v.v23.discovery.Update;
+import io.v.v23.discovery.VDiscovery;
+import io.v.v23.verror.VException;
+
+import static io.v.v23.VFutures.sync;
+
+/**
+ * Scans for live presentations using Vanadium Discovery.  When it does find those advertisements,
+ * it issues an RPC to fetch the details.  The scanning is started/stopped when the first/last
+ * listener is added/removed.
+ */
+class RpcScanner implements DynamicList<PresentationAdvertisement> {
+    private static final String TAG = "RpcScanner";
+    private static final String QUERY = "v.InterfaceName=\"" +
+            RpcPresentationDiscovery.INTERFACE_NAME + "\"";
+
+    private final VContext mBaseContext;
+    private final VDiscovery mDiscovery;
+    private final ClientFactory mClientFactory;
+    private final Set<ListListener> mListeners;
+    private final ExecutorService mExecutor;
+    private final List<PresentationAdvertisement> mElems;
+    private final Handler mHandler;
+    private CancelableVContext mCurrentContext;
+
+    public RpcScanner(VContext context, VDiscovery discovery, ClientFactory clientFactory) {
+        mBaseContext = context;
+        mDiscovery = discovery;
+        mClientFactory = clientFactory;
+        mListeners = Sets.newHashSet();
+        mExecutor = Executors.newSingleThreadExecutor();
+        mElems = Lists.newArrayList();
+        mHandler = new Handler(Looper.getMainLooper());
+    }
+
+    @Override
+    public int getItemCount() {
+        return mElems.size();
+    }
+
+    @Override
+    public PresentationAdvertisement get(int i) {
+        return mElems.get(i);
+    }
+
+    @Override
+    public void addListener(final ListListener listener) {
+        mListeners.add(listener);
+        if (mListeners.size() == 1) {
+            // First listener.  Start the thread.
+            mCurrentContext = mBaseContext.withCancel();
+            mExecutor.submit(() -> scan());
+        }
+        mHandler.post(() -> listener.notifyDataSetChanged());
+    }
+
+    @Override
+    public void removeListener(ListListener listener) {
+        mListeners.remove(listener);
+        if (mListeners.isEmpty()) {
+            // Stop the scan via cancel.
+            mCurrentContext.cancel();
+            mCurrentContext = null;
+            mHandler.removeCallbacksAndMessages(null);
+        }
+    }
+
+    private void addAdvertisement(PresentationAdvertisement ad) {
+        mElems.add(ad);
+        for (ListListener listener : mListeners) {
+            listener.notifyItemInserted(mElems.size() - 1);
+        }
+    }
+
+    private void removeAdvertisement(String id) {
+        for (int i = 0; i < mElems.size(); i++) {
+            if (id.equals(mElems.get(i).getId())) {
+                mElems.remove(i);
+                for (ListListener listener : mListeners) {
+                    listener.notifyItemRemoved(i);
+                }
+            }
+        }
+    }
+
+    private void handleError(Exception e) {
+        for (ListListener listener : mListeners) {
+            listener.onError(e);
+        }
+    }
+
+    // Runs in a background thread.
+    private void scan() {
+        InputChannel<Update> updateChannel = mDiscovery.scan(mCurrentContext, QUERY);
+        final VIterable<Update> updates = InputChannels.asIterable(updateChannel);
+        for (Update update : updates) {
+            if (update instanceof Update.Found) {
+                Service descriptor = ((Update.Found) update).getElem().getService();
+                Attributes attrs = descriptor.getAttrs();
+                String remoteDevice = attrs.get(RpcPresentationDiscovery.DEVICE_ID_ATTRIBUTE);
+                if (remoteDevice != null &&
+                        remoteDevice.equals(RpcPresentationDiscovery.DEVICE_ID)) {
+                    // Self advertisement.
+                    continue;
+                }
+                fetchDetails(descriptor);
+            } else {
+                String id = ((Update.Lost) update).getElem().getInstanceId();
+                mHandler.post(() -> removeAdvertisement(id));
+            }
+        }
+        if (updates.error() != null) {
+            mHandler.post(() -> handleError(updates.error()));
+        }
+    }
+
+    // Runs in a background thread.
+    private void fetchDetails(Service descriptor) {
+        List<String> addresses = descriptor.getAddrs();
+        if (addresses.isEmpty()) {
+            Log.e(TAG, "Descriptor " + descriptor.getInstanceId() + " had no addrs.");
+            return;
+        }
+        String name = "/" + addresses.get(0);
+        LivePresentationClient client = mClientFactory.make(name);
+        VContext rpcContext = mCurrentContext.withCancel();
+        PresentationInfo info;
+        try {
+            info = sync(client.getInfo(rpcContext));
+        } catch (VException e) {
+            Log.e(TAG, "Failed to fetch presentation info: " + e.getMessage());
+            return;
+        }
+        VDeck vDeck = info.getDeck();
+        Deck deck = new DeckImpl(vDeck.getTitle(), vDeck.getThumbnail(), info.getDeckId());
+        Person person = new Person(info.getPerson().getBlessing(), info.getPerson().getName());
+        final PresentationAdvertisement ad = new PresentationAdvertisement(
+                descriptor.getInstanceId(), person, deck, info.getSyncgroupName());
+        mHandler.post(() -> addAdvertisement(ad));
+    }
+}
diff --git a/android/app/src/main/java/io/v/syncslides/discovery/presentation.vdl b/android/app/src/main/java/io/v/syncslides/discovery/presentation.vdl
new file mode 100644
index 0000000..5322535
--- /dev/null
+++ b/android/app/src/main/java/io/v/syncslides/discovery/presentation.vdl
@@ -0,0 +1,20 @@
+// 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 discovery
+
+import (
+    "io/v/syncslides/db"
+)
+
+type PresentationInfo struct {
+    Person db.VPerson
+    DeckId string
+    Deck db.VDeck
+    SyncgroupName string
+}
+
+type LivePresentation interface {
+    GetInfo() (PresentationInfo | error)
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/io/v/syncslides/model/PresentationAdvertisement.java b/android/app/src/main/java/io/v/syncslides/model/PresentationAdvertisement.java
index 4271746..4bc39b6 100644
--- a/android/app/src/main/java/io/v/syncslides/model/PresentationAdvertisement.java
+++ b/android/app/src/main/java/io/v/syncslides/model/PresentationAdvertisement.java
@@ -9,17 +9,26 @@
  * member could choose to join it.
  */
 public class PresentationAdvertisement {
-    Person mPresenter;
-    Deck mDeck;
-    String mSyncgroupName;
+    private final String mId;
+    private final Person mPresenter;
+    private final Deck mDeck;
+    private final String mSyncgroupName;
 
-    public PresentationAdvertisement(Person presenter, Deck deck, String syncgroupName) {
+    public PresentationAdvertisement(String id, Person presenter, Deck deck, String syncgroupName) {
+        mId = id;
         mPresenter = presenter;
         mDeck = deck;
         mSyncgroupName = syncgroupName;
     }
 
     /**
+     * Returns the unique ID for this presentation.
+     */
+    public String getId() {
+        return mId;
+    }
+
+    /**
      * Returns the person who is presenting.
      */
     public Person getPresenter() {
@@ -39,4 +48,16 @@
     public String getSyncgroupName() {
         return mSyncgroupName;
     }
+
+    /**
+     * Detects equality with the id passed to the constructor.
+     */
+    @Override
+    public boolean equals(Object o) {
+        if (o instanceof PresentationAdvertisement) {
+            PresentationAdvertisement other = (PresentationAdvertisement) o;
+            return mId.equals(other.mId);
+        }
+        return false;
+    }
 }
diff --git a/android/app/src/main/res/layout/deck_card.xml b/android/app/src/main/res/layout/deck_card.xml
index 66302b8..8d0cb5a 100644
--- a/android/app/src/main/res/layout/deck_card.xml
+++ b/android/app/src/main/res/layout/deck_card.xml
@@ -41,6 +41,7 @@
                 <TextView
                     android:id="@+id/deck_card_toolbar_live_now"
                     style="@style/DeckLiveNowFont"
+                    android:text="@string/presentation_live"
                     android:layout_width="wrap_content"
                     android:layout_height="wrap_content"
                     android:layout_marginTop="@dimen/toolbar_text_top_margin"