This post has been deprecated. Instead, please see the new Amplify Android Getting Started tutorial to learn how to build Android mobile applications using AWS Amplify.
This is part 2 of a two-part series of walkthroughs on how to build an AWS cloud-enabled Android mobile app with the AWS Amplify toolchain.
In this post, we continue from part 1 and add more advanced features to our Android app. We cover the following:
- Using optimistic updates: AWS AppSync API offline support
- Using subscriptions on data changes (mutations)
- Enabling object storage through Amazon S3
Prerequisites
To create an Android project, you need to have the Java JDK installed on your work station. Download and install Android Studio, and then download the Android 6.0 SDK (API level 23 or higher) in the Android SDK Manager.
Also download an emulator image. To do so, choose AVD Manager in Android Studio. Choose + Create Virtual Device, and then follow the instructions to complete setup.
Continuing from part 1
At the end of the last post, we had created an Android app that displays a list of pets, and lets you add new pets. After adding a new pet, the app looks like this:
Optimistic updates and offline support
With the optimistic update functionality, you can provide a more responsive end user experience. You configure the UI so that it behaves as if the server will eventually return the data we expect. It’s being optimistic that the update is successful.
In this section, we create the data that we expect to be returned in memory after the mutation, and write it to the persistent SQL store that the Android device manages. Then when the server update returns, the SDK consolidates the data for you.
This approach works well with the scenario where internet connectivity is cut off while you’re trying to modify data. The AWS AppSync SDK automatically reconnects and sends the mutation when the app goes online.
Now let’s try it out. Open AddPetActivity.java
, and add the offline support at the end of save()
:
private void save() {
// ... Other code ...
ClientFactory.appSyncClient().mutate(addPetMutation).
refetchQueries(ListPetsQuery.builder().build()).
enqueue(mutateCallback);
// Enables offline support via an optimistic update
// Add to event list while offline or before request returns
addPetOffline(input);
}
Now let’s add the addPetOffline
method. We check for connectivity after writing to the local cache, and close the activity as if the addition was successful.
private void addPetOffline(CreatePetInput input) {
final CreatePetMutation.CreatePet expected =
new CreatePetMutation.CreatePet(
"Pet",
UUID.randomUUID().toString(),
input.name(),
input.description());
final AWSAppSyncClient awsAppSyncClient = ClientFactory.appSyncClient();
final ListPetsQuery listEventsQuery = ListPetsQuery.builder().build();
awsAppSyncClient.query(listEventsQuery)
.responseFetcher(AppSyncResponseFetchers.CACHE_ONLY)
.enqueue(new GraphQLCall.Callback<ListPetsQuery.Data>() {
@Override
public void onResponse(@Nonnull Response<ListPetsQuery.Data> response) {
List<ListPetsQuery.Item> items = new ArrayList<>();
if (response.data() != null) {
items.addAll(response.data().listPets().items());
}
items.add(new ListPetsQuery.Item(expected.__typename(),
expected.id(),
expected.name(),
expected.description()));
ListPetsQuery.Data data = new ListPetsQuery.Data(new ListPetsQuery.ListPets("ModelPetConnection", items, null));
awsAppSyncClient.getStore().write(listEventsQuery, data).enqueue(null);
Log.d(TAG, "Successfully wrote item to local store while being offline.");
finishIfOffline();
}
@Override
public void onFailure(@Nonnull ApolloException e) {
Log.e(TAG, "Failed to update event query list.", e);
}
});
}
private void finishIfOffline(){
// Close the add activity when offline otherwise allow callback to close
ConnectivityManager cm =
(ConnectivityManager) getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
boolean isConnected = activeNetwork != null &&
activeNetwork.isConnectedOrConnecting();
if (!isConnected) {
Log.d(TAG, "App is offline. Returning to MainActivity .");
finish();
}
}
We don’t need to change MainActivity
because its query()
method uses the CACHE_AND_NETWORK
approach. It reads from the local cache first while making a network call. Our previously added pet already exists in the local cache because of optimistic update.
Build and run the app. After you sign in, turn on Airplane mode:
Go back to the app and try adding a new item. The UI experience should be the same as when you were online before. Enter a name and description:
Choose SAVE. The app should display the second item in the list.
Now turn Airplane mode off. The items should be automatically saved. You can verify that the save is successful by closing and reopening the app, and then confirming that you still see the same two items being displayed.
Subscriptions
With AWS AppSync, you can use subscriptions for real-time notifications.
In this section, we use a subscription to let us know right away when someone else adds a new pet. To do this, let’s add the following block at the end of the MainActivity.java
class:
private AppSyncSubscriptionCall subscriptionWatcher;
private void subscribe(){
OnCreatePetSubscription subscription = OnCreatePetSubscription.builder().build();
subscriptionWatcher = ClientFactory.appSyncClient().subscribe(subscription);
subscriptionWatcher.execute(subCallback);
}
private AppSyncSubscriptionCall.Callback subCallback = new AppSyncSubscriptionCall.Callback() {
@Override
public void onResponse(@Nonnull Response response) {
Log.i("Response", "Received subscription notification: " + response.data().toString());
// Update UI with the newly added item
OnCreatePetSubscription.OnCreatePet data = ((OnCreatePetSubscription.Data)response.data()).onCreatePet();
final ListPetsQuery.Item addedItem = new ListPetsQuery.Item(data.__typename(), data.id(), data.name(), data.description());
runOnUiThread(new Runnable() {
@Override
public void run() {
mPets.add(addedItem);
mAdapter.notifyItemInserted(mPets.size() - 1);
}
});
}
@Override
public void onFailure(@Nonnull ApolloException e) {
Log.e("Error", e.toString());
}
@Override
public void onCompleted() {
Log.i("Completed", "Subscription completed");
}
};
Then let’s modify the onResume
method to call subscribe
at the end, to subscribe to new pet creations. We also need to make sure that we unsubscribe when we’re done with the activity.
@Override
public void onResume() {
super.onResume();
query();
subscribe();
}
@Override
protected void onStop() {
super.onStop();
subscriptionWatcher.cancel();
}
Now let’s test it out. Build and run our app on your emulator.
Let’s then start up a second emulator. To start a second emulator, make sure that you have the default unselected. (Choose Run, choose Edit configurations. Under Android App, choose app, and clear the Use same device for future launches check box).
Also make sure that you have a different emulator device type in the AVD Manager. Run the app, choose your second emulator device, and have the app running side by side in these two emulators. Make sure that you sign into both so that you’re looking at the list of pets on both devices.
Add another pet in one of the apps, and watch it appear in the other app. Viola!
Working with storage
With AWS Amplify, you can easily add object storage support by using Amazon S3. AWS Amplify manages the bucket provision and permission configuration for you automatically.
Update AWS Amplify
To get started, let’s modify the local schema in ./amplify/backend/api/<PROJECTNAME>/schema.graphql
to add a photo string property:
type Pet @model {
id: ID!
name: String!
description: String
photo: String
}
Next, go to our root directory, and run the following at the command line:
Answer the following questions:
- Please select from one of the below mentioned services: Content (Images, audio, video, etc.)
- Please provide a friendly name for your resource that will be used to label this category in the project: MyPetAppResources
- Please provide bucket name: mypetapp1246e0cde8074f78b94363dbe73f8adfdsfds (or something unique)
- Who should have access: Auth users only
- What kind of access do you want for Authenticated users: read/write
Then run:
Choose Y when you’re asked whether you want to update code and regenerate GraphQL statements. Choose Enter, and wait for AWS CloudFormation updates to finish. This takes a couple of minutes.
Add storage dependencies
Meanwhile, let’s update our front-end client code.
Open AndroidManifest.xml
, and add the TransferService
in our <application>
:
<application>
<!-- ...other code... -->
<service android:name="com.amazonaws.mobileconnectors.s3.transferutility.TransferService" />
</application>
Open your app’s build.gradle
, and add the dependency for Amazon S3:
implementation 'com.amazonaws:aws-android-sdk-s3:2.7.+'
Add photo selection code
Next, open AddPetActivity.java
, and add the photo selection code:
// Photo selector application code.
private static int RESULT_LOAD_IMAGE = 1;
private String photoPath;
public void choosePhoto() {
Intent i = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
startActivityForResult(i, RESULT_LOAD_IMAGE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == RESULT_LOAD_IMAGE && resultCode == RESULT_OK && null != data) {
Uri selectedImage = data.getData();
String[] filePathColumn = {MediaStore.Images.Media.DATA};
Cursor cursor = getContentResolver().query(selectedImage,
filePathColumn, null, null, null);
cursor.moveToFirst();
int columnIndex = cursor.getColumnIndex(filePathColumn[0]);
String picturePath = cursor.getString(columnIndex);
cursor.close();
// String picturePath contains the path of selected Image
photoPath = picturePath;
}
}
We need to call the upload photo from the UI. Open activity_add_pet.xml
, and add a button before the Save button:
<LinearLayout>
<!-- ... other code... -->
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/btn_add_photo"
android:layout_marginTop="15dp"
android:text="Add Photo"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/btn_save"
android:layout_marginTop="15dp"
android:text="Save"/>
</LinearLayout>
Now let’s connect this button to our choosePhoto()
method. Go back to AddPetActivity.java
, and modify onCreate
to attach a click listener to the Save button:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_pet);
Button btnAddItem = findViewById(R.id.btn_save);
btnAddItem.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
save();
}
});
Button btnAddPhoto = findViewById(R.id.btn_add_photo);
btnAddPhoto.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
choosePhoto();
}
});
}
Build and run the app to confirm that the photo selection button works:
Choose ADD PHOTO. You should be able to select a photo from your gallery. (If there’s no photo in the emulator, open the browser and download some photos from the internet.)
After you select a photo, you should be redirected back to the ADD screen.
Add Amazon S3 photo uploading code
Now that the photo can be selected, we need to make sure that the photo gets uploaded and stored in the backend. We’ll use TransferUtility
to handle the Amazon S3 file upload and download. Let’s add its initialization code to our ClientFactory.java class
.
private static volatile TransferUtility transferUtility;
public static synchronized void init(final Context context) {
// ... appsyncClient initialization code ...
if (transferUtility == null) {
transferUtility = TransferUtility.builder()
.context(context)
.awsConfiguration(AWSMobileClient.getInstance().getConfiguration())
.s3Client(new AmazonS3Client(AWSMobileClient.getInstance()))
.build();
}
}
public static synchronized TransferUtility transferUtility() {
return transferUtility;
}
Next, let’s add code to upload the photo by using the TransferUtility
in our AddPetActivity.java
:
private String getS3Key(String localPath) {
//We have read and write ability under the public folder
return "public/" + new File(localPath).getName();
}
public void uploadWithTransferUtility(String localPath) {
String key = getS3Key(localPath);
Log.d(TAG, "Uploading file from " + localPath + " to " + key);
TransferObserver uploadObserver =
ClientFactory.transferUtility().upload(
key,
new File(localPath));
// Attach a listener to the observer to get state update and progress notifications
uploadObserver.setTransferListener(new TransferListener() {
@Override
public void onStateChanged(int id, TransferState state) {
if (TransferState.COMPLETED == state) {
// Handle a completed upload.
Log.d(TAG, "Upload is completed. ");
// Upload is successful. Save the rest and send the mutation to server.
save();
}
}
@Override
public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) {
float percentDonef = ((float) bytesCurrent / (float) bytesTotal) * 100;
int percentDone = (int)percentDonef;
Log.d(TAG, "ID:" + id + " bytesCurrent: " + bytesCurrent
+ " bytesTotal: " + bytesTotal + " " + percentDone + "%");
}
@Override
public void onError(int id, Exception ex) {
// Handle errors
Log.e(TAG, "Failed to upload photo. ", ex);
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(AddPetActivity.this, "Failed to upload photo", Toast.LENGTH_LONG).show();
}
});
}
});
}
Save photo in mutation
Because we’ve added a new property to the Pet object, we need to modify our code to accommodate it. In AddPetActivity.java
, extract the following method to produce different types of CreatePetInput
s, depending on whether a photo has been selected:
private CreatePetInput getCreatePetInput() {
final String name = ((EditText) findViewById(R.id.editTxt_name)).getText().toString();
final String description = ((EditText) findViewById(R.id.editText_description)).getText().toString();
if (photoPath != null && !photoPath.isEmpty()){
return CreatePetInput.builder()
.name(name)
.description(description)
.photo(getS3Key(photoPath)).build();
} else {
return CreatePetInput.builder()
.name(name)
.description(description)
.build();
}
}
Next, we modify our save()
to call the extracted method:
private void save() {
CreatePetInput input = getCreatePetInput();
CreatePetMutation addPetMutation = CreatePetMutation.builder()
.input(input)
.build();
ClientFactory.appSyncClient().mutate(addPetMutation).
refetchQueries(ListPetsQuery.builder().build()).
enqueue(mutateCallback);
// Enables offline support via an optimistic update
// Add to event list while offline or before request returns
addPetOffline(input);
}
Because we changed the CreatePet
mutation, we need to modify the addPetOffline
code as well:
private void addPetOffline(final CreatePetInput input) {
final CreatePetMutation.CreatePet expected =
new CreatePetMutation.CreatePet(
"Pet",
UUID.randomUUID().toString(),
input.name(),
input.description(),
input.photo());
final AWSAppSyncClient awsAppSyncClient = ClientFactory.appSyncClient();
final ListPetsQuery listPetsQuery = ListPetsQuery.builder().build();
awsAppSyncClient.query(listPetsQuery)
.responseFetcher(AppSyncResponseFetchers.CACHE_ONLY)
.enqueue(new GraphQLCall.Callback<ListPetsQuery.Data>() {
@Override
public void onResponse(@Nonnull Response<ListPetsQuery.Data> response) {
List<ListPetsQuery.Item> items = new ArrayList<>();
if (response.data() != null) {
items.addAll(response.data().listPets().items());
}
items.add(new ListPetsQuery.Item(expected.__typename(),
expected.id(),
expected.name(),
expected.description(),
expected.photo()));
ListPetsQuery.Data data = new ListPetsQuery.Data(
new ListPetsQuery.ListPets("ModelPetConnection", items, null));
awsAppSyncClient.getStore().write(listPetsQuery, data).enqueue(null);
Log.d(TAG, "Successfully wrote item to local store while being offline.");
finishIfOffline();
}
@Override
public void onFailure(@Nonnull ApolloException e) {
Log.e(TAG, "Failed to update event query list.", e);
}
});
}
Then we can create a new method, uploadAndSave()
, to handle both the photo and no-photo saves:
private void uploadAndSave(){
if (photoPath != null) {
// For higher Android levels, we need to check permission at runtime
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
// Permission is not granted
Log.d(TAG, "READ_EXTERNAL_STORAGE permission not granted! Requesting...");
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
1);
}
// Upload a photo first. We will only call save on its successful callback.
uploadWithTransferUtility(photoPath);
} else {
save();
}
}
Now we can call our uploadAndSave()
functions when the Save button is chosen:
protected void onCreate(Bundle savedInstanceState) {
// ... other code ...
btnAddItem.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
uploadAndSave();
}
});
We also have to update the subscription callback in MainActivity.java
because of the newly added photo
property:
private AppSyncSubscriptionCall.Callback subCallback = new AppSyncSubscriptionCall.Callback() {
@Override
public void onResponse(@Nonnull Response response) {
Log.i("Response", "Received subscription notification: " + response.data().toString());
// Update UI with the newly added item
OnCreatePetSubscription.OnCreatePet data = ((OnCreatePetSubscription.Data)response.data()).onCreatePet();
final ListPetsQuery.Item addedItem =
new ListPetsQuery.Item(data.__typename(), data.id(), data.name(), data.description(), data.photo());
runOnUiThread(new Runnable() {
@Override
public void run() {
mPets.add(addedItem);
mAdapter.notifyItemInserted(mPets.size() - 1);
}
});
}
//...other event handlers ...
};
Download and display photos
Now that we’ve implemented the ability to save photos, let’s make sure that they get downloaded and displayed.
Open recyclerview_row.xml
, add an <ImageView>
, and modify the layout as follows:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp"
android:weightSum="100">
<ImageView
android:id="@+id/image_view"
android:layout_width="0dp"
android:layout_height="match_parent"
android:maxHeight="200dp"
android:layout_weight="30"
/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_weight="70"
android:layout_marginTop="10dp">
<TextView
android:id="@+id/txt_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15dp"
android:paddingLeft="10dp" />
<TextView
android:id="@+id/txt_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15dp"
android:paddingLeft="10dp" />
</LinearLayout>
</LinearLayout>
Open MyAdapter.java
. Add code to correspond to the photo property, and to download the photo if one exists.
public class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder> {
// ... other code ...
// stores and recycles views as they are scrolled off screen
class ViewHolder extends RecyclerView.ViewHolder {
TextView txt_name;
TextView txt_description;
ImageView image_view;
String localUrl;
ViewHolder(View itemView) {
super(itemView);
txt_name = itemView.findViewById(R.id.txt_name);
txt_description = itemView.findViewById(R.id.txt_description);
image_view = itemView.findViewById(R.id.image_view);
}
void bindData(ListPetsQuery.Item item) {
txt_name.setText(item.name());
txt_description.setText(item.description());
if (item.photo() != null) {
if (localUrl == null) {
downloadWithTransferUtility(item.photo());
} else {
image_view.setImageBitmap(BitmapFactory.decodeFile(localUrl));
}
}
else
image_view.setImageBitmap(null);
}
private void downloadWithTransferUtility(final String photo) {
final String localPath = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + "/" + photo;
TransferObserver downloadObserver =
ClientFactory.transferUtility().download(
photo,
new File(localPath));
// Attach a listener to the observer to get state update and progress notifications
downloadObserver.setTransferListener(new TransferListener() {
@Override
public void onStateChanged(int id, TransferState state) {
if (TransferState.COMPLETED == state) {
// Handle a completed upload.
localUrl = localPath;
image_view.setImageBitmap(BitmapFactory.decodeFile(localPath));
}
}
@Override
public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) {
float percentDonef = ((float) bytesCurrent / (float) bytesTotal) * 100;
int percentDone = (int) percentDonef;
Log.d(TAG, " ID:" + id + " bytesCurrent: " + bytesCurrent + " bytesTotal: " + bytesTotal + " " + percentDone + "%");
}
@Override
public void onError(int id, Exception ex) {
// Handle errors
Log.e(TAG, "Unable to download the file.", ex);
}
});
}
}
}
Because we’re downloading photos, we need to make sure that permissions can be granted. Go to MainActivity.java
, and add the permission-seeking block in query()
:
public void query(){
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
// Permission is not granted
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission not granted! Requesting...");
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
2);
}
ClientFactory.appSyncClient().query(ListPetsQuery.builder().build())
.responseFetcher(AppSyncResponseFetchers.CACHE_AND_NETWORK)
.enqueue(queryCallback);
}
Now we’re finally done! Build and run the app again. Check to see if you can add a photo and see your lovely pet! Your app should look something like this:
Other features
There are other enhancements that we can make to the app. Try to work on the following as practice for yourself:
- Add the capability to update a pet’s information.
- Add the ability to delete a pet.
- Subscribe to update and delete mutations.
Give us feedback
We’re excited that you’ve created this Android app and have added more advanced features to it. As always, let us know how we’re doing, and submit any requests in the AWS Amplify CLI repository. You can read more about AWS Amplify on the new AWS Amplify website.
Jane Shen is an AWS Professional Services Application Architect based in Toronto, Canada.