Writing a Serverless Android app (ft. Huawei's AppGallery Connect) - Part 2 - CloudDB

Not read part 1? Read it first!

Over the next couple of months I will be releasing a complete end to end guide to creating an Android app using serverless functionality to completely remove the need for any backend server/hosting etc.

This weeks Video is below

At the bottom of this post you will find my twitch channel where we live stream every Thursday at 2pm BST and the GitHub repo for this project!.

But for those that would rather a written guide, lets get into it!

Starting with the project as we completed last week (on GitHub) lets now configure the application to support and use the CloudDB functionality from Huawei. Today we will be setting up everything we need to be able to use the CloudDB service and set/get/delete data.

Navigate to the the AppGallery Connect area of the developer portal, select the project we setup last week and on the left hand menu find CloudDB under the build sub menu.

From here enable the service and if you haven't already you will be asked to setup a data location.

Next under the ObjectTypes tab lets create the first data object, click add and you will be presented with a screen like this:

For todays example set your object type name to User, then we will create three fields, id, uid and username as below
image

Finally create an index called user_id with index field set to id. Leave data permission as they are and save your new data object.

Next go over to the next tab "Cloud DB Zones" and create a new zone, for this example we will call it "Barker".

Head back over to the ObjectTypes tab and press the "Export" button, Pick the JAVA file format, and android for file type. Then enter your Android package name.

This will download two files in a zipped folder, unzip and add these java files to your Android project.

Lets now take a look at these files, if you have followed my naming schemes your User.java should look like:

/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2019-2020. All rights reserved.
 * Generated by the CloudDB ObjectType compiler.  DO NOT EDIT!
 */
package site.zpweb.barker.model;

import com.huawei.agconnect.cloud.database.CloudDBZoneObject;
import com.huawei.agconnect.cloud.database.Text;
import com.huawei.agconnect.cloud.database.annotations.DefaultValue;
import com.huawei.agconnect.cloud.database.annotations.EntireEncrypted;
import com.huawei.agconnect.cloud.database.annotations.NotNull;
import com.huawei.agconnect.cloud.database.annotations.Indexes;
import com.huawei.agconnect.cloud.database.annotations.PrimaryKeys;

import java.util.Date;

/**
 * Definition of ObjectType User.
 *
 * @since 2021-07-09
 */
@PrimaryKeys({"id"})
@Indexes({"user_id:id"})
public final class User extends CloudDBZoneObject {
    private Integer id;

    @DefaultValue(stringValue = "0")
    private String uid;

    @DefaultValue(stringValue = "0")
    private String username;

    public User() {
        super(User.class);
        this.uid = "0";
        this.username = "0";
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public Integer getId() {
        return id;
    }

    public void setUid(String uid) {
        this.uid = uid;
    }

    public String getUid() {
        return uid;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }

}

Which as you can see is a fairly standard object class with setup for all the fields we defined in the ObjectType.

The other generated file ObjectTypeInfoHelper.java should look like

/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2019-2020. All rights reserved.
 * Generated by the CloudDB ObjectType compiler.  DO NOT EDIT!
 */
package site.zpweb.barker.model;

import com.huawei.agconnect.cloud.database.CloudDBZoneObject;
import com.huawei.agconnect.cloud.database.ObjectTypeInfo;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * Definition of ObjectType Helper.
 *
 * @since 2021-07-09
 */
public final class ObjectTypeInfoHelper {
    private static final int FORMAT_VERSION = 2;
    private static final int OBJECT_TYPE_VERSION = 10;

    public static ObjectTypeInfo getObjectTypeInfo() {
        ObjectTypeInfo objectTypeInfo = new ObjectTypeInfo();
        objectTypeInfo.setFormatVersion(FORMAT_VERSION);
        objectTypeInfo.setObjectTypeVersion(OBJECT_TYPE_VERSION);
        List<Class<? extends CloudDBZoneObject>> objectTypeList = new ArrayList<>();
        Collections.addAll(objectTypeList, User.class);
        objectTypeInfo.setObjectTypes(objectTypeList);
        return objectTypeInfo;
    }
}

Which is a helper class used by the framework to know what Object classes are available, in this instance just the User class.

You will notice that at this point the code doesn't compile! We need to add in the new CloudDB dependency to the apps build.gradle file

implementation 'com.huawei.agconnect:agconnect-cloud-database:1.4.8.300'

So your gradle file should now look like:

plugins {
    id 'com.android.application'
    id 'com.huawei.agconnect'
}

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"

    defaultConfig {
        applicationId "site.zpweb.barker"
        minSdkVersion 17
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'com.huawei.agconnect:agconnect-core:1.4.1.300'
    implementation 'com.huawei.agconnect:agconnect-auth:1.4.1.300'
    implementation 'com.huawei.agconnect:agconnect-cloud-database:1.4.8.300'
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

Once gradle has synced up we are good to go!
To more easily manage the connection between the CloudDB and functionality within the app I suggest written a separate class to do this. Below is my CloudDBManager class which will act as a wrapper handling much of the CloudDB functionality.

package site.zpweb.barker.db;

import android.content.Context;

import com.huawei.agconnect.cloud.database.AGConnectCloudDB;
import com.huawei.agconnect.cloud.database.CloudDBZone;
import com.huawei.agconnect.cloud.database.CloudDBZoneConfig;
import com.huawei.agconnect.cloud.database.CloudDBZoneObjectList;
import com.huawei.agconnect.cloud.database.CloudDBZoneQuery;
import com.huawei.agconnect.cloud.database.CloudDBZoneSnapshot;
import com.huawei.agconnect.cloud.database.exceptions.AGConnectCloudDBException;
import com.huawei.hmf.tasks.OnFailureListener;
import com.huawei.hmf.tasks.OnSuccessListener;
import com.huawei.hmf.tasks.Task;

import java.util.ArrayList;
import java.util.List;

import site.zpweb.barker.model.User;
import site.zpweb.barker.utils.Toaster;

public class CloudDBManager {

    private int maxUserID = 0;

    Toaster toaster = new Toaster();

    private final AGConnectCloudDB cloudDB;
    private CloudDBZone cloudDBZone;

    public CloudDBManager(){
        cloudDB = AGConnectCloudDB.getInstance();
    }

    public static void initCloudDB(Context context){
        AGConnectCloudDB.initialize(context);
    }

    public void openCloudDBZone(Context context){
        CloudDBZoneConfig config = new CloudDBZoneConfig("Barker",
                CloudDBZoneConfig.CloudDBZoneSyncProperty.CLOUDDBZONE_CLOUD_CACHE,
                CloudDBZoneConfig.CloudDBZoneAccessProperty.CLOUDDBZONE_PUBLIC);
        config.setPersistenceEnabled(true);

        try {
            cloudDBZone = cloudDB.openCloudDBZone(config, true);
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        }
    }

    public void closeCloudDBZone(Context context){
        try {
            cloudDB.closeCloudDBZone(cloudDBZone);
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        }
     }

    public void upsertUser(User user, Context context) {
        Task<Integer> upsertTask = cloudDBZone.executeUpsert(user);
        executeTask(upsertTask, context);
    }

    public void upsertUsers(List<User> users,Context context) {
        Task<Integer> upsertTask = cloudDBZone.executeUpsert(users);
        executeTask(upsertTask, context);
    }

    private void executeTask(Task<Integer> task,Context context) {
        task.addOnSuccessListener(integer -> toaster.sendSuccessToast(context, "upsert successful"))
                .addOnFailureListener(e -> toaster.sendErrorToast(context, e.getLocalizedMessage()));
    }

    public void deleteUser(User user){
        cloudDBZone.executeDelete(user);
    }

    public int getMaxUserID(){
        return maxUserID;
    }

    private void updateMaxUserID(User user){
        if (maxUserID < user.getId()) {
            maxUserID = user.getId();
        }
    }

    public void getAllUsers(Context context){
        queryUsers(CloudDBZoneQuery.where(User.class), context);
    }

    public void queryUsers(CloudDBZoneQuery<User> query, Context context) {
        Task<CloudDBZoneSnapshot<User>> task = cloudDBZone.executeQuery(query,
                CloudDBZoneQuery.CloudDBZoneQueryPolicy.POLICY_QUERY_FROM_CLOUD_ONLY);
        task.addOnSuccessListener(userCloudDBZoneSnapshot -> processResults(userCloudDBZoneSnapshot, context))
                .addOnFailureListener(e -> toaster.sendErrorToast(context, e.getLocalizedMessage()));
    }

    private void processResults(CloudDBZoneSnapshot<User> userCloudDBZoneSnapshot, Context context) {
        CloudDBZoneObjectList<User> userCursor = userCloudDBZoneSnapshot.getSnapshotObjects();
        List<User> userList = new ArrayList<>();

        try {
            while (userCursor.hasNext()) {
                User user = userCursor.next();
                updateMaxUserID(user);
                userList.add(user);
            }
            //HAVE USER LIST
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        } finally {
            userCloudDBZoneSnapshot.release();
        }
    }

}

Lets break it down and take a look at what we are going to be able to do with this class.

First we define the variables we are going to need, the most important thing here is maxUserID. CloudDB currently doesn't have any auto increment support so we will need to keep a running check on what is the last used ID.

Next we have the class constructor where we will get an instance of the CloudDB interface to be used by the other methods in this class.

public CloudDBManager(){
        cloudDB = AGConnectCloudDB.getInstance();
    }

Next up with a static init method, the CloudDB initialize method must be called at the start of your application, so this static method is used to do just that!

public static void initCloudDB(Context context){
        AGConnectCloudDB.initialize(context);
    }

In the openCloudDBZone method we setup the configured cloud zone, this is where the data will be saved to and received from. Note that you could have multiple zone's that all use the same ObjectType's. They wouldn't however have access to other zone's data. Useful if you have multiple applications that require similar data structures.

public void openCloudDBZone(Context context){
        config = new CloudDBZoneConfig("Barker",
                CloudDBZoneConfig.CloudDBZoneSyncProperty.CLOUDDBZONE_CLOUD_CACHE,
                CloudDBZoneConfig.CloudDBZoneAccessProperty.CLOUDDBZONE_PUBLIC);
        config.setPersistenceEnabled(true);

        try {
            cloudDBZone = cloudDB.openCloudDBZone(config, true);
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        }
    }

As we have an open method we should also have a close method to shut down the apps access to that zone.

public void closeCloudDBZone(Context context){
        try {
            cloudDB.closeCloudDBZone(cloudDBZone);
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        }
     }

Next we have three methods that handle the upsert of User's, that is either the update or insert depending on if the user already exists in the database. The executeTask method sets the success/failure listeners while the other two methods simply set up the task depending on if we are upserting one user or a list of users.

public void upsertUser(User user, Context context) {
        Task<Integer> upsertTask = cloudDBZone.executeUpsert(user);
        executeTask(upsertTask, context);
    }

    public void upsertUsers(List<User> users,Context context) {
        Task<Integer> upsertTask = cloudDBZone.executeUpsert(users);
        executeTask(upsertTask, context);
    }

    private void executeTask(Task<Integer> task,Context context) {
        task.addOnSuccessListener(integer -> toaster.sendSuccessToast(context, "upsert successful"))
                .addOnFailureListener(e -> toaster.sendErrorToast(context, e.getLocalizedMessage()));
    }

Next we have a simple method that will delete a User from the database

public void deleteUser(User user){
        cloudDBZone.executeDelete(user);
    }

Then a getter for the maxUserID, and a method to update the maxUserID. If the given User has a greater ID than the current max, update the max ID to that Users ID.

public int getMaxUserID(){
        return maxUserID;
    }

    private void updateMaxUserID(User user){
        if (maxUserID < user.getId()) {
            maxUserID = user.getId();
        }
    }

And finally we have three methods that handle the querying of data, the getAllUsers method makes use of a predefined query which simply asks for all objects that are of the type User.

public void getAllUsers(Context context){
        queryUsers(CloudDBZoneQuery.where(User.class), context);
    }

Next the queryUsers method which will generate the query task and runs it, on success we then pass the result into processResult.

public void queryUsers(CloudDBZoneQuery<User> query, Context context) {
        Task<CloudDBZoneSnapshot<User>> task = cloudDBZone.executeQuery(query,
                CloudDBZoneQuery.CloudDBZoneQueryPolicy.POLICY_QUERY_FROM_CLOUD_ONLY);
        task.addOnSuccessListener(userCloudDBZoneSnapshot -> processResults(userCloudDBZoneSnapshot, context))
                .addOnFailureListener(e -> toaster.sendErrorToast(context, e.getLocalizedMessage()));
    }

Note that for the query variable we can create the type of query we need. We will look at examples of this next week (other than just the provided CloudDBZoneQuery.where(User.class)

The final method in this manager is the processResult, here we use a cursor to move over the result returned from the query. For each object in the result we update the max UserID and then add that User to a list. This is the point where we would then do something with that list, perhaps update the UI to show the result or do some other processing.

private void processResults(CloudDBZoneSnapshot<User> userCloudDBZoneSnapshot, Context context) {
        CloudDBZoneObjectList<User> userCursor = userCloudDBZoneSnapshot.getSnapshotObjects();
        List<User> userList = new ArrayList<>();

        try {
            while (userCursor.hasNext()) {
                User user = userCursor.next();
                updateMaxUserID(user);
                userList.add(user);
            }
            //HAVE USER LIST
        } catch (AGConnectCloudDBException e) {
            toaster.sendErrorToast(context, e.getLocalizedMessage());
        } finally {
            userCloudDBZoneSnapshot.release();
        }
    }

We now have all the basic methods we might need to get/set/delete the User object.

We have just a little more setup to do and then we are ready to start using the CloudDB!
As I mentioned earlier we need to init the CloudDB before we can use it anywhere in the app. The best way to do this will be to make it part of the Application class's onCreate method. For example:

package site.zpweb.barker;

import android.app.Application;

import site.zpweb.barker.db.CloudDBManager;

public class BarkerApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        CloudDBManager.initCloudDB(this);
    }
}

Setting this as the application class in your manifest:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="site.zpweb.barker">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Barker"
        android:name=".BarkerApplication">
        <activity android:name=".RegisterActivity"></activity>
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

And that's it! We are now good to go, next week we will look at how we might actually make use of this functionality as well as expand on ObjectType's and storing more complex data in the cloud!

Twitch Account

Github Repo

Barker - A Serverless Twitter Clone

Barker is a simple twitter style social network written to demostrate the serverless services provided by Huawei's AppGallery Connect platform.

This app is written during live streams over at https://www.twitch.tv/devwithzachary The streams are then edited down into videos which you can watch https://www.youtube.com/channel/UC63PqG8ZnWC4JWYrNJKocMA

Finally there is also a written copy of the guide found on dev.to https://dev.to/devwithzachary

Each episodes code is commited as a single commit to make it easy to step along with the code, simply checkout the specific commit and follow along!

We will be back with the next part next week!

29