Build a collaborative text editor in Android

Introduction

In this tutorial, we will learn how to build a collaborative text editor in Android. We will be using Pusher Channels to make the collaboration part easy.

We will be building a simple Android app and it will have a single activity containing only an EditText view. We will then keep track of changes to the EditText view and broadcast these changes to other users on the same application. The application will also listen for updates and update the EditText with changes received.

Here is a demo of what will be built by the end of this tutorial:

collaborative-text-editor-android-demo

Let's get started!

Create the Android Studio Project

Launch Android Studio and create a new Project. You could name the application anything you want, but for the purpose of this tutorial we will name it 'CollabEditor'. Also, ensure you select the 'Empty Activity' option as the initial Activity and name it MainActivity on the 'Customize Activity Page'.

Once Android Studio is done with the project's setup, open the build.gradle file of your application's module to add the follow dependencies:

1dependencies {
2    ...
3    compile 'com.pusher:pusher-java-client:1.4.0'
4    compile 'com.google.code.gson:gson:2.7'
5}

These add Pusher Channels and Gson to our android project. Sync the Gradle project so the modules can be installed and the project built.

Next, Add the INTERNET permission to the AndroidManifest.xml file.

1<?xml version="1.0" encoding="utf-8"?>
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3    package="com.pusher.collabeditor">
4
5    <uses-permission android:name="android.permission.INTERNET" />
6
7    <application 
8        android:allowBackup="true"
9        android:icon="@mipmap/ic_launcher"
10        android:label="@string/app_name"
11        android:roundIcon="@mipmap/ic_launcher_round"
12        android:supportsRtl="true"
13        android:theme="@style/AppTheme">
14        ...
15    </application>
16
17</manifest>

Create the text editor layout

Next, open the activity_main.xml layout file and modify it to look like this:

1<?xml version="1.0" encoding="utf-8"?>
2<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3    xmlns:app="http://schemas.android.com/apk/res-auto"
4    xmlns:tools="http://schemas.android.com/tools"
5    android:layout_width="match_parent"
6    android:layout_height="match_parent"
7    tools:context="com.pusher.collabeditor.MainActivity">
8
9    <EditText
10        android:id="@+id/textEditor"
11        android:layout_width="match_parent"
12        android:layout_height="match_parent"
13        android:hint="Editor is empty. Select to start typing"
14        android:gravity="top"/>
15
16</LinearLayout>

The layout is quite simple. It contains an EditText with its width and height set to match_parent.

Create the 'EditorUpdate' model

Create the class com.pusher.collabeditor.EditorUpdate and write the following to it:

1package com.pusher.collabeditor;
2
3public class EditorUpdate {
4
5    public String data;
6
7    public EditorUpdate(String data) {
8        this.data = data;
9    }
10}

This class when converted to JSON with Gson corresponds to the following structure:

1{
2  "data": "Editor text will be here"
3}

This is the structure of JSON that would be sent to other users of the application when updates are made to the text editors content.

Setting up a Pusher account

To get started with Pusher Channels, sign up for a free Pusher account. Then go to the dashboard and create a new Channels app. Go to the App Keys tab and copy your App ID, Key, and Secret credentials. We will use them in our application.

Update the MainActivity

Now, back in Android Studio, open the class com.pusher.collabeditor.MainActivity.

First let us declare all the required constants and variables for the application:

1public class MainActivity extends AppCompatActivity {
2
3    private static final String DEBUG_TAG = MainActivity.class.getSimpleName();
4    private static final String PUSHER_API_KEY = "YOUR PUSHER APP KEY";
5    private static final String PUSHER_CLUSTER = "PUSHER APP CLUSTER";
6    private static final String AUTH_ENDPOINT = "PUSHER AUTHENTICATION ENDPOINT";
7
8    private Pusher pusher;
9    private EditText textEditor;
10    private TextWatcher textEditorWatcher;

Ensure you replace those variable values with your own Pusher credentials. I'll explain how to get the AUTH_ENDPOINT value later in this tutorial.

Next, in the onCreate method, set the content view and initialize the Pusher object like this:

1...
2
3@Override
4protected void onCreate(Bundle savedInstanceState) {
5    super.onCreate(savedInstanceState);
6    setContentView(R.layout.activity_main);
7    textEditor = (EditText) findViewById(R.id.textEditor);
8
9    pusher = new Pusher(PUSHER_API_KEY, new PusherOptions()
10            .setEncrypted(true)
11            .setCluster(PUSHER_CLUSTER)
12            .setAuthorizer(new HttpAuthorizer(AUTH_ENDPOINT)));
13
14}

We set an authorizer because we are going to be using Channels Client Events to broadcast changes on the text editor to other users of the application. An advantage of this is that we don't need to route our updates through a server.

Using Pusher Channels Client Events

To use Pusher ChannelsClient Events, it needs to be enabled for your Pusher Channels app. You can do this in the Settings tab for your app within the Pusher's dashboard. Client Events can only be broadcast on a private channel and event names must start with the prefix client-.

To use private channels, the Pusher client must be authenticated hence the reason for the AUTH_ENDPOINT. Pusher makes writing an auth server easy. I used their Node.js template here. Once set up, update the AUTH_ENDPOINT of your code to the URL of the auth server.

With all this in mind, we now go back to the Android code. After initializing the Pusher client, we create a PrivateChannelEventListener:

1...
2
3@Override
4protected void onCreate(Bundle savedInstanceState) {
5    ...
6
7    PrivateChannelEventListener subscriptionEventListener = new PrivateChannelEventListener() {
8
9        @Override
10        public void onEvent(String channel, String event, final String data) {
11            runOnUiThread(new Runnable() {
12                @Override
13                public void run() {
14                    EditorUpdate editorUpdate = new Gson().fromJson(data, EditorUpdate.class);
15                    textEditor.setText(editorUpdate.data);
16                }
17            });
18        }
19
20        @Override
21        public void onAuthenticationFailure(String message, Exception e) {
22            Log.d(DEBUG_TAG, "Authentication failed.");
23            Log.d(DEBUG_TAG, message);
24        }
25
26        @Override
27        public void onSubscriptionSucceeded(String message) {
28            Log.d(DEBUG_TAG, "Subscription Successful");
29            Log.d(DEBUG_TAG, message);
30        }
31    };
32
33    ...   
34}

When an event is received in the onEvent method, we convert the JSON data to an EditorUpdate object using Gson and then update the editor's text with the data received.

Next, we subscribe to our private channel and bind the event listener to client events on the channel.

1final PrivateChannel editorChannel = pusher.subscribePrivate("private-editor", subscriptionEventListener);
2noteChannel.bind("client-update", subscriptionEventListener);

Now, the text editor will update its content whenever it receives a client-update event.

Next, we need to add a TextWatcher to our textEditor:

1textEditorWatcher = new TextWatcher() {
2    @Override
3    public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
4    }
5
6    @Override
7    public void onTextChanged(CharSequence charSequence, int start, int before, int count) {
8        String text = charSequence.toString();
9        EditorUpdate editorUpdate = new EditorUpdate(text);
10        noteChannel.trigger("client-update", new Gson().toJson(editorUpdate));
11    }
12
13    @Override
14    public void afterTextChanged(Editable editable) {}
15};
16
17textEditor.addTextChangedListener(textEditorWatcher);

So when text changes on the editor, we trigger a client-update event on the editor channel. After this ensure that you connect and disconnect your Pusher client in the onResume() and onPause() methods respectively.

1@Override
2protected void onResume() {
3    super.onResume();
4    pusher.connect();
5}
6
7@Override
8protected void onPause() {
9    pusher.disconnect();
10    super.onPause();
11}

With this, our Android application is almost fully functional. If you were to run and test the Android application now, you would notice an endless update loop in the EditText whenever it receives an client-update event.

This loop is because when a client-update event is received, textEditor.setText() is called which in turn triggers textEditorWatcher.onTextChanged() and this causes another client-update to be sent to other applications which would restart the loop process. Below is an image showing how this looks like between two apps:

collaborative-text-editor-android-endless-loop

Fixing the EditText update loop

To fix this endless update loop, we will remove the textEditorWatcher from the textEditor before we call textEditor.setText() and then add it back afterwards.

1...
2PrivateChannelEventListener subscriptionEventListener = new PrivateChannelEventListener() {
3
4    @Override
5    public void onEvent(String channelName, String eventName, final String data) {
6        runOnUiThread(new Runnable() {
7            @Override
8            public void run() {
9                Log.d(DEBUG_TAG, data);
10                EditorUpdate editorUpdate = new Gson().fromJson(data, EditorUpdate.class);
11                //remove textEditorWatcher
12                textEditor.removeTextChangedListener(textEditorWatcher);
13                textEditor.setText(editorUpdate.data);
14                //add it back afterwards
15                textEditor.addTextChangedListener(textEditorWatcher);
16            }
17        });
18    }
19
20    ...
21};
22...

So this way, textEditor.setText() doesn't call textEditorWatcher.onTextChanged() and therefore the loop doesn't happen.

Now, our collaborative text editor Android app is fully functional. Yay!

Testing the application

To test the Android application, you will need to build and run the application on multiple devices (or you could just run it on multiple Android emulators). Any edit you make on an application's text editor will be seen in the other applications running.

Conclusion

In this tutorial, we have seen how to build a collaborative text editor in Android using Pusher Channels Client Events. Some extra things to note about this tutorial are:

  • This Android app doesn’t account for concurrent edits at the same place in the editor. You can use a technique called Operational Transforms to solve this.
  • Client Events have a number of restrictions that are important to know about, one of which is the limit to the number of events that can be published per seconds. Read more about them here.