TODOs: Use constant ad and scan to share lists to invited users
While the new Syncbase API is being worked on, we will perform sharing
with Discovery directly.
- constant scan for syncgroups advertised to you
- constant ad for syncgroups you shared with others
Issues:
- Discovery is a little flaky; the shares don't always succeed after the
first few
- Nexus 9 still likes to crash (because of the CGo pointer problem)
- Discovery doesn't let you make these ads private
https://github.com/vanadium/issues/issues/1328
- Menu to Share is shown even though the user may only have Read access
for the todo list syncgroup.
Change-Id: I31e54ca0032b1a2186091b7fddeebbe2a439a86c
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
index ed6fd8f..33bfa9e 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
@@ -47,14 +47,12 @@
import io.v.v23.syncbase.Collection;
import io.v.v23.syncbase.RowRange;
import io.v.v23.syncbase.WatchChange;
-import io.v.v23.vdl.VdlAny;
import io.v.v23.verror.NoExistException;
import io.v.v23.verror.VException;
public class SyncbaseMain extends SyncbasePersistence implements MainPersistence {
private static final String
- TAG = SyncbaseMain.class.getSimpleName(),
- LISTS_PREFIX = "lists_";
+ TAG = SyncbaseMain.class.getSimpleName();
private final IdGenerator mIdGenerator = new IdGenerator(IdAlphabets.COLLECTION_ID, true);
private final Map<String, MainListTracker> mTaskTrackers = new HashMap<>();
@@ -69,22 +67,30 @@
// Prepare a watch on top of the userdata collection to determine which todo lists need to
// be tracked by this application.
- InputChannel<WatchChange> watch = getDatabase().watch(getVContext(),
- getUserCollection().id(), LISTS_PREFIX);
- trap(InputChannels.withCallback(watch, new InputChannelCallback<WatchChange>() {
+ trap(watchUserCollection(new InputChannelCallback<WatchChange>() {
@Override
public ListenableFuture<Void> onNext(WatchChange change) {
final String listId = change.getRowName();
+ final String ownerBlessing = SyncbasePersistence.castFromSyncbase(change.getValue(),
+ String.class);
if (change.getChangeType() == ChangeType.DELETE_CHANGE) {
// (this is idempotent)
Log.d(TAG, listId + " removed from index");
deleteTodoList(listId);
} else {
+ // If we are tracking this list already, don't bother doing anything.
+ // This might happen if a same-user device did a simultaneous put into the
+ // userdata collection.
+ if (mTaskTrackers.get(listId) != null) {
+ return null;
+ }
+
mIdGenerator.registerId(change.getRowName().substring(LISTS_PREFIX.length()));
- Log.d(TAG, "Found a list id from userdata watch: " + listId);
- trap(Futures.catchingAsync(joinListSyncgroup(listId),
+ Log.d(TAG, "Found a list id from userdata watch: " + listId + " with owner: "
+ + ownerBlessing);
+ trap(Futures.catchingAsync(joinListSyncgroup(listId, ownerBlessing),
SyncgroupJoinFailedException.class, new
AsyncFunction<SyncgroupJoinFailedException, SyncgroupSpec>() {
public ListenableFuture<SyncgroupSpec> apply(@Nullable
@@ -100,7 +106,7 @@
// If this errors, then we will not get another chance to see
// this syncgroup until the app is restarted.
- return joinListSyncgroup(listId).get();
+ return joinListSyncgroup(listId, ownerBlessing).get();
}
}, RETRY_DELAY, TimeUnit.MILLISECONDS);
}
@@ -108,12 +114,7 @@
MainListTracker listTracker = new MainListTracker(getVContext(), getDatabase(),
listId, listener);
- if (mTaskTrackers.put(listId, listTracker) != null) {
- // List entries in the main collection are just ( list ID => nil ), so we
- // never expect updates other than an initial add...
- Log.w(TAG, "Unexpected update to " + USER_COLLECTION_NAME + " collection " +
- "for list " + listId);
- }
+ mTaskTrackers.put(listId, listTracker);
// If the watch fails with NoExistException, the collection has been deleted.
Futures.addCallback(listTracker.watchFuture, new SyncTrappingCallback<Void>() {
@@ -152,8 +153,7 @@
@Override
public void onSuccess(@Nullable Void result) {
// These can happen in either order.
- trap(getUserCollection().put(getVContext(), listName, null,
- VdlAny.class));
+ trap(rememberTodoList(listName));
trap(listCollection.put(getVContext(),
SyncbaseTodoList.LIST_METADATA_ROW_NAME, listSpec,
ListSpec.class));
@@ -163,11 +163,10 @@
});
}
- private ListenableFuture<SyncgroupSpec> joinListSyncgroup(String listId) {
+ private ListenableFuture<SyncgroupSpec> joinListSyncgroup(String listId, String ownerBlessing) {
SyncgroupMemberInfo memberInfo = getDefaultMemberInfo();
String sgName = computeListSyncgroupName(listId);
- String blessingStr = getPersonalBlessingsString();
- return getDatabase().getSyncgroup(new Id(blessingStr, sgName)).join(getVContext(),
+ return getDatabase().getSyncgroup(new Id(ownerBlessing, sgName)).join(getVContext(),
CLOUD_NAME, CLOUD_BLESSING, memberInfo);
}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
index e0692f9..13365d4 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
@@ -14,11 +14,14 @@
import android.support.annotation.Nullable;
import android.util.Log;
+import com.google.common.base.Function;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningScheduledExecutorService;
@@ -30,6 +33,10 @@
import org.joda.time.format.DateTimeFormat;
import java.io.File;
+import java.util.List;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
@@ -47,6 +54,9 @@
import io.v.todos.persistence.Persistence;
import io.v.todos.sharing.NeighborhoodFragment;
import io.v.todos.sharing.Sharing;
+import io.v.v23.InputChannel;
+import io.v.v23.InputChannelCallback;
+import io.v.v23.InputChannels;
import io.v.v23.VFutures;
import io.v.v23.context.VContext;
import io.v.v23.rpc.Server;
@@ -65,6 +75,7 @@
import io.v.v23.syncbase.Syncbase;
import io.v.v23.syncbase.SyncbaseService;
import io.v.v23.syncbase.Syncgroup;
+import io.v.v23.syncbase.WatchChange;
import io.v.v23.vdl.VdlStruct;
import io.v.v23.verror.ExistException;
import io.v.v23.verror.VException;
@@ -85,9 +96,12 @@
LIST_COLLECTION_SYNCGROUP_SUFFIX = "list_",
DEFAULT_BLESSING_STRING = "dev.v.io:o:608941808256-43vtfndets79kf5hac8ieujto8837660" +
".apps.googleusercontent.com:";
+ protected static final String LISTS_PREFIX = "lists_";
protected static final long
SHORT_TIMEOUT = 2500,
- RETRY_DELAY = 2000;
+ RETRY_DELAY = 2000,
+ MEMBER_TIMER_DELAY = 100,
+ MEMBER_TIMER_PERIOD = 5000;
public static final String
USER_COLLECTION_NAME = "userdata",
MOUNTPOINT = "/ns.dev.v.io:8101/tmp/todos/users/",
@@ -431,6 +445,7 @@
/**
* Hook to insert or rebind fragments.
+ *
* @param manager
* @param transaction the fragment transaction to use to add fragments, or null if fragments are
* being restored by the system.
@@ -478,13 +493,7 @@
VFutures.sync(Futures.dereference(blessings));
appVInit(activity.getApplicationContext());
- Future<?> initDiscovery = sExecutor.submit(new Callable<Void>() {
- @Override
- public Void call() throws VException {
- Sharing.initDiscovery();
- return null;
- }
- });
+ final SyncbasePersistence self = this;
final Future<?> ensureCloudDatabaseExists = sExecutor.submit(new Runnable() {
@Override
public void run() {
@@ -496,7 +505,7 @@
ensureUserCollectionExists();
VFutures.sync(ensureCloudDatabaseExists); // must finish before syncgroup setup
ensureUserSyncgroupExists();
- VFutures.sync(initDiscovery);
+ Sharing.initDiscovery(); // requires that db and collection exist
sInitialized = true;
}
@@ -515,4 +524,73 @@
}
}
}
+
+ public static void acceptSharedTodoList(final String listId, final String owner) {
+ sExecutor.submit(new Callable<Void>() {
+ @Override
+ public Void call() throws VException {
+ Boolean exists = VFutures.sync(sUserCollection.getRow(listId).exists
+ (getAppVContext()));
+ if (!exists) {
+ VFutures.sync(rememberTodoList(listId, owner));
+ }
+ return null;
+ }
+ });
+ }
+
+ protected static ListenableFuture<Void> rememberTodoList(String listId) {
+ return rememberTodoList(listId, getPersonalBlessingsString());
+ }
+
+ protected static ListenableFuture<Void> rememberTodoList(String listId, String owner) {
+ return sUserCollection.put(getAppVContext(), listId, owner, String.class);
+ }
+
+ public static ListenableFuture<Void> watchUserCollection(InputChannelCallback<WatchChange>
+ callback) {
+ InputChannel<WatchChange> watch = sDatabase.watch(getAppVContext(),
+ sUserCollection.id(), LISTS_PREFIX);
+ return InputChannels.withCallback(watch, callback);
+ }
+
+ public static Timer watchSharedTo(final String listId, final Function<List<BlessingPattern>,
+ Void>
+ callback) {
+ final Syncgroup sgHandle = sDatabase.getSyncgroup(new Id(getPersonalBlessingsString(),
+ computeListSyncgroupName(listId)));
+
+ Timer timer = new Timer();
+ timer.scheduleAtFixedRate(new TimerTask() {
+ private SyncgroupSpec lastSpec;
+
+ @Override
+ public void run() {
+ Map<String, SyncgroupSpec> specMap;
+ try {
+ // Ok to block; we don't want to try parallel polls.
+ specMap = VFutures.sync(sgHandle.getSpec(getAppVContext()));
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to get syncgroup spec for list: " + listId, e);
+ return;
+ }
+
+ String version = Iterables.getOnlyElement(specMap.keySet());
+ SyncgroupSpec spec = specMap.get(version);
+
+ if (spec.equals(lastSpec)) {
+ return; // no changes, so no event should fire.
+ }
+ Log.d(TAG, "Spec changed for list: " + listId);
+ lastSpec = spec;
+
+ Permissions perms = spec.getPerms();
+ AccessList acl = perms.get(Constants.READ.getValue());
+ List<BlessingPattern> patterns = acl.getIn();
+
+ callback.apply(patterns);
+ }
+ }, MEMBER_TIMER_DELAY, MEMBER_TIMER_PERIOD);
+ return timer;
+ }
}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
index 6fad570..c4639fb 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
@@ -9,8 +9,8 @@
import android.app.FragmentTransaction;
import android.os.Bundle;
import android.support.annotation.NonNull;
-import android.util.Log;
+import com.google.common.base.Function;
import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.AsyncFunction;
import com.google.common.util.concurrent.Futures;
@@ -22,7 +22,6 @@
import java.util.Map;
import java.util.Set;
import java.util.Timer;
-import java.util.TimerTask;
import java.util.concurrent.Callable;
import io.v.impl.google.services.syncbase.SyncbaseServer;
@@ -59,10 +58,6 @@
private static final String
SHOW_DONE_ROW_NAME = "ShowDone";
- private static final long
- MEMBER_TIMER_DELAY = 100,
- MEMBER_TIMER_PERIOD = 5000;
-
private final Collection mList;
private final TodoListListener mListener;
private final IdGenerator mIdGenerator = new IdGenerator(IdAlphabets.ROW_NAME, true);
@@ -81,6 +76,8 @@
}
mShareListMenuFragment.persistence = this;
mShareListMenuFragment.setEmail(getPersonalEmail());
+ // TODO(alexfandrianto): I shouldn't show the sharing menu item when this person cannot
+ // share the todo list with other people. (Cannot re-share in this app.)
}
/**
@@ -114,40 +111,15 @@
}
});
- final Syncgroup sgHandle = getListSyncgroup();
- mMemberTimer = new Timer();
- mMemberTimer.scheduleAtFixedRate(new TimerTask() {
- private SyncgroupSpec lastSpec;
-
+ mMemberTimer = watchSharedTo(listId, new Function<List<BlessingPattern>, Void>() {
@Override
- public void run() {
- Map<String, SyncgroupSpec> specMap;
- try {
- // Ok to block; we don't want to try parallel polls.
- specMap = VFutures.sync(sgHandle.getSpec(getVContext()));
- } catch (Exception e) {
- Log.w(TAG, "Failed to get syncgroup spec for list: " + mList.id().getName(), e);
- return;
- }
-
- String version = Iterables.getOnlyElement(specMap.keySet());
- SyncgroupSpec spec = specMap.get(version);
-
- if (spec.equals(lastSpec)) {
- return; // no changes, so no event should fire.
- }
- lastSpec = spec;
-
- // Get the list of patterns that can read from the spec.
- Permissions perms = spec.getPerms();
- AccessList acl = perms.get(Constants.READ.getValue());
- List<BlessingPattern> patterns = acl.getIn();
-
+ public Void apply(List<BlessingPattern> patterns) {
// Analyze these patterns to construct the emails, and fire the listener!
List<String> emails = parseEmailsFromPatterns(patterns);
mShareListMenuFragment.setSharedTo(emails);
+ return null;
}
- }, MEMBER_TIMER_DELAY, MEMBER_TIMER_PERIOD);
+ });
// Watch the "showDone" boolean in the userdata collection and forward changes to the
// listener.
diff --git a/app/src/syncbase/java/io/v/todos/sharing/Sharing.java b/app/src/syncbase/java/io/v/todos/sharing/Sharing.java
index 73bc757..0be7f38 100644
--- a/app/src/syncbase/java/io/v/todos/sharing/Sharing.java
+++ b/app/src/syncbase/java/io/v/todos/sharing/Sharing.java
@@ -4,16 +4,47 @@
package io.v.todos.sharing;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
import io.v.android.v23.V;
+import io.v.todos.R;
import io.v.todos.persistence.syncbase.SyncbasePersistence;
+import io.v.v23.InputChannelCallback;
+import io.v.v23.InputChannels;
+import io.v.v23.context.VContext;
+import io.v.v23.discovery.Advertisement;
import io.v.v23.discovery.Discovery;
+import io.v.v23.discovery.Update;
+import io.v.v23.security.BlessingPattern;
+import io.v.v23.syncbase.ChangeType;
+import io.v.v23.syncbase.WatchChange;
import io.v.v23.verror.VException;
public final class Sharing {
- private Sharing(){}
+ private Sharing() {
+ }
+ private static final String OWNER_KEY = "owner";
private static final Object sDiscoveryMutex = new Object();
private static Discovery sDiscovery;
+ private static VContext sScanContext;
+ private static VContext sAdvertiseContext;
+ private final static Map<String, VContext> sAdContextMap = new HashMap<>();
public static Discovery getDiscovery() {
return sDiscovery;
@@ -26,10 +57,22 @@
// Rely on the neighborhood fragment to initialize presence advertisement.
NeighborhoodFragment.initSharePresence();
+
+ sScanContext = initScanForInvites();
+ sAdvertiseContext = initAdvertiseLists();
}
}
}
+ // TODO(alexfandrianto): Nobody calls this, so we never stop sharing.
+ public static void stopDiscovery() {
+ synchronized (sDiscoveryMutex) {
+ sScanContext.cancel();
+ sAdvertiseContext.cancel();
+ sAdContextMap.clear();
+ }
+ }
+
private static String getRootInterface() {
return SyncbasePersistence.getAppContext().getPackageName();
}
@@ -41,4 +84,183 @@
public static String getInvitationInterface() {
return getRootInterface() + ".invitation";
}
+
+ /**
+ * Starts a scanner seeking advertisements that invite this user to a todo list. When an invite
+ * is found, the app will automatically accept it.
+ */
+ public static VContext initScanForInvites()
+ throws VException {
+ VContext vContext = SyncbasePersistence.getAppVContext().withCancel();
+ try {
+ ListenableFuture<Void> scan = InputChannels.withCallback(
+ Sharing.getDiscovery().scan(vContext,
+ "v.InterfaceName = \"" + Sharing.getInvitationInterface() + "\""),
+ new InputChannelCallback<Update>() {
+ @Override
+ public ListenableFuture<Void> onNext(Update result) {
+ final String listName = Iterables.getOnlyElement(result.getAddresses());
+ if (listName == null) {
+ return null;
+ }
+ Log.d("SHARING", "Noticed advertised list: " + listName + " by: " +
+ result.getAttribute(OWNER_KEY));
+
+ // TODO(alexfandrianto): Remove hack.
+ // https://github.com/vanadium/issues/issues/1328
+ if (result.getAttribute(SyncbasePersistence
+ .getPersonalBlessingsString()) == null) {
+ Log.d("SHARING", "...but the ad was not meant for this user.");
+ return null; // ignore; this isn't meant for us
+ }
+
+ // Never mind about losses, just handle found advertisements.
+ if (!result.isLost()) {
+ Log.d("SHARING", "...and will accept it.");
+
+ SyncbasePersistence.acceptSharedTodoList(listName, result
+ .getAttribute(OWNER_KEY));
+ }
+ return null;
+ }
+ });
+ Futures.addCallback(scan, new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ handleScanListsError(t);
+ }
+ });
+ } catch (VException e) {
+ handleScanListsError(e);
+ }
+ return vContext;
+ }
+
+ private static void handleScanListsError(Throwable t) {
+ SyncbasePersistence.getAppErrorReporter().onError(R.string.err_scan_lists, t);
+ }
+
+ /**
+ * Creates advertisements based on the todo lists this user has created thus far and those that
+ * are created in the future. The advertisements will need to be targeted to the users that have
+ * been invited to the list.
+ *
+ * @return
+ * @throws VException
+ */
+ public static VContext initAdvertiseLists()
+ throws VException {
+ final VContext vContext = SyncbasePersistence.getAppVContext().withCancel();
+
+ // Prepare a watch on top of the userdata collection to determine which todo lists need to
+ // be tracked by this application.
+ SyncbasePersistence.watchUserCollection(new InputChannelCallback<WatchChange>() {
+ @Override
+ public ListenableFuture<Void> onNext(WatchChange change) {
+ final String listId = change.getRowName();
+ final String owner = SyncbasePersistence.castFromSyncbase(change.getValue(),
+ String.class);
+ if (!owner.equals(SyncbasePersistence.getPersonalBlessingsString())) {
+ return Futures.immediateFuture((Void) null);
+ }
+
+ if (change.getChangeType() == ChangeType.DELETE_CHANGE) {
+ VContext ctx = sAdContextMap.remove(listId);
+ ctx.cancel(); // Stop advertising the list; it's been deleted.
+ } else {
+ // We should probably start to advertise this collection and check its spec.
+ SyncbasePersistence.watchSharedTo(listId, new Function<List<BlessingPattern>,
+ Void>() {
+ @Override
+ public Void apply(List<BlessingPattern> patterns) {
+ // Make a copy of the patterns list that excludes the cloud and this
+ // user's blessings.
+ List<BlessingPattern> filteredPatterns = new ArrayList<>();
+ for (BlessingPattern pattern : patterns) {
+ String pStr = pattern.toString();
+ if (pStr.equals(SyncbasePersistence.getPersonalBlessingsString()) ||
+ pStr.equals(SyncbasePersistence.CLOUD_BLESSING)) {
+ continue;
+ }
+ filteredPatterns.add(pattern);
+ }
+
+ // Advertise to the remaining patterns.
+ if (filteredPatterns.size() > 0) {
+ Log.d("SHARING", "Must advertise for " + listId + " to " +
+ filteredPatterns.toString());
+ advertiseList(vContext, listId, filteredPatterns);
+ }
+ return null;
+ }
+ });
+ }
+ return null;
+ }
+ });
+ return vContext;
+ }
+
+ /**
+ * Advertises that this list is available to this set of people. Cancels the old advertisement
+ * if one exists. Only called by initAdvertiseLists.
+ *
+ * @param baseContext The context for all advertisements
+ * @param listId The list to be advertised
+ * @param patterns Blessings that the advertisement should target
+ */
+ private static void advertiseList(VContext baseContext, String listId, List<BlessingPattern>
+ patterns) {
+ if (baseContext.isCanceled()) {
+ Log.w("SHARING", "Base context was canceled; cannot advertise");
+ return;
+ }
+ // Swap out the ad context...
+ VContext oldAdContext = sAdContextMap.remove(listId);
+ if (oldAdContext != null) {
+ oldAdContext.cancel();
+ }
+ VContext newAdContext = baseContext.withCancel();
+ sAdContextMap.put(listId, newAdContext);
+
+
+ try {
+ Advertisement ad = new Advertisement();
+ ad.setInterfaceName(Sharing.getInvitationInterface());
+ ad.getAddresses().add(listId);
+ ad.getAttributes().put(OWNER_KEY, SyncbasePersistence.getPersonalBlessingsString());
+
+ // TODO(alexfandrianto): Remove hack. https://github.com/vanadium/issues/issues/1328
+ for (BlessingPattern pattern : patterns) {
+ ad.getAttributes().put(pattern.toString(), "");
+ }
+
+ Futures.addCallback(Sharing.getDiscovery().advertise(sAdvertiseContext, ad,
+ // TODO(alexfandrianto): Crypto crash if I use patterns instead of null.
+ // https://github.com/vanadium/issues/issues/1328 and
+ // https://github.com/vanadium/issues/issues/1331
+ null),
+ //patterns),
+ new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(@android.support.annotation.Nullable Void result) {
+ }
+
+ @Override
+ public void onFailure(@NonNull Throwable t) {
+ handleAdvertiseListError(t);
+ }
+ });
+ } catch (VException e) {
+ handleAdvertiseListError(e);
+ }
+ }
+
+ private static void handleAdvertiseListError(Throwable t) {
+ SyncbasePersistence.getAppErrorReporter().onError(R.string.err_advertise_list, t);
+ }
}
diff --git a/app/src/syncbase/res/values/strings.xml b/app/src/syncbase/res/values/strings.xml
index c4538d2..dbffb09 100644
--- a/app/src/syncbase/res/values/strings.xml
+++ b/app/src/syncbase/res/values/strings.xml
@@ -8,4 +8,6 @@
<!-- Errors -->
<string name="err_share_location">Could not share location</string>
<string name="err_scan_nearby">Unable to scan for nearby users</string>
+ <string name="err_scan_lists">Unable to scan for todo lists shared with you</string>
+ <string name="err_advertise_list">Unable to send todo list invitation(s)</string>
</resources>