Cloudinary Blog

Impressed by WhatsApp technology? Build a WhatsApp clone with image and video upload

Clone WhatsApp Technology to Build a File Upload Android App

With more than one billion people using WhatsApp, the platform is becoming a go-to for reliable and secure instant messaging. Having so many users means that data transfer processes must be optimized and scalable across all platforms. WhatsApp is touted for its ability to achieve significant media quality preservation when traversing the network from sender to receiver, and this is no easy feat to achieve.

In this post, we will build a simple clone of WhatsApp with a focus on showcasing the background image upload process using Cloudinary’s Android SDK. The app is built using Pusher to implement real-time features. We’ll do this in two parts, first we’ll build the app with primary focus on the file upload and delivery with Cloudinary. Then in the second part, we’ll show how to apply Cloudinary’s transformation and optimization features to the images. To continue with the project, we’ll work on the assumption that you’re not new to Android development and you’ve worked with custom layouts for CompoundViews(a ListView in this case). If you have not, then check out this tutorial.

Setting up an Android Studio Project

Follow the pictures below to set up your Android project.

Create a new Android project

select minimum sdk

select an empty activity

finish the creation with the default activity names

For this tutorial, we will be using a number of third-party libraries including:

Open up your app level build.gradle file, add the following lines and sync your project :

implementation group: 'com.cloudinary', name: 'cloudinary-android', version: '1.22.0'
implementation 'com.pusher:pusher-java-client:1.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.picasso:picasso:2.5.2'

Before you proceed, create Cloudinary and Pusher accounts. You will need your API credentials to enable communication between your app and Cloudinary’s servers.

Open the AndroidManifest.xml file and add the following snippet:

<application...>
      ....
      <meta-data android:name="CLOUDINARY_URL"
      android:value="cloudinary://@myCloudName"

</application>

The metadata tag will be used for a one-time lifecycle initialization of the Cloudinary SDK. Replace the myCloudName with your cloud name, which can be found on your Cloudinary dashboard.

Set Up A Simple API Server

Next, you need to create a web server with your Pusher credentials to handle your HTTP requests. You can get them from your account dashboard.

Here’s a breakdown of what the server should do:

  • The app sends a message via HTTP to the server
  • Server receives message and emits a pusher event
  • The app then subscribes to the Pusher event and updates view

Here’s a basic example using Node.js :

// Import Dependencies
const Express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const uuid = require('uuid/v4');
const Pusher = require('pusher');
const pusher = new Pusher({
  appId: 'APP_ID',
  key: 'APP_KEY',
  secret: 'APP_SECRET',
  cluster: 'us2',
  encrypted: true
});
// Create an Express app
const app = Express();
// Configure middleware
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// Configure database and use a file adapter
const adapter = new FileSync('db.json');
const db = low(adapter);
// Choose a port
app.set('port', process.env.PORT || 8050);
app.post('/messages', (req, res) => {
  // Assemble data from the requesting client
  // Also assign an id and a creation time
  const post = Object.assign({}, req.body, {
    id: uuid(),
    created_at: new Date()
  });
  // Create post using `low`
  db
    .get('messages')
    .push(post)
    .write();
  // Respond with the last post that was created
  const newMessage = db
    .get('messages')
    .last()
    .value();
  pusher.trigger('messages', 'new-message', newMessage);
  res.json(newMessage);
});
// Listen to the chosen port
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));

With that set, we are ready to start building the app. Let’s begin by customizing the xml files to suit our needs. Open activity_chat.xml file and change its content to the one in the repository

Since we’re using ListView to show our chats, we need to create a custom ListView layout. So create a new layout resource file `message_xml` and modify its content to feature the necessary view objects required to achieve the chat view.

Next, add two vector assets. We won’t be covering how to do that here. But you can check the official Android documentation on how to do it. Now our XML files are good to go. Next, we have to start adding the application logic.

Application logic

To achieve the desired functionalities of the app, we’ll create two Java Classes Message and ListMessagesAdapter and an interface called Constants

So create a new java class called Message and modify its contents as:

public class Message {
    public String messageType, message, messageTime, user, image;
}

Once that's done, create the Adapter Class and modify its contents as well :

public class ListMessagesAdapter extends BaseAdapter {
private Context context;
private List<Message> messages;
public ListMessagesAdapter(Context context, List<Message> messages){
    this.context = context;
    this.messages = messages;
}
@Override
public int getCount() {
    return messages.size();
}
@Override
public Message getItem(int position) {
    return messages.get(position);
}
@Override
public long getItemId(int position) {
    return position;
}
public void add(Message message){
    messages.add(message);
    notifyDataSetChanged();
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
    if (convertView == null){
        convertView = LayoutInflater.from(context).inflate
        (R.layout.message_layout, parent, false);
    }
    TextView messageContent = convertView.findViewById(R.id.message_content);
    TextView timeStamp = convertView.findViewById(R.id.time_stamp);
    ImageView imageSent = convertView.findViewById(R.id.image_sent);
    View layoutView = convertView.findViewById(R.id.view_layout);
    Message message = messages.get(position);
    if (message.messageType.equals(Constants.IMAGE)){
        imageSent.setVisibility(View.VISIBLE);
        messageContent.setVisibility(View.GONE);
        layoutView.setBackgroundColor(context.getResources().getColor
        (android.R.color.transparent));
        timeStamp.setTextColor(context.getResources().getColor
        (android.R.color.black));
        Picasso.with(context)
                .load(message.image)
                .placeholder(R.mipmap.ic_launcher)
                .into(imageSent);
    } else {
        imageSent.setVisibility(View.GONE);
        messageContent.setVisibility(View.VISIBLE);
    }
    timeStamp.setText(message.user);
    messageContent.setText(message.message);
    return convertView;
}
}
// updating the ListView.
public void add(Message message){
    messages.add(message);
    notifyDataSetChanged();
}
/**
This method adds a new item to our List<Messages> container and subsequently
notifies the ListView holding the adapter of the change by calling the
“notifyDataSetChanged()” method.
**/

Finally, let’s create an interface for our constant values:

public interface Constants {
    String PUSHER_KEY = "*******************";
    String PUSHER_CLUSTER_TYPE = "us2";
    String MESSAGE_ENDPOINT = "https://fast-temple-83483.herokuapp.com/";
    String IMAGE = "image";
    String TEXT = "text";
    int IMAGE_CHOOSER_INTENT = 10001;
}

The interface file contains variables we will make reference to later in other classes. Having your constant values in the same class eases access to them. One crucial thing to note is that you need your own PUSHERKEY (you can get it from your profile dashboard on Pusher) and MESSAGEEND_POINT(representing your server link). Next, open your MainActivity.java file. Add the following method to your onCreate() method:

@Override
protected void onCreate(Bundle savedInstanceState){
  ...
  MediaManager.init(this)
}

The entry point of the Cloudinary Android SDK is the MediaManager class. MediaManager.init(this) initiates a one-time initialization of the project with the parameters specified in our metadata tag earlier on. Suffice to say, this initialization can only be executed once per application lifecycle.

Another way to achieve this without modifying the AndroidManifest.xml file is to pass an HashMap with the necessary configuration details as the second parameter of the MediaManager.init() method:

Map config = new HashMap();
config.put("cloud_name", "myCloudName");
MediaManager.init(this, config);

For this project, we will be sticking with the former method since we already modified our AndroidManifest.xml file.

Configure Pusher library

It’s time to configure our Pusher library. Add the following lines of code to your onCreate() method below.

PusherOptions options = new PusherOptions();
options.setCluster(Constants.PUSHER_CLUSTER_TYPE);
Pusher pusher = new Pusher(Constants.PUSHER_KEY, options);
Channel channel = pusher.subscribe("messages");

The snippet above is self explanatory.

messages is the name of the channel you created in your server. Now, we need to subscribe to an event in the messages channel. Hence, we’ll subscribe to the new-message event.

channel.bind("new-message", new SubscriptionEventListener() {
    @Override
    public void onEvent(String channelName, String eventName, final String data) {
        /..../
    }
});
pusher.connect();

Now, we have successfully tagged to our messages channel and subscribed to the new-message event. So, each time we send an HTTP request to the server, it redirects it to Pusher and we get notified of this “event” in our app, and we can then react to it appropriately in the onEvent(…) method.

Set Up Server Communication with Retrofit

Before we continue, we need to initialize the Retrofit library to communicate with our server.

To do this, we will create to Java files:

  • RetrofitUtils
  • Upload(an Interface)

Modify the the RetrofitUtils.java file :

public class RetrofitUtils {
    private static Retrofit retrofit;
    public static Retrofit getRetrofit(){
        if (retrofit != null){
            return retrofit;
        }
        retrofit = new Retrofit.Builder()
                .baseUrl(Constants.MESSAGE_ENDPOINT)
                .addConverterFactory(GsonConverterFactory.create())
                .build();
        return retrofit;
    }
}

In the Upload.java file, we also set it up as so:

public interface Upload {
    @FormUrlEncoded
    @POST("messages")
    Call<Void> message(@Field("message") String message, @Field("user")
    String user);
    @FormUrlEncoded
    @POST("messages")
    Call<Void> picture(@Field("message") String message, @Field("user")
    String user, @Field("image") String imageLink);
}

This is not a Retrofit tutorial, so I won’t be covering the basics of using the library. There are a number of Medium articles that provide those details. But you can check this article from Vogella or this one by Code TutsPlus. What you need to know, however, is the reason we are making two POST requests. The first POST request will be triggered in the case the user sends only a text. The second will be triggered in the case of picture upload.

Hence we’ll use this second POST request to handle this part of the tutorial for Image Upload and Delivery using Cloudinary.

Handling File Upload and Delivery with Cloudinary

Now we’ll start adding the logic and discussing how to achieve the file upload features using the Cloudinary account we’ve set up. Given the code complexity of this part, we’ll be walking through it with snippets and providing explanations as we go. To handle the image upload features, we’ll head back to the MainActivity.java file and set it up the onClick() method :

case R.id.load_image:
  Intent  chooseImage = new Intent();
  chooseImage.setType("image/*");
  chooseImage.setAction(Intent.ACTION_GET_CONTENT);
  startActivityForResult(Intent.createChooser(chooseImage, "Select Picture"), Constants.IMAGE_CHOOSER_INTENT);
  break;

Here we sent an implicit intent for images upload. This would pop up a new activity to select pictures from your phone. Once that is done, we want to get the details of the selected image. This is handled in the onActivityResult(…) method. Here’s how we set up the method:

@override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == Constants.IMAGE_CHOOSER_INTENT && resultCode == RESULT_OK){
    if (data != null && data.getData() != null){
        uri = data.getData();
        hasUploadedPicture = true;
        String localImagePath = getRealPathFromURI(uri);
        Bitmap bitmap;
        try {
            InputStream stream = getContentResolver().openInputStream(uri);
            bitmap = BitmapFactory.decodeStream(stream);
            localImage.setVisibility(View.VISIBLE);
            localImage.setImageBitmap(bitmap);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        imagePath = MediaManager.get().url().generate(getFileName(uri));
        typedMessage.setText(localImagePath);
    }
}
}

Next we set up a method that will be triggered whenever the user selects an image. Once this method executes, we will have the URI of the selected image stored in the “uri” variable. We monitor the image upload with hasUploadedPicture variable. This will be useful in determining which upload interface method to trigger. so we set it up as:

@Override
public void onClick(View v) {
    switch (v.getId()){
        case R.id.send:
//                makeToast("Send clicked");
            if (hasUploadedPicture){
//                unsigned upload
                String requestId = MediaManager.get()
                  .upload(uri)
                  .unsigned("sample_preset")
                  .option("resource_type", "image")
                  .callback(new UploadCallback() {
                    @Override
                    public void onStart(String requestId) {
                        makeToast("Uploading...");
                    }
                    @Override
                    public void onProgress(String requestId, long bytes,
                                           long totalBytes) {
                    }
                    @Override
                    public void onSuccess(String requestId, Map resultData) {
                        makeToast("Upload finished");
                        imagePath = MediaManager.get().url()
                        .generate(resultData.get("public_id").toString()
                        .concat(".jpg"));
                        uploadToPusher();
                    }
                    @Override
                     public void onError(String requestId, ErrorInfo error) {
                        makeToast("An error occurred.\n" + error
                        .getDescription());
                    }
                    @Override
                     public void onReschedule(String requestId,
                                              ErrorInfo error) {
                        makeToast("Upload rescheduled\n" + error
                        .getDescription());
                }).dispatch();

            } else {
                upload.message(typedMessage.getText().toString(), "Eipeks"
                ).enqueue(new Callback<Void>() {
                @Override
                public void onResponse(@NonNull Call<Void> call,
                                       @NonNull Response<Void> response) {
                     switch (response.code()){
                      case 200:
                      typedMessage.setText("");
                      break;
                }
            }
                @Override
                public void onFailure(@NonNull Call<Void> call,
                @NonNull Throwable t) {
                   Toast.makeText(Chat.this, "Error uploading message",
                   Toast.LENGTH_SHORT).show();
                }
            });
         }
         break;
      case R.id.load_image:
         Intent  chooseImage = new Intent();
         chooseImage.setType("image/*");
         chooseImage.setAction(Intent.ACTION_GET_CONTENT);
         startActivityForResult(Intent.createChooser(
         chooseImage, "Select Picture"),
         Constants.IMAGE_CHOOSER_INTENT);
         break;
  }
}

At this point we have the URI of the selected image stored in the uri variable and the hasUploaded variable should now let us know whether the image upload was successful or not. With this information, we can head on back to the onClick method and upload the selected image to pusher:

@Override
public void onClick(View v) {
    switch (v.getId()){
        case R.id.send:
//                makeToast("Send clicked");
            if (hasUploadedPicture){
                String requestId = MediaManager.get()
                .upload(uri)
                .unsigned("myPreset")
                .option("resource_type", "image")
                .callback(new UploadCallback() {
                      @Override
public void onStart(String requestId) {
   makeToast("Uploading...");
}
.........

To further explain what went on here, it is worth noting that this method is used for building our image upload request. It contains five synchronized methods:

  • get()
  • upload()
  • option()
  • callback()
  • dispatch()
  • upload() is an overloaded method however, we’ll be using upload(Uri uri) since we already have the uri of the image we want to upload.

We need to set an unsigned upload preset to upload images to our cloud without a secret key.

  • option() takes in two parameters: name and value If you are uploading a video, then your value will be video instead of image.
  • callback() method. This method is used in tracking the progress of the upload. We are using the UploadCallback(){…} as its method
  • onSuccess() method is triggered upon successful completion of the the media upload. This method contains two parameters: String requestId and Map resultData

The resultData contains information about the uploaded picture. The information we need is the uniquely generated picture name, which can be accessed from the resultData using the public_id as the key. Cloudinary also enables unique url() generation for easy access of the uploaded picture. That’s what we achieved with this bit of code

imagePath = MediaManager.get().url().format("webp"). generate(resultData.get("public_id"));

The line ensures that we can access the image we’ve uploaded using the Retrofit library. With this, we then call the uploadToPusher() method.

private void uploadToPusher(){
    upload.picture(typedMessage.getText().toString(), "Eipeks", imagePath)
        .enqueue(new Callback<Void>() {
            @Override
            public void onResponse(@NonNull Call<Void> call,
                                   @NonNull Response<Void> response) {
                switch (response.code()){
                    case 200:
                        localImage.setVisibility(View.GONE);
                        typedMessage.setText("");
                        break;
                }
            }
            @Override
           public void onFailure(@NonNull Call<Void> call, @NonNull Throwable t) {
               Toast.makeText(Chat.this, "Failed to upload picture\n" +
               t.getLocalizedMessage(), Toast.LENGTH_SHORT).show();
           }
        });
}

Once this method executes, our HTTP requests reaches the server, which in turn redirects the information we’ve uploaded to Pusher. This information goes to the “messages” channel. Since, we have subscribed to the “new-messages” event, our application is notified of this event. All that’s left is for our app to react appropriately to this event. Next, we will modify our onEvent() method.

@Override
public void onEvent(String channelName, String eventName, final String data) {
    Gson gson = new Gson();
    final Message message = gson.fromJson(data, Message.class);
    if (hasUploadedPicture){
        message.messageType = Constants.IMAGE;
    } else {
        message.messageType = Constants.TEXT;
    }
    hasUploadedPicture = false;
    messages.add(message);
    runOnUiThread(new Runnable() {
        @Override
        public void run() {
            messagesList.setSelection(messagesList.getAdapter().getCount() - 1);
        }
    });
}

This brings us to the end of this part of this tutorial, on the next part we’ll be discussing how to manipulate our uploaded images with Cloudinary to add transformations and optimizations. Here’s an image showing how the image upload works thus far:

Build a WhatsApp clone with image and video upload

Feel free to check the official documentation here. The source code for the project is on GitHub. In the next part of this article, we will cover how uploaded images can be transformed and what we can get from Cloudinary’s optimization features.

Recent Blog Posts

Reimaging DAM--The Next-Gen DAM for Marketing & Dev

There are great digital asset management (DAM) products out there for uploading, storing, managing, organizing, and sharing digital assets. With Cloudinary's new end-to-end DAM solution, you can also upload and manage your assets efficiently, but the journey doesn't end there. It continues on to the development and delivery stages, so that your assets can be seamlessly manipulated, optimized, and delivered to create an engaging user experience that will in turn, increase conversion and loyalty.

Read more
Integrating Cloudinary with Forestry’s Media Library

At Forestry, we believe that there is a bright future for static HTML sites built with tools like Jekyll and Hugo. These tools can create sites that run well, and are easy to host and maintain, because they don’t require any server-side code.

Read more
Video Optimization With the HTML5 <video> Player

Lack of experience and compression knowhow can cause significant user-experience problems. For instance, on a major retail site, I recently ran into a 48 MB video-hero banner. Pulling out the video and encoding it as an H.264 MP4 reduces the size to 1.9 MB. So, despite the desire for more video content, developers have not yet caught up to best practices. How do we get the best of both worlds without creating a disaster like the one above?

Read more
Build a Facial Emotion Recognition Based Video Suggestion App

Developers are always looking for new and creative ways to deliver content that resonates with the way users feel. Often using the latest technical innovations the market has to offer such as Artificial Intelligence (AI) and Machine Learning (ML). What better way to demonstrate innovative uses of these technology in a consumer market than capturing expressions from your users and then serving content based on that expression!

Read more
Improve Customer Data Protection with GDPR Implementation

TL;DR

Yay! We've done it! Gold-Star for us! We've talked with all the people, made all the changes, paid all the lawyers and checked all the boxes. GDPR? ✅Done!

Not so fast. Of course, conforming to the GDPR regulations introduced in Europe is just the beginning. This is a process and a state of mind that must become part of our long-term cultural ethos.

Read more