Building the Chat App

Now that we know how to use Firebase for what we need (authentication and database) and we have some more advanced UI notions, we can start building the chat app I promised we would work on.

Planning Our App’s UI

A chat app needs the user to perform multiple actions when they first start the app:

  • We need the user to insert an email address and password and either log into an existing account or create a new one if they don’t have one yet, at this point the user should also receive an email asking him to verify his user account, but we’re not going to make distinctions between verified and unverified users at this point.

  • We need the user to choose a display name to use to identify themselves to other users in the app.

  • If the user has done all of this, they should get to the page showing all messages sent by other users.

Building the UI

We’ll build the UI by creating each piece in the order in which the user will interact with it. Let’s start with the Sign-Up/Sign-In page!

Login/Signup Page

As we did in the previous chapter, we are going to use a TextEditingController to be able to access text entered into a TextField from outside its own onSubmitted.

The UI is going to be the following (we’ll implement the logIn and signUp methods later in Authentication):

 @override
 Widget ​build​(BuildContext context) {
 if​(_verificationComplete) {
  Navigator.pushReplacement(
  context,
  MaterialPageRoute(
  builder: (context) => ConfigPage()
  )
  );
  }
 return​ Scaffold(
  appBar: AppBar(
  title: Text(​"ChatOnFire Login"​),
  ),
  body: ListView(
  children: <Widget>[
  Padding(
  padding: EdgeInsets.all(15.0),
  child: Text(
 "Log In Using Your Phone Number"​,
  style: Theme.of(context).textTheme.display1,
  ),
  ),
  Padding(
  padding: EdgeInsets.all(10.0),
  child: TextField(
  keyboardType: TextInputType.emailAddress,
  controller: _emailController,
  decoration: InputDecoration(
  labelText: ​"Email Address"​,
  ),
  autofocus: ​true​,
  )
  ),
  Padding(
  padding: EdgeInsets.all(10.0),
  child: TextField(
  keyboardType: TextInputType.text,
  obscureText: ​true​,
  controller: _passwordController,
  decoration: InputDecoration(
  labelText: ​"Password"​,
  ),
  )
 ),
 Padding(
  padding: EdgeInsets.all(10.0),
  child: FlatButton(
  color: Theme.of(context).accentColor,
  textColor: Colors.white,
  child: Text(​"Log In"​.toUpperCase()),
  onPressed: () {
  logIn(
  _emailController.text,
  _passwordController.text
  ).then(
  (user) {
  _user = user;
 if​(!_user.isEmailVerified) {
  _user.sendEmailVerification();
  }
  _verificationComplete = ​true​;
  Navigator.pushReplacement(
  context, MaterialPageRoute(
  builder: (context) => ConfigPage()
  )
  );
  }
  ).catchError(
  (e) {
  Scaffold.of(context).showSnackBar(
  SnackBar(
  content: Text(
 "You don't have an account. Please sign up."
  )
  )
  );
  }
  );
  },
  ),
 ),
 Padding(
  padding: EdgeInsets.all(10.0),
  child: FlatButton(
  color: Theme.of(context).hintColor,
  textColor: Colors.white,
  child: Text(​"Create an Account"​.toUpperCase()),
  onPressed: () async {
 try​ {
  _user = await signUp(
  _emailController.text,
  _passwordController.text
  );
 if​(!_user.isEmailVerified) {
  _user.sendEmailVerification();
  }
  _verificationComplete = ​true​;
  Navigator.pushReplacement(
  context, MaterialPageRoute(
  builder: (context) => ConfigPage()
  )
  );
  }
 catch​(e) {
  Scaffold.of(context).showSnackBar(
  SnackBar(
  content: Text(​"An error occurred"​)
  )
  );
  }
  },
  ),
  ),
  ],
  )
  );
 }

At this point no guidance should be needed to understand these widgets, all of which we have already used at some point.

An exception is Navigator.pushReplacement, which is just like Navigator.push, but it provides no way for the user to go back to the previous page. In this case each screen means that some progress has been done, so the previous screen was a worry of the past we don’t want the user to accidentally go back to.

Config Page

After logging in, the user has to enter a display name, so we need a screen called ConfigPage specifically meant to achieve that, since it’s a step that requires authentication to have happened and is a prerequisite to using the chat page:

 @override
 Widget ​build​(BuildContext context) {
 return​ Scaffold(
  appBar: AppBar(title: Text(​"Configure you account's basic information"​)),
  body: ListView(
  children: <Widget>[
  TextField(decoration: InputDecoration(
  labelText: ​"Display Name"
  ),
  onSubmitted: (displayName) =>
  setNameAndGoToChatPage(displayName, context),
  controller: _controller,
  ),
  FlatButton(
  child: Text(​"Submit"​),
  onPressed: () =>
  setNameAndGoToChatPage(_controller.text, context),
  )
  ],
  ),
  );
 }

Once again, we’re going to implement setNameAndGoToChatPage later. All we need to know about it is that it’s certainly going to push a new screen, loading a widget called ChatPage, which we’ll implement now.

Chat Page

Here’s what we’re going to build in this section:

images/Firebase/chatPage.png

The ChatPage is going to consist of a Column that shows an Expanded-wrapped ListView built using the messages passed to the StreamBuilder and a Row(shown at the bottom of the screen, since the ListView is going to be expanded by the Expanded widget) showing a TextField and an IconButton to send the message, making use of a TextEditingController as always:

 @override
 Widget ​build​(BuildContext context) {
 return​ Scaffold(
  appBar: AppBar(
  title: Text(​"ChatOnFire"​),
  ),
  body: Column(
  children: <Widget>[
  Expanded(
  child: StreamBuilder(
  stream: getMessages(),
  builder: (context, snapshot) =>
  snapshot.hasData ?
  MessagesList(snapshot.data ​as​ QuerySnapshot)
  :
  Center(child: CircularProgressIndicator())
  ),
  ),
  Row(
  children: <Widget>[
  Expanded(
  child: Padding(
  padding: ​const​ EdgeInsets.all(8.0),
  child: TextField(
  controller: _messageController,
  keyboardType: TextInputType.text,
  onSubmitted: (txt) {
  sendText(txt);
  _messageController.clear();
  }
  ),
  ),
  ),
  IconButton(
  icon: Icon(Icons.send),
  onPressed: () {
  sendText(_messageController.text);
  _messageController.clear();
  }
  )
  ],
  )
  ],
  ),
  );
 }

As always, getMessages and the MessagesList will be defined later, when we implement an interface with the Cloud Firestore in Database.

The List of Messages

In the MessagesList widget, we’ll take the list of messages and create a ListView using the ListView.builder constructor, passing the data needed to show the message (sender, text, and timestamp) to a Message widget we’ll build later.

The ListView’s reverse argument makes the list start from the bottom, meaning the first element will be shown at the bottom, which will be the scrolling view’s default position, which will be held when new messages are added. This is the typical behavior of chat apps and we want to replicate it, so we’ll set that to true:

 class​ MessagesList ​extends​ StatelessWidget {
  MessagesList(​this​.data);
 
 final​ QuerySnapshot data;
 
 bool​ areSameDay(Timestamp a, Timestamp b) {
 var​ date1 = a.toDate().toLocal();
 var​ date2 = b.toDate().toLocal();
 return
  (date1.year == date2.year)
  &&
  (date1.month == date2.month)
  &&
  (date1.day == date2.day);
  }
 
  @override
  Widget build(BuildContext context) =>
  ListView.builder(
  reverse: ​true​,
  itemCount: data.documents.length,
  itemBuilder: (context, i) {
 var​ months = [
 "January"​,
 "February"​,
 "March"​,
 "April"​,
 "May"​,
 "June"​,
 "July"​,
 "August"​,
 "September"​,
 "October"​,
 "November"​,
 "December"
  ];
  DateTime when = data
  .documents[i]
  .data[​"when"​]
  .toDate()
  .toLocal();
 var​ widgetsToShow = <Widget>[
  Message(
  from: data.documents[i].data[​"from"​],
  msg: data.documents[i].data[​"msg"​],
  when: when
  ),
  ];
 if​(i == data.documents.length-1) {
  widgetsToShow.insert(
  0,
  Padding(
  padding: ​const​ EdgeInsets.symmetric(vertical: 10.0),
  child: Text(
 "​​${when.day}​​ ​​${months[when.month-1]}​​ ​​${when.year}​​"​,
  style: Theme.of(context).textTheme.subhead,
  ),
  )
  );
  } ​else​ ​if​(
  !areSameDay(
  data.documents[i+1].data[​"when"​],
  data.documents[i].data[​"when"​]
  )
  ) {
  widgetsToShow.insert(
  0,
  Padding(
  padding: ​const​ EdgeInsets.symmetric(vertical: 10.0),
  child: Text(
 "​​${when.day}​​ ​​${months[when.month-1]}​​ ​​${when.year}​​"​,
  style: Theme.of(context).textTheme.subhead
  ),
  )
  );
  }
 return​ Column(
  children: widgetsToShow
  );
  }
  );
 }

You might have noticed that we are building the ListView using a Column that allows us to show the date on which a message was sent if it is different than the previous message’s date. This is the preferred approach of many chat apps since it allows us to show the date only when it matters to the user.

One disadvantage is that doing it that way is a bit unintuitive and verbose: we need to have an if and an else if necessarily because having both conditions in the first if will cause the app to crash if we try to access the document at index documents.length, but it’s made even worse by the fact that the list is created in two steps, making the contents less clear at first glance.

Dart 2.3 helps with that by allowing us to integrate if statements in list literals, allowing us to bypass the widgetsToShow variable completely and return a Column that has, at the start of the list of children, the following:

 return​ ​Column​(
  children: [
 if​(i == data.documents.length)
  Padding(
  padding: ​const​ EdgeInsets.symmetric(vertical: 10.0),
  child: Text(
 "​​${when.day}​​ ​​${months[when.month-1]}​​ ​​${when.year}​​"​,
  style: Theme.of(context).textTheme.subhead
  ),
  )
 else​ ​if​(
  !areSameDay(
  data.documents[i+1].data[​"when"​],
  data.documents[i].data[​"when"​]
  )
  )
  Padding(
  padding: ​const​ EdgeInsets.symmetric(vertical: 10.0),
  child: Text(
 "​​${when.day}​​ ​​${months[when.month-1]}​​ ​​${when.year}​​"​,
  style: Theme.of(context).textTheme.subhead
  ),
  ),

This is more intuitive, but it requires you to change pubspec.yaml’s environment section to the following, requiring Dart version 2.3.0 or higher:

 environment:
  sdk: ​"​​>=2.3.0​ ​<3.0.0"

In case you wondered, for loops can be used inside list literals in a similar way starting from Dart version 2.3.2.

Showing Each Message

 @override
 Widget ​build​(BuildContext context) {
 
 return​ FutureBuilder(
  future: FirebaseAuth.instance.currentUser(),
  builder: (context, snapshot) {
 if​(snapshot.hasData) {
  FirebaseUser user = snapshot.data;
 return​ Container(
  alignment: user.displayName == from
  ?
  Alignment.centerRight
  :
  Alignment.centerLeft,
  child: Container(
  width: MediaQuery.of(context).size.width/3*2,
  child: Card(
  shape: StadiumBorder(),
  child: ListTile(
  title: user.displayName != from
  ? Padding(
  padding: ​const​ EdgeInsets.only(
  top: 8.0,
  left: 5.0
  ),
  child: Text(
  from,
  style: Theme.of(context).textTheme.subtitle
  ),
  )
  : Padding(
  padding: EdgeInsets.only(left: 5.0),
  child: Text(
 "You"​,
  style: Theme.of(context).textTheme.subtitle
  ),
  ),
  subtitle: Padding(
  padding: ​const​ EdgeInsets.only(
  bottom: 10.0,
  left: 5.0
  ),
  child: Text(
  msg,
  style: Theme.of(context).textTheme.body1
  ),
  ),
  trailing: Text(​"​​${when.hour}​​:​​${when.minute}​​"​),
  )
  ),
  )
  );
  } ​else​ {
 return​ CircularProgressIndicator();
  }
  }
  );
 }

Implementing an Interface with Firebase

The emphasis on email/password authentication in the previous section was spefically meant to prepare you to implement this kind of authentication method in our chat app.

We’re going to follow the flow of user interaction for this section as well and start with implementing an interface for authentication purposes.

Authentication

In Writing Log-In and Sign-Up Methods, we saw how to implement a log-in method:

 Future<FirebaseUser> logIn(
 String​ email, ​String​ password
 ) async =>
  _auth.signInWithEmailAndPassword(
  email: email,
  password: password
  );

and a sign-up method:

 Future<FirebaseUser> signUp(
 String​ email, ​String​ password
 ) async =>
  _auth.createUserWithEmailAndPassword(
  email: email,
  password: password
  );

What we’re missing is a method that sets the display name and redirects to the chat page, as needed by the ConfigPage.

That’s something that you shouldn’t find too hard since we have already seen how to change data such as the display name when we’ve covered The FirebaseUser:

 void​ ​setNameAndGoToChatPage​(​String​ name, BuildContext context) {
  FirebaseAuth.instance.currentUser().then(
  (user) {
 var​ newUserInfo = UserUpdateInfo();
  newUserInfo.displayName = name;
  user.updateProfile(newUserInfo);
  }
  );
  Navigator.pushReplacement(
  context,
  MaterialPageRoute(
  builder: (_) => ChatPage(),
  )
  );
 }

Database

Sending a message is done using the CollectionReference.add we mentioned when we talked about adding documents to the Cloud Firestore in Adding Data to the Cloud Firestore:

 void​ ​sendText​(​String​ text) =>
  FirebaseAuth.instance.currentUser().then(
  (user) =>
  Firestore.instance.collection(​"Messages"​).add(
  {
 "from"​: user != ​null​ ? user.displayName : ​"Anonymous"​,
 "when"​: Timestamp.fromDate(DateTime.now().toUtc()),
 "msg"​: text,
  }
  )
  );

Among the methods discussed at the start of the chapter (Reading from the Cloud Firestore) to read data from the Cloud Firestore, we need to get a Stream to use with a StreamBuilder, so we use CollectionReference.snapshots:

 Stream<QuerySnapshot> getMessages() =>
  Firestore
  .instance
  .collection(​"Messages"​)
  .orderBy(​"when"​, descending: ​true​)
  .snapshots();

Adding a Profile Page: Storing User Data in the Cloud Firestore

There are some issues with our app: two users with the same display name would create confusion, since we are only storing a user’s display name in the database, and not their ID, so our app would think a user was the sender of a message even if the user who sent the message was a completely different user that happened to have the same display name.

Additionally, the app doesn’t really provide any way to distinguish between these two users, so that would be a confusing situation for everyone. A good start would be to provide users with a way to set a short string to customize their profile (usually called a status or a bio) and a way to contact another user of the chat app via email since we’re already asking for that information.

Creating a Collection for User Data

Let’s create another collection! We’ll call it Users, and it’ll be structured the following way:

  • The user’s uid will be the document’s ID, to make it easier to access the document corresponding to each user.

  • The user’s displayName will be a string called displayName.

  • The user’s email address will be another string called email.

  • The user’s bio will be stored as a string called bio.

Changing the Documents and the App

We need to create entries to the user data collection for each existing user of our app and change every entry in the collection of messages and substitute the display name with the user ID. Then, we’ll worry about making the app work with the changed messages in the database, make it display the display name fetched from the Firestore, and then we’ll work on building a profile page.

Changing the Data

Creating the users’ collection has to be done manually and it’s a good idea generally to have a collection containing user data: that’s how it is in most “traditional” databases and it’s worth it to replicate it in Firebase, except for the fact we don’t need (and shouldn’t, and especially not in plain text) to store passwords in the database we can access.

Let’s start with dealing with the changing data: to change each message in the database and substitute the values is easy enough when there aren’t many messages since we can just change them manually in the Firebase console, but with bigger databases you’d have to write some code that (using the interfaces we learned about in this chapter) fetches the messages, finds the user with the given display name and substitutes the display name with the user ID.

Changing the App

The changes we need to make to our app start, as always, before the user even gets to the chat page. After log-in or sign-up, when we ask the user for a display name, we need to prompt the user to enter a bio.

We’ll change the setNameAndGoToChatPage(name, context) to a generic setDataAndGoToChatPage(name, bio, context). This addition means we need to define another TextEditingController and change the UI to this:

 TextField(
  decoration: InputDecoration(
  labelText: ​"Display Name"
  ),
  controller: _nameController,
 ),
 TextField(
  decoration: InputDecoration(
  labelText: ​"Bio"
  ),
  controller: _bioController,
 ),
 FlatButton(
  child: Text(​"Submit"​),
  onPressed: () =>
  setDataAndGoToChatPage(
  _nameController.text,
  _bioController.text, context
  ),
 )

The method itself will do the same things setNameAndGoToChatPage did, but it will also add a document to the Users collection:

 void​ ​setDataAndGoToChatPage​(
 String​ name,
 String​ bio,
  BuildContext context
 ) {
  FirebaseAuth.instance.currentUser().then(
  (user) {
 var​ newUserInfo = UserUpdateInfo();
  newUserInfo.displayName = name;
  user.updateProfile(newUserInfo);
  Firestore
  .instance
  .collection(​"Users"​)
  .document(user.uid)
  .setData(
  {
 "bio"​: bio,
 "displayName"​: name,
 "email"​: user.email,
  }
  );
  }
  );
  Navigator.pushReplacement(
  context,
  MaterialPageRoute(
  builder: (_) => ChatPage(),
  )
  );
 }

Another place where things need to change is the chat page, more specifically, the list of messages should pass the entire user to the Message class, so that it has all of the information about it that will also be needed by the ProfilePage, but it should also pass the user ID, which isn’t actually included in the Map that contains the user data, since the ID actually represents the path where to find the user data in the Firestore collection.

This requires changing the Message constructor and properties:

 Message({​this​.from, ​this​.msg, ​this​.when, ​this​.uid});
 
 final​ Map<​String​, ​dynamic​> from;
 final​ ​String​ uid;
 final​ ​String​ msg;
 final​ DateTime when;

and the call from the MessagesList:

 ? Message(
  from: (snapshot.data ​as​ DocumentSnapshot).data,
  msg: data.documents[i].data[​"msg"​],
  when: when,
  uid: data.documents[i].data[​"from"​]
 )

The Message itself doesn’t need to change a lot: it just needs to align widgets left or right by checking the UID we got from the MessagesList with the current user’s ID instead of comparing display names, everything else will be supplied by the from argument. The other change is that the display name’s Text widget (the title of each ListTile) will now be wrapped in an InkWell to make it possible for the user to tap it and go to the profile page:

 @override
 Widget ​build​(BuildContext context) {
 
 return​ FutureBuilder(
  future: FirebaseAuth.instance.currentUser(),
  builder: (context, snapshot) {
 if​(snapshot.hasData) {
  FirebaseUser user = snapshot.data;
 return​ Container(
  alignment: user.uid == uid
  ? Alignment.centerRight
  : Alignment.centerLeft,
  child: Container(
  width: MediaQuery.of(context).size.width*2/3,
  child: Card(
  shape: StadiumBorder(),
  child: ListTile(
  title: user.uid != uid
  ?
  InkWell(
  child: Padding(
  padding: EdgeInsets.only(
  top: 8.0,
  left: 5.0
  ),
  child: Text(
  from[​"displayName"​],
  style: Theme.of(context).textTheme.subtitle
  ),
  ),
  onTap: () =>
  Navigator.push(
  context,
  MaterialPageRoute(
  builder: (context) => ProfilePage(from)
  )
  ),
  )
  :
  InkWell(
  child: Padding(
  padding: EdgeInsets.only(left: 5.0),
  child: Text(
 "You"​,
  style: Theme.of(context).textTheme.subtitle
  ),
  ),
  onTap: () =>
  Navigator.push(
  context,
  MaterialPageRoute(
  builder: (context) => ProfilePage(from)
  )
  ),
  ),
  subtitle: Padding(
  padding: ​const​ EdgeInsets.only(
  bottom: 10.0,
  left: 5.0
  ),
  child: Text(
  msg,
  style: Theme.of(context).textTheme.body1
  ),
  ),
  trailing: Text(​"​​${when.hour}​​:​​${when.minute}​​"​),
  )
  ),
  )
  );
  } ​else​ {
 return​ CircularProgressIndicator();
  }
  }
  );
 }

Creating a Profile Page

The profile page, as we said previously, needs to show three things: the user’s display name, the bio, and a button to send an email. To do the latter we need a new package: the url_launcher package, which allows us to launch an URL from the app.

Using url_launcher to Launch an URL and Send an Email

Since an URL can also be used to create an email by prefixing the email address to which to send the email with mailto:, we need to add the url_launcher package to the dependencies in pubspec.yaml and import it into main.dart:

 import​ ​'package:url_launcher/url_launcher.dart'​;

The package provides two functions:

  • canLaunch(url), which returns a Future<bool> and checks whether there are any apps that are capable of handling the URL we’re trying to launch.

  • launch(url), which launches the URL asynchronously, throwing a PlatformException if no app was able to handle the URL.

The profile page UI itself is going to be very simple: a Column (with evenly spaced children) with two Text widgets (one for the display name and one for the bio) and a FlatButton to allow the user to send an email.

More specifically, the FlatButton is going to have an icon as well as text as shown in the screenshot.

images/Firebase/profilepage.png

The code is the following:

 class​ ProfilePage ​extends​ StatelessWidget {
  ProfilePage(​this​.user);
 
 final​ Map<​String​, ​dynamic​> user;
  @override
  Widget build(context) {
 return​ Scaffold(
  body: Center(
  child: Column(
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: <Widget>[
  Text(
  user[​"displayName"​],
  style: Theme.of(context).textTheme.title
  ),
  Text(
  user[​"bio"​],
  style: Theme.of(context).textTheme.subtitle
  ),
  FlatButton.icon(
  icon: Icon(Icons.email),
  label: Text(​"Send an e-mail to ​​${user["displayName"]}​​"​),
  onPressed: () async {
 var​ url =
 "mailto:​​${user["email"]}​​?body=​​${user["displayName"]}​​,​​ ​​"​;
 if​(await canLaunch(url)) {
  launch(url);
  } ​else​ {
  Scaffold.of(context).showSnackBar(
  SnackBar(
  content: Text(​"You don't have any e-mail app"​),
  )
  );
  }
  }
  )
  ],
  ),
  )
  );
  }
 }

Allowing Users to Edit Their Bio

A user might want to edit their bio in the future. For that, we’ll provide an AppBar action in the chat page that fires up a new page called ChangeBioPage:

 appBar: AppBar(
  title: Text(​"ChatOnFire"​),
  actions: [
  IconButton(
  tooltip: ​"Change your bio"​,
  icon: Icon(Icons.edit),
  onPressed: () =>
  Navigator.push(
  context,
  MaterialPageRoute(
  builder: (context) => ChangeBioPage()
  )
  ),
  )
  ],
 ),

The page itself is going to be very similar to the ConfigPage:

 class​ ChangeBioPage ​extends​ StatelessWidget {
 final​ _controller = TextEditingController();
 
 void​ _changeBio(​String​ bio) =>
  FirebaseAuth.instance.currentUser().then(
  (user) {
  Firestore
  .instance
  .collection(​"Users"​)
  .document(user.uid)
  .updateData(
  {
 "bio"​: bio
  }
  );
  }
  );
 
  @override
  Widget build(context) =>
  Scaffold(
  appBar: AppBar(
  title: Text(​"Change your bio"​),
  ),
  body: Center(
  child: Column(
  children: <Widget>[
  Padding(
  padding: ​const​ EdgeInsets.all(8.0),
  child: TextField(
  controller: _controller,
  decoration: InputDecoration(
  labelText: ​"New Bio"
  ),
  onSubmitted: (bio) {
  _changeBio(bio);
  Navigator.pop(context);
  }
  ),
  ),
  FlatButton(
  child: Text(​"Change Bio"​),
  onPressed: () {
  _changeBio(_controller.text);
  Navigator.pop(context);
  }
  )
  ],
  ),
  )
  );
 }

Making the App More Secure by Locking Down Access to the Firestore

Since we’ve implemented authentication, we shouldn’t keep the Cloud Firestore database rules as open as they are now because, even though we allow anyone to sign up, we should try to constrain access to the minimum necessary for the app to work.

What We’re Going to Do

More specifically we need to do the following:

  • Only allow users authenticated with a given uid to add or edit a user with that uid to the users’ collection, letting everybody read user data, but allowing nobody to delete users (at the moment we’re not offering that option).

  • Only allow users authenticated with a given uid to add a message that declares to be from that uid, letting everybody access the messages, but allowing nobody to edit or delete them (for the same reason: we’re not offering that option to users at the moment).

Implementing the Restriction

All of that is achieved by the following Cloud Firestore configuration (to be inserted in Database -> Rules in the Firebase console):

 service cloud.firestore {
  match /databases/{database}/documents {
  match /Users/{userId} {
  allow create, update: if request.auth.uid == userId;
  allow read: if request.auth != null;
  }
 
  match /Messages/{messageId} {
  allow create: if request.auth.uid == request.resource.data.from;
  allow read: if request.auth != null;
  }
  }
 }

matching Documents

match /databases/{database}/documents makes it so the root path of everything in the curly braces after that statement is at collection-level, meaning that, for example, /Users/ABCD is the document of the collection Users that has ID ABCD.

Inside that, we have other two match statements:

  • match /Users/{userId}, where we’re going to regulate access to the Users collection.

  • match /Messages/{message}, where we’re going to worry about access to the Messages collection.

allowing Access

allow statements are used in the following way:

 allow operation: if condition;

The operation can be either just read or write, or a more specific aspect of write, like create, update and delete. It can also be a comma-separated list of operations.

The condition can be just true or false (like we did at the start), but it should take into consideration both what document the user is trying to access and who the user is (whether they’re authenticated, what their UID is, etc.).

In our case, for each condition, we can use three pieces of data: whatever is in the curly braces in the path of any match statement that encloses it (for example, in the case of the Users match statement, {userId}, which is the path of the user document, in other words its ID), the request interface and the resource interface.

Let’s start with the latter: the resource interfaces contains data about the resource we’re trying to read, update, or delete. For example, if we wanted to access the from field of a message, we would write resource.data.from.

Very similar to resource is request.resource, which is the incoming data from the request. For example, if the request is trying to add a user, we would access that user’s bio with request.resource.data.bio. Another important proprty of the request interface is request.auth, which tells us whether the user who’s trying to acess the Firestore is authenticated, and what their credentials are. For example, if the user is not authenticated request.auth will be equal to null; if, instead, the user is authenticated request.auth.uid will return the UID of the user who’s trying to access the Firestore.

Combining all of these aspects and our requirements gives us the following for the Users:

 match /Users/{userId} {
  allow create, update: if request.auth.uid == userId;
  allow read: if request.auth != null;
 }

and the following for the Messages:

 match /Messages/{messageId} {
  allow create: if request.auth.uid == request.resource.data.from;
  allow read: if request.auth != null;
 }

A Few Words on Making Data in the Firestore More Secure

Now the data is inaccessible to anonymous users and, in the case of a public chat, it’s fine. But what about one-on-one or group chat apps? For one, you would need to separate messages from different conversations, which would be rather simple, but a really important issue is that of privacy and confindentiality: the messages would be stored in plain text in the database, allowing those who run the app to spy on conversations, which would all be revealed in case of a security incident. That is the reason why most one-on-one chat apps implement some sort of encryption, storing in the database encrypted messages that can only be encrypted using keys stored on each user’s device.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset