Cloudinary Blog

Impressed by WhatsApp technology? Clone WhatsApp Technology to Build a File Upload Android App

By
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 technology 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 :

Copy to clipboard
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:

Copy to clipboard
<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 :

Copy to clipboard
// 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:

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

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

Copy to clipboard
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:

Copy to clipboard
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 PUSHER_KEY (you can get it from your profile dashboard on Pusher) and MESSAGE_END_POINT(representing your server link). Next, open your MainActivity.java file. Add the following method to your onCreate() method:

Copy to clipboard
@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:

Copy to clipboard
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.

Copy to clipboard
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.

Copy to clipboard
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 :

Copy to clipboard
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:

Copy to clipboard
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 Android File Upload and Delivery with Cloudinary

Now we’ll start adding the logic and discussing how to achieve the Android 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 :

Copy to clipboard
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:

Copy to clipboard
@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:

Copy to clipboard
@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:

Copy to clipboard
@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

Copy to clipboard
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.

Copy to clipboard
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.

Copy to clipboard
@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.


Want to Learn More About File Uploads?

Recent Blog Posts

Our $2B Valuation

By
Blackstone Growth Invests in Cloudinary

When we started our journey in 2012, we were looking to improve our lives as developers by making it easier for us to handle the arduous tasks of handling images and videos in our code. That initial line of developer code has evolved into a full suite of media experience solutions driven by a mission that gradually revealed itself over the course of the past 10 years: help companies unleash the full potential of their media to create the most engaging visual experiences.

Read more
Direct-to-Consumer E-Commerce Requires Compelling Visual Experiences

When brands like you adopt a direct–to-consumer (DTC) e-commerce approach with no involvement of retailers or marketplaces, you gain direct and timely insight into evolving shopping behaviors. Accordingly, you can accommodate shoppers’ preferences by continually adjusting your product offering and interspersing the shopping journey with moments of excitement and intrigue. Opportunities abound for you to cultivate engaging customer relationships.

Read more
Automatically Translating Videos for an International Audience

No matter your business focus—public service, B2B integration, recruitment—multimedia, in particular video, is remarkably effective in communicating with the audience. Before, making video accessible to diverse viewers involved tasks galore, such as eliciting the service of production studios to manually dub, transcribe, and add subtitles. Those operations were costly and slow, especially for globally destined content.

Read more
Cloudinary Helps Minted Manage Its Image-Generation Pipeline at Scale

Shoppers return time and again to Minted’s global online community of independent artists and designers because they know they can count on unique, statement-making products of the highest quality there. Concurrently, the visual imagery on Minted.com must do justice to the designs into which the creators have poured their hearts and souls. For Minted’s VP of Engineering David Lien, “Because we are a premium brand, we need to ensure that every single one of our product images matches the selected configuration exactly. For example, if you pick an 18x24 art print on blue canvas, we will show that exact combination on the hero images in the PDF.”

Read more
Highlights on ImageCon 2021 and a Preview of ImageCon 2022

New year, same trend! Visual media will continue to play a monumental role in driving online conversions. To keep up with visual-experience trends and best practices, Cloudinary holds an annual conference called ImageCon, a one-of-a-kind event that helps attendees create the most engaging visual experiences possible.

Read more