Let’s say you've got a chat app where you want to enable a private conversation among a select group of people. Or you have a photo sharing app where you want a group of friends to contribute to an album together. How could you limit this kind of data sharing to a small group of users without exposing it to the world?
This is where security rules in Firebase can help out. Firebase's security rules can be quite powerful, but they do sometimes require a little guidance. This isn't really because they're complicated, but mostly because most people tend to not use them frequently enough to build up a lot of expertise.
Luckily for all of you, I sit next to people who have built up some expertise in Firebase security, and I've been able to pester them over the last several weeks to get this blog post written. More importantly, I found this One Weird Trick that makes it easy to figure out security rules that I'll share with you… at the end of this article.
But for the moment, let's go back to our hypothetical example of a chat app that wants to have private group conversations. Anybody who's part of the chat group can read and write chat messages, but we don't want other people to be able to listen in.
Imagine that we've structured our database like so. There are lots of ways to do this, of course, but this is probably the easiest for demonstration purposes.
Within every semi-private chat, we have a list of people who are allowed to participate in the chat, along with the list of chat messages. (And yes, in real life, these userIDs are going to look a whole lot messier than user_abc.)
So the first security rule we want to set up is that only people in the members list are allowed to see chat messages. That's something we could create using a set of security rules like this:
{ "rules": { "chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()" } } } } }
What we're saying here is that you're allowed to read the chats in the chats//messages, as long as your userID exists in that same chat's members section.
chats//messages
userID
members
Curious about that $chatID line? That's kind of the equivalent of a wildcard that matches anything, but sticks the match into a $chatID variable that you could reference later if you want.
$chatID
So user_abc? Totally able to read chat messages. But user_xyz isn't allowed because there's no members/user_xyz entry within that chat group.
user_abc
user_xyz
members/user_xyz
Once we've done that, it's trivial to add a similar rule that says only members can write chat messages, too.
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).exists()" } } }
And we could get more fine-grained if we wanted. What if our chat app had a "lurker" user type, who was allowed to view, but not write messages?
"lurker"
That could be addressed as well. We want to change our rules to say, "You can write messages, but only if you're listed as an owner or a chatter." So we'd end up with something like this:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" } } }
(Note that for brevity, I've dropped the "rules" line from the rest of these samples.)
Incidentally, you might thinking to yourself, "Gosh wouldn't it be easier to just say 'Allow people to write chat messages only if they're not listed as a lurker'"? And sure, that would require one less line of code…
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() != 'lurker'" } }
...but quite often, when it comes to security, you're better off basing your security off of whitelists rather than blacklists. Consider this: What would happen if your app suddenly decides to add a new class of users (we'll call them "newbies") and you forget to update those rules?
With the first set of rules, that new group of users wouldn't be able to post anything, but with the second set of rules, that new group of users would be able to post whatever they want. Either case could be bad if it's not what you intended, but that latter case could be a whole lot worse from a security standpoint.
Of course, all of this is overlooking one tiny little problem: How were we able to populate those list of users in the first place?
Well, let's assume, for a moment, that a user is somehow able to get a list of their friends through the app. (And we'll leave that as an exercise for the reader.) There are a few options we can consider for adding new users to a group chat.
Frankly, any of these would work; it's really up to the app developer to decide what's the best user experience for their app.
So let's look at these in order.
To handle that first option, we'd need to set a rule that says "People who are already in the members list are allowed to write to the members list."
This is pretty similar to the rules we've already set up for posting to the members list:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).exists()", ".write": "data.child(auth.uid).exists()" } } }
Essentially, we're saying that any user can read or write to the members list as long as the user's current user id already exists somewhere in that list.
Restricting this to just letting an owner write to the list is also easy.
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner'" } } }
We're saying, "You can go ahead and write to the members branch of a chat, but only if your userID is already in there and you're listed as an owner."
So we've got that second case covered.
So what about the idea of allowing users to ask to join and then having the owner if the chat approve them? Well, for that, one good option would be to add a pending list in the database alongside the members list, where people could add themselves.
The group's owner would then be allowed to add these potential users to the members list and also delete them from the pending list.
So the first rule we want to declare is, "You can add an entry to the pending list, but only if you're adding yourself." In other words, the key of the item that's being added has to be your own user id.
The rule for adding this looks like the following:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner'" }, "pending": { "$uid": { ".write": "$uid === auth.uid" } } } }
Here, we're saying, "Go ahead and write anything you want to the pending/ branch, just as long as uid is your own userID."
pending/
If we want to be thorough, we can also specify that you can only do this if you haven't added yourself already to the "pending" list, which would look a little more like this:
"pending": { "$uid": { ".write": "$uid === auth.uid && !data.exists()" } }
While we're at it, let's also specify that you can't ask to add yourself if you're already a member of the chat. That would be kinda pointless. So we'd end up with rules like this:
"pending": { "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } }
Then we can add some rules to the overall pending folder that says an owner can read or write to it.
pending
"pending": { ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'", ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'", "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } }
And… that's about it! Assuming we've kept the rules from the previous section that only allow the owner of a chat to read or write to a members list, we've successfully added the security rules that allows an owner to remove an entry from the pending list, and add them to the members list.
Oh, but I guess we've forgotten one last, kinda-important rule: Allowing somebody to create a new group chat. How can we set that up? Well, if you think about it, you can add this by stating that "Anybody can write to a members list if that list is empty and you're setting yourself as the owner"
"members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')" }
For the purpose of this write, imagine you first start by writing to /chats/chat_345/members with an object of { "user_zzz" : "owner" }. That newData line is going to look at this object and make sure the child with the key of the signed in user (user_zzz) is listed as owner.
/chats/chat_345/members
{ "user_zzz" : "owner" }
(user_zzz)
Once you've done this, then you can go ahead and add whatever additional messages or users the owner wants. Since they are now officially listed as an owner, the security rules should allow those actions no problem.
Note that the security rules don't really have a concept of a separate "create directory" action. If a user is allowed to write to chat_456/messages/abc, that rule applies whether or not messages already exists. (Or, for that matter, chat_456.)
chat_456/messages/abc
chat_456
I am not a Firebase security expert, but I'm able to play one in blog posts. Mostly by running the rules simulator.
See, every time you make a change to the rules -- and before you publish them -- you can test out how they run by simulating reads or writes to the database. In the Rules section of the Firebase console, there's a "Simulator" button on the upper right that you can click on. This will bring up a form that allows you to test any kind of read or write action you'd like.
In this example, I'm testing out that last rule by having a user signed in as "user_zzz" attempting to add themselves as an owner to an empty /chats/chat_987/members list. The rules simulator is telling me this is allowed and highlighting the line where the write action evaluates to true.
"user_zzz"
/chats/chat_987/members
(Technically, it's highlighting the wrong line. It's the part of the rule in step 13 that evaluates to true. I think the highlighter doesn't handle line breaks within a string particularly well.)
On the other hand, if that user attempts to add themselves as an owner of a non-empty list, it fails, which is exactly what we want.
Note that there are a few other refinements that could be make here. Right now, we're set up such that owners can add other members as owners. That may or may not be what we want.
Come to think of it, we haven't done anything to validate that new members are being added with legitimate roles. And there's certainly some validation rules we could be adding to the chat messages to make sure they're of a length that our UI can handle. But perhaps this is an area you could play around with.
Copy-and-paste these final rules into your own version of a chat app and see what you can do to add these refinements.
{ "rules": { "chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')" }, "pending": { ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'", ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'", "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } } } } } }
You're more than welcome to check out the documentation if you need more help, and play around a little with the simulator! I guarantee it'll the most fun you'll have this week playing with a database security rules simulator. Or at least in the top three.
We recently announced that the first Firebase Dev Summit will be held in Berlin on Nov 7th. A few spots for app developers are still available and we encourage you to register today!
In our experience there are a number of things required that make a great developer event- and it just happens we’ll have them all at the Firebase Dev Summit in Berlin next month:
1. Announcements. Since our announcement at I/O, we’ve worked hard to add even more exciting features that will help you develop and grow your app. Be the first to hear about our new releases and to exclusively try them out.
2. Meet the Firebase team. Firebase founders, product managers, and engineers will be on hand to answer questions, hear how you're using Firebase and discuss how we can make it even better.
3. Content. The full speaker list and schedule will be released next week! Spoilers: sessions on growth hacking with Firebase, app quality, developing without infrastructure, trainings for new features (and more!) will be part of the full day agenda.
4. Cool swag. This is our first ever Firebase Dev Summit and we take it pretty seriously…
5. Travel grant. We believe a diversity of attributes, experiences, and perspectives are needed to build tools and apps that can change the world. So, we’ve partnered with the Women Techmakers team to offer travel grants to women in technology interested in attending. Apply here to join us in Berlin.
6. Network. The Firebase Dev Summit is for app developers like you. This will be a great chance to meet other folks that are working on similar challenges, have a drink together and dance to the sounds of Drum & Bass while chatting about Firebase. :)
Oh, did we mention that the event is free of charge? We hope you can make it - don’t forget to reserve your spot before we sell out.
Can’t make it? Sign up here if you’d like to receive updates on the livestream and tune in live on November 7th.
This is the last post in this blog series about the Play Services Task API and its use in Firebase. If you missed the first three parts, consider jumping back to those before continuing here. When you're all caught up, let's finish up this series!
Throughout this series, we've only ever talked about units of work that are themselves represented by a Task or a Continuation. In reality, however, there are lots of other ways to get work done. Various utilities and libraries may have their own ways of performing threaded work. You might wonder if you have to switch to the Task API to unify all this if you want to switch to Firebase. But you certainly don't have to. The Task API was designed with the capability of integrating with other ways of doing threaded work.
For example, Java has always had the ability to simply fire up a new thread to process something in parallel with other threads. You can write code like this (though I heartily recommend against it on Android):
new Thread(new Runnable() { @Override public void run() { String result = "the output of some long-running compute"; // now figure out what to do with the result... } }).start();
Here we fire up that new thread from the main thread and do exciting work that ends with a String of interest. All that work that went into creating that string happens in parallel with the main thread, which continued executing after the thread was started. If that threaded work happened to block at any point, the main thread would not be held up by it. However, something must be done to get that String result into the place where it's expected. On Android, if that needs to be back on the main thread, you'll have to write more code to arrange for that to happen. This can get hairy. And we can use Tasks to help.
The Play Services Task API provides a way to make other units of work behave like Tasks, even if they weren't implemented as such. The class of interest here is TaskCompletionSource. This allows you to effectively create a Task "placeholder" that some other bit of code can trigger for success or failure. If you wanted that thread from above to behave like a Task without implementing it as a Task (as we learned last time by passing a Callable to Tasks.call()), you could do this:
final TaskCompletionSource<String> source = new TaskCompletionSource<>(); new Thread(new Runnable() { @Override public void run() { String result = "the output of some long-running compute"; source.setResult(result); } }).start(); Task<String> task = source.getTask(); task.addOnCompleteListener(new OnCompleteListener<String>() { ... });
We now have the thread offering its result String to the TaskCompletionSource using its setResult() method. Then, in the original thread, we simply ask the TaskCompletionSource for its "placeholder" Task, and add a listener to that. The result is now handled inside the listener running on the main thread. You can do the same in the failure case by calling the setException() method on the TaskCompletionSource. That will end up triggering any failure listeners, and they'll get a hold of the exception.
This strategy might seem a little bit silly up front, because there are less verbose ways of putting the result of some work back on the main thread. The value here is in the ability to work with that new placeholder Task along with other Tasks you might be working with in a unified fashion.
Imagine you're writing an app that absolutely depends on some values in Firebase Realtime Database, along with the values in Firebase Remote Config. However, to keep your users entertained while they wait for this data to load, you’d like to create a splash screen that shows some animation until that data is available to work with. Oh, and you don't want that screen to appear and disappear in a jarring way in the event that the data happens to be locally cached, so you want the screen to show for a minimum of 2 seconds. How might you implement this screen?
For starters, you'll need to create a new Activity and design and implement the views for the splash screen. That's straightforward. Then you'll need to coordinate the work between Realtime Database and Remote Config, as well as factor in the two second timer. You'll probably want to kick off all that work during the Activity's onCreate() after you create the splash screen views. You could use a series of Continuations to make sure all these things happen in serial, one after another. But why do that if you could instead start them all at once to run in parallel, and make the user wait only as long as it takes to complete the longest item of work? Let's see how!
The Task API provides a couple methods to help you know when several Tasks are all complete. These static utility methods create a new Task that gets triggered in response to the completion of a collection of Tasks that you provide.
Task<Void> Tasks.whenAll(Collection<? extends Task<?>> tasks) Task<Void> Tasks.whenAll(Task...<?> tasks)
One version of whenAll() accepts a Java Collection (such as a List or Set), and the other uses the varargs style of passing multiple parameters to easily form an array of any length. Either way, the returned Task will now get triggered for success when all the other Tasks succeed, and trigger for failure if any one of them fails. Note that the new Task result is parameterized with Void, meaning it doesn't contain any results directly. If you want the results of each individual Task, you'll have to get the results from each of them directly.
This whenAll() function looks pretty handy for knowing when all our concurrent work is done, so we can move the user past the splash screen. The trick for this case is to somehow get a bunch of Task objects the represent each thing we're waiting on.
The Remote Config fetch is easy, because it will give you a Task you can use to listen to the availability your values. Let's kick off that task and remember it:
private Task<Void> fetchTask; // during onCreate: fetchTask = FirebaseRemoteConfig.getInstance().fetch();
Realtime Database isn't as easy, because it doesn't provide a Task for triggering on the completion of available data. However, we can use the TaskCompletionSource we just learned about to trigger a placeholder task when the data is available:
private TaskCompletionSource<DataSnapshot> dbSource = new TaskCompletionSource<>(); private Task dbTask = dbSource.getTask(); // during onCreate: DatabaseReference ref = FirebaseDatabase.getInstance().getReference("/data/of/interest"); ref.addListenerForSingleValueEvent(new ValueEventListener() { @Override public void onDataChange(DataSnapshot dataSnapshot) { dbSource.setResult(dataSnapshot); } @Override public void onCancelled(DatabaseError databaseError) { dbSource.setException(databaseError.toException()); } });
Here, we're registering a listener for the data we need to continue launching the app. That listener will then trigger dbTask to success or failure via dbSource depending on the callback it received.
Lastly, there's the minimum two second delay for the splash screen to stay up. We can also represent that delay as a Task using TaskCompletionSource:
private TaskCompletionSource<Void> delaySource = new TaskCompletionSource<>(); private Task<Void> delayTask = delaySource.getTask(); // during onCreate: new Handler().postDelayed(new Runnable() { @Override public void run() { delaySource.setResult(null); } }, 2000);
For the delay, we're just scheduling a Runnable to execute on the main thread after 2000ms, and that Runnable will then trigger delayTask via delaySource.
Now, we have three Tasks, all operating in parallel, and we can use Tasks.whenAll() to create another Task that triggers when they're all successful:
private Task<Void> allTask; // during onCreate(): allTask = Tasks.whenAll(fetchTask, dbTask, delayTask); allTask.addOnSuccessListener(new OnSuccessListener<Void>() { @Override public void onSuccess(Void aVoid) { DataSnapshot data = dbTask.getResult(); // do something with db data? startActivity(new Intent(SplashScreenActivity.this, MainActivity.class)); } }); allTask.addOnFailureListener(new OnFailureListener() { @Override public void onFailure(@NonNull Exception e) { // apologize profusely to the user! } });
And that should do it! When the final allTask succeeds, we can do whatever we need with the data from the database, then we send the user to MainActivity. Without the use of Tasks here, this code becomes more tedious to write because you'd have to check the state of all the other ongoing units of work at the end of each of them, and proceed only when you know they are all done. Here, the Task API handles those details for you. And you can easily add more Tasks as needed without having to change the logic. Just keep adding Tasks to the collection behind allTask.
It's worth noting that there is a way to block the current thread on the result of one or more Tasks. Normally you don't want to block threads at all, if you can help it, but occasionally it's useful when you have to (such as with Loaders). If you do need to wait on the result of a Task, you can use the await() function:
static <TResult> TResult await(Task<TResult> task) static <TResult> TResult await(Task<TResult> task, long timeout, TimeUnit unit)
With await(), the calling thread simply blocks until the task completes, or the given timeout expires. If it was successful, you'll receive the result object, and if it fails, it will throw an ExecutionException which wraps the underlying cause. Please remember that you should never block the main thread! Only use this when you know you're running on some background thread, OK?
Here's what we covered in the four parts of this blog series:
This should be everything you need to know to make effective use of Play Services Task API! I hope you’re able to use Firebase along with the Task API to make efficient and delightful Android apps.
Successful apps turn into successful revenue generating businesses when the right business model is built into the core app development strategy from the very beginning. Since Firebase is designed to help app developers at every part of their lifecycle, from creating high-quality apps to growing and monetizing their app traffic, let’s take a peek at what monetization concepts you could be thinking about now.
Ready to start exploring AdMob?
Sign up for an AdMob account and link it to your Firebase project.
Alrighty! Thanks for joining us for part three of this blog series about the Play services Task API for Android. By now, you've seen the essentials of the API in part one, and how to select the best style of listener in part two. So, at this point, you probably have everything you need to know to make effective use of the Tasks generated by Firebase APIs. But, if you want to press into some advanced usage of Tasks, keep reading!
We know that some of the Firebase features for Android will do work for you and notify a Task upon completion. But, what if you want to create your own Tasks to perform threaded work? The Task API gives you the tools for this. If you want to work with the Task API without having to integrate Firebase into your app, you can get the library with a dependency in your build.gradle:
compile 'com.google.android.gms:play-services-tasks:9.6.1'
But, if you are integrating Firebase, you'll get this library included for free, so no need to call it out specifically in that case.
There is just one method (with two variants) you can use to kick off a new Task. You can use the static method named "call" on the Tasks utility class for this. The variants are as follows:
Task<TResult> call(Callable<TResult> callable) Task<TResult> call(Executor executor, Callable<TResult> callable)
Just like addOnSuccessListener(), you have a version of call() that executes the work on the main thread and another that submits the work to an Executor. You specify the work to perform inside the passed Callable. A Java Callable is similar to a Runnable, except it's parameterized by some result type, and that type becomes the returned object type of its call() method. This result type then becomes the type of the Task returned by call(). Here's a really simple Callable that just returns a String:
public class CarlyCallable implements Callable<String> { @Override public String call() throws Exception { return "Call me maybe"; } }
Notice that CarlyCallable is parameterized by String, which means its call() method must return a String. Now, you can create a Task out of it with a single line:
Task<String> task = Tasks.call(new CarlyCallable());
After this line executes, you can be certain that the call() method on the CarlyCallable will be invoked on the main thread, and you can add a listener to the Task to find the result (even though that result is totally predictable). More interesting Callables might actually load some data from a database or a network endpoint, and you'd want to have those blocking Callables run on an Executor using the second form of call() that accepts the Executor as the first argument.
Let's say, for the sake of example, you want to process the String result of the CarlyCallable Task after it's been generated. Imagine that we're not so much interested in the text of the resulting String itself, and more interested in a List of individual words in the String. But, we don't necessarily want to modify CarlyCallable because it's doing exactly what it's supposed to, and it could be used in other places as it’s written now. Instead, we'd rather encapsulate the logic that splits words into its own class, and use that after the CarlyCallable returns its String. We can do this with a Continuation. An implementation of the Continuation interface takes the output of one Task, does some processing on it, and returns a result object, not necessarily of the same type. Here's a Continuation that splits a string of words into an List of Strings with each word:
public class SeparateWays implements Continuation<String, List<String>> { @Override public List<String> then(Task<String> task) throws Exception { return Arrays.asList(task.getResult().split(" +")); } }
Notice that the Continuation interface being implemented here is parameterized by two types, an input type (String) and an output type (List). The input and output types are used in the signature of the lone method then() to define what it's supposed to do. Of particular note is the parameter passed to then(). It's a Task, and the String there must match the input type of the Continuation interface. This is how the Continuation gets its input - it pulls the finished result out of the completed Task.
Here's another Continuation that randomizes a List of Strings:
public class AllShookUp implements Continuation<List<String>, List<String>> { @Override public List<String> then(@NonNull Task<List<String>> task) throws Exception { // Randomize a copy of the List, not the input List itself, since it could be immutable final ArrayList<String> shookUp = new ArrayList<>(task.getResult()); Collections.shuffle(shookUp); return shookUp; } }
And another one that joins a List of Strings into a single space-separated String:
private static class ComeTogether implements Continuation<List<String>, String> { @Override public String then(@NonNull Task<List<String>> task) throws Exception { StringBuilder sb = new StringBuilder(); for (String word : task.getResult()) { if (sb.length() > 0) { sb.append(' '); } sb.append(word); } return sb.toString(); } }
Maybe you can see where I'm going with this! Let's tie them all together into a chain of operations that randomizes the word order of a String from a starting Task, and generates a new String with that result:
Task<String> playlist = Tasks.call(new CarlyCallable()) .continueWith(new SeparateWays()) .continueWith(new AllShookUp()) .continueWith(new ComeTogether()); playlist.addOnSuccessListener(new OnSuccessListener<String>() { @Override public void onSuccess(String message) { // The final String with all the words randomized is here } });
The continueWith() method on Task returns a new Task that represents the computation of the prior Task after it’s been processed by the given Continuation. So, what we’re doing here is chaining calls to continueWith() to form a pipeline of operations that culminates in a final Task that waits for each stage to complete before completing.
This chain of operations could be problematic if these they have to deal with large Strings, so let's modify it to do all the processing on other threads so we don't block up the main thread:
Executor executor = ... // you decide! Task<String> playlist = Tasks.call(executor, new CarlyCallable()) .continueWith(executor, new SeparateWays()) .continueWith(executor, new AllShookUp()) .continueWith(executor, new ComeTogether()); playlist.addOnSuccessListener(executor, new OnSuccessListener() { @Override public void onSuccess(String message) { // Do something with the output of this playlist! } });
Now, the Callable, all of the Continuations, and the final Task listener will each run on some thread determined by the Executor, freeing up the main thread to deal with UI stuff while this happens. It should be totally jank-free.
At first blush, it could seem a bit foolish to separate all these operations into all the different classes. You could just as easily write this as a few lines in a single method that do only what's required. So, keep in mind that this is a simplified example intended to highlight how Tasks can work for you. The benefit of chaining of Tasks and Continuations (even for relatively simple functions) becomes more evident when you consider the following:
Practically speaking, you're more likely to use Task continuations to perform a series of modular chain of filter, map, and reduce functions on a set of data, and keep those units of work off the main thread, if the collections can be large. But, I had fun with music theme here!
One last thing to know about Continuations. If a runtime exception is thrown during processing at any stage along the way, that exception will normally propagate all the way down to the failure listeners on the final Task in the chain. You can check for this yourself in any Continuation by asking the input Task if it completed successfully with the isSuccessful() method. Or, you can just blindly call getResult() (as is the case in the above samples), and if there was previously a failure, it will get re-thrown and automatically end up in the next Continuation. The listeners on the final Task in the chain should always check for failure, though, if failure is an option.
So, for example, if the CarlyCallable in the above chain returned null, that would cause the SeparateWays continuation to throw a NullPointerException, which would propagate to the end of the Task. And if we had an OnFailureListener registered, that would get invoked with the same exception instance.
What's the most efficient way, with the above chain, of finding out the number of words in the original string, without modifying any of the processing components? Take a moment to think about it before reading on!
The answer is probably more simple than you'd imagine. The most obvious solution is to count the number of words in the final output string, since their order only got randomized. But there is one more trick. Each call to continueWith() returns a new Task instance, but those are all invisible here because we used a chaining syntax to assemble them into the final Task. So you can intercept any of those those tasks and add another listener to it, in addition to the next Continuation:
Task<List<String>> split_task = Tasks.call(new CarlyCallable()) .continueWith(executor, new SeparateWays()); split_task = .continueWith(executor, new AllShookUp()) .continueWith(executor, new ComeTogether()); split_task.addOnCompleteListener(executor, new OnCompleteListener<List<String>>() { @Override public void onComplete(@NonNull Task<List<String>> task) { // Find the number of words just by checking the size of the List int size = task.getResult().size(); } }); playlist.addOnCompleteListener( /* as before... */ );
When a Task finishes, it will trigger both of the Continuations on it, as well as all of the added listeners. All we've done here is intercept the Task that captures the output of the SeparateWays continuation, and listen to the output of that directly, without affecting the chain of continuations. With this intercepted task, we only have to call size() on the List to get the count.
All joking aside, the Task API makes it relatively easy for you to express and execute a sequential pipeline of processing in a modular fashion, while giving you the ability to specify which Executor is used at each stage in the process. You can do this /with or without/ Firebase integrated into your app, using your own Tasks or those that come from Firebase APIs. For the next and final part to this series, we'll look at how Tasks can be used in parallel to kick off multiple units of work simultaneously.
As usual, if you have any questions, consider using Twitter with the #AskFirebase hashtag or the firebase-talk Google Group. We also have a dedicated Firebase Slack channel. And you can follow me @CodingDoug on Twitter to get notified of the next post in this series.
Lastly, if you're wondering about all the songs I referenced in this post, you can find them here: