DEV Community

Cover image for Don't forget to build that app ๐Ÿ“ฒ
Anders Marzi Tornblad
Anders Marzi Tornblad

Posted on • Edited on • Originally published at atornblad.se

Don't forget to build that app ๐Ÿ“ฒ

In the first part of this series, I describe how and why I started building the apps named Call Mom and Call Dad. This part describes the initial work needed to build the first useful version, including some Java pointers. If you just want the summary and some links, go to the Summary section at the bottom.

Donโ€™t forget to build that app ๐Ÿ“ฒ

For this task, I went for writing the app in Java. Learning techniques that were completely new to me, like Kotlin or React Native, were not the main focus for me at the time, though I have gotten into both of those later. So I installed Android Studio, launched it, and started taking baby-steps forward.

Coming from the world of modern Java and C#, and moving to Java 7 (the default Java version in Android Studio when I started) felt like a huge step back in time.

Fortunately, I was able to remedy some of these by activating Java 8 compatibility in Android Studio, and setting minSdkVersion to API level 24 in the manifest file.

Navigating the unknowns

Android has been around for more than a decade now, and is a really mature platform for both users and developers. Unfortunately, this also means that there is a lot of outdated information out there on blogs, video tutorials and StackOverflow questions. Even in the official documentation there are contradictions, and ambiguities when it comes to how to implement specific things. The best practices for one version quickly become frowned upon, or turn deprecated in a newer version of the Android SDK. At the same time, developers are encouraged to always target the latest version.

Compatibility with earlier Android versions ๐Ÿคœ๐Ÿค›

Fortunately, there are compatibility libraries that let developers target the bleeding-edge devices and use newer features, while automatically falling back to older equivalent APIs or simulating the new behaviors on older devices. So this problem has been solved. The problem is just that it has been solved twice.

Support libraries

When learning how to use the RecyclerView and the CardView to allow the user to pick the correct contact to call from a list, I did it according to what I could find in the official documentation, by adding references to the Support Libraries. All was good for a while, and I used the Support Libraries for a lot of different things, like showing notifications correctly on all supported Android versions.

Later, when I wanted to add persistent data storage, I had to add references to AndroidX. After a while, the compiler started complaining about conflicts between different RecyclerView implementations. The conflicts came from me referencing those classes in code, Android Studio asking to automatically add import statements, and me picking the wrong ones.

Android Jetpack

Lately, Android development has seen a number of improvements to architecture and standardized components for all kinds of things, like data storage, user interface elements, notifications, media and security. Separate from the platform APIs, the Android Jetpack suite also includes an updated take on how to do version compatibility. From the AndroidX Overview page:

AndroidX fully replaces the Support Library by providing feature parity and new libraries.

This is all very nice, but the top search result for RecyclerView, for example, at the time of me writing this, still leads to the older version. It's something to be aware of.

If you are working on an app that depends on the older Support Libraries, there are ways to easily and automatically migrate to AndroidX. In my experience, automatic migration works fine. Also, newer versions of Android Studio tries to coerce (and even force) you to use the newer compatibility libraries.

To ensure a consistent user experience across multiple Android versions, here are a few tips to consider:

  • Let your activities extend AppCompatActivity instead of Activity:
import androidx.appcompat.app.AppCompatActivity;

public class MyActivity extends AppCompatActivity {
}
Enter fullscreen mode Exit fullscreen mode
  • Use ContextCompat instead of calling Context methods directly, when suitable methods exist:
import androidx.core.content.ContextCompat;

// Start other activities from inside an activity like this:
ContextCompat.startActivity(this, intent, options);
// And not like this:
this.startActivity(intent, options)

// Get some resources from inside an activity like this:
Drawable picture = ContextCompat.getDrawable(this, R.drawable.pic);
// And not like this:
Drawable picture = getDrawable(R.drawable.pic);

// Check permissions like this:
int permissionState = ContextCompat.checkSelfPermission(this, CALL_PHONE);
// And not like this:
int permissionState = checkSelfPermission(CALL_PHONE);
Enter fullscreen mode Exit fullscreen mode
  • Use NotificationManagerCompat instead of NotificationManager:
import androidx.core.app.NotificationManagerCompat;

// Get the Notification Manager like this:
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
// And not like this:
NotificationManager = getSystemService(NOTIFICATION_SERVICE);
Enter fullscreen mode Exit fullscreen mode

Persistent data ๐Ÿ’พ

To handle the user's selection of which contact to call, and the notification frequency, I needed to store data persistently, so nothing would get lost between app restarts. I also needed to store the time of the user's most recent call to be able to calculate the date and time for the next notification.

At first, I went with a Room database to store everything, and ended up creating a lot of AsyncTask solutions to actively read data when needed or write data after user input. This approach was what I could find when I searched for answers. However, using the LiveData approach is much more efficient and straight-forward for subscribing to changed data across an entire app. Also, a Room database might not be the best storage for every bit of data your app needs to store.

SharedPreferences

When storing very simple data, like single strings or numeric values, keeping that in a Room database is probably overkill. Reading and writing Room data can not be done in the UI thread, so you have to use LiveData, AsyncTask or other asynchronous mechanisms to read or store values.

The SharedPreferences APIs provide a key-value store that you can use directly from your Activity code, without spawning worker threads or worrying about synchronization issues. To read data, start by calling the getSharedPreferences method.

// First open the preferences file called "prefs"
// If it doesn't exist, it gets created automatically
SharedPreferences prefs = getSharedPreferences("prefs", MODE_PRIVATE);

// Read an integer named "launches" from the preferences
// If that values doesn't exist, let it return zero
int numberOfAppLaunches = prefs.getInt("launches", 0);

// Read a string named "username"
// If that value doesn't exist, let it return null
String username = prefs.getString("username", null);
Enter fullscreen mode Exit fullscreen mode

Your app can maintain multiple different preferences files (the first argument to getSharedPreferences) to separate groups of data. In my apps, I haven't used that feature, but it can be useful for avoiding name collisions.

To update your app's SharedPreferences, you first need to create an Editor object, put the new value into the editor, and call apply(), which saves the changes to the preferences file asynchronously, without disturbing the UI thread.

// Open the preferences file called "prefs"
SharedPreferences prefs = getSharedPreferences("prefs", MODE_PRIVATE);

// Create an Editor
SharedPreferences.Editor prefsEditor = prefs.edit();

// Update the value
numberOfAppLaunches += 1;
prefsEditor.putInt("launches", numberOfAppLaunches);

// Save changes to the preferences file
prefsEditor.apply();
Enter fullscreen mode Exit fullscreen mode

Room databases

For storing more complex data, you should consider the Room persistance library. This give you access to the lightweight database engine SQLite, hidden behind an abstraction layer that helps you focus on designing your data model instead of getting sidelined by more complex things like connections and SQL query syntax beyond simple SELECT queries. Combined with the LiveData architecture, you get a fully reactive data flow, based on the Observer pattern.

Start by defining your data classes. Each data class is annotated as an @Entity and translates to a single table in your SQLite database. This is how a simple MomData entity class could look:

import androidx.room.Entity;
import androidx.room.PrimaryKey;

@Entity
public class MomData {
    @PrimaryKey(autoGenerate = true)
    public long id;

    public String name;
    public String number;
}
Enter fullscreen mode Exit fullscreen mode

Then define your data access methods. These are Java interfaces, annotated as @Dao, and should reflect every data use case in your app, like retreiving all instances from the database table, getting one specific instance by id, searching for instances matching some input, updating an existing instance or adding instances of your entity to the database:

import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;

@Dao
public interface MomDao {
    @Query("SELECT * FROM MomData")
    MomData[] getAllMoms();

    @Query("SELECT * FROM MomData WHERE id = :id")
    MomData getMomById(long id);

    @Query("SELECT * FROM MomData WHERE name = :whatName")
    MomData[] getAllMomsWithName(String whatName);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void addOrUpdate(MomData mom);
}
Enter fullscreen mode Exit fullscreen mode

This data access interface lets your app:

  • List all moms in the database with the getAllMoms method
  • Get one specific mom using its id with the getMomById method
  • List all moms with a specific name with the getAllMomsWithName method
  • Add a new mom, or update an existing one, with the same addOrUpdate method; the onConflict parameter of the @Insert annotation tells Room to replace the row in the database if the id matches an existing row, or to create a new row if the MomData object is a new one

As you can see, some SQL knowledge is required for creating queries, and if you find yourself having a need for more complex JOIN or WHERE clauses, you might want to investigate other ORM solutions, like GreenDao which has a sofisticated QueryBuilder concept.

Finally, you create an abstract class that extends the RoomDatabase class, which handles connections correctly for you:

import androidx.room.Database;
import androidx.room.RoomDatabase;

// Add all your app's entity classes to the entities array
@Database(entities = { MomData.class }, version = 1)
public abstract class CallMomDatabase extends RoomDatabase {
    // Create an abstract DAO getter for each DAO class
    public abstract MomDao getMomDao();
}
Enter fullscreen mode Exit fullscreen mode

Now, to use the database, you need to create a RoomDatabase.Builder object, that will create the database if it doesn't already exist, and establish a connection to it:

// From inside a method in an Activity:
RoomDatabase.Builder<CallMomDatabase> builder =
    Room.databaseBuilder(this, CallMomDatabase.class, "callmomdb");
CallMomDatabase db = builder.build();

// Get a list of all moms
MomData[] allMoms = db.getMomDao().getAllMoms();

// Close the connection to clean up
db.close();
Enter fullscreen mode Exit fullscreen mode

However, you are not allowed to perform any database queries from your app's UI thread, which means the code above can not be called from any onClick-like methods.

My first solution to this was to create a lot of AsyncTask implementations, to create new worker threads any time I needed to read from, or write to, the database. This mostly worked fine, but I had to think about thread synchronization issues myself, which is always a pain. I do not recommend building your app this way. When I found out about LiveData, database connectivity could be made much cleaner and more robust, by adding just a little bit more code.

LiveData โ€“ a Room with a View

Making sure that your app's views show the correct data from your model at all times can be tricky, especially when you have to take the Activity Lifecycle into consideration. Your Activity object can get created and destroyed, paused and resumed, at any time, outside of your control, even when the user does a simple thing like turning their phone from portrait to landscape orientation. To know when, and how, to save the view state and when to read it back is not completely trivial.

Luckily, Android Jetpack provides a concept of Lifecycle-Aware Components, that solves a large part of that problem. One such component is LiveData, that is used to wrap a mutable value (a simple value or an object) in a lifecycle-aware observable. Any observer, such as an Activity or a Fragment will receive updated values exactly when they need to, at the correct times in their lifecycle. Even though LiveData objects can be used with any type of data from any source, they are especially useful for dealing with entities living in a Room database.

First, you need to refactor the Dao interface to leverage the LiveData mechanism. You'll need to wrap the return type of any data that you need to observe in a LiveData<> generic class.

import androidx.lifecycle.LiveData;

@Dao
public interface MomDao {
    @Query("SELECT * FROM MomData")
    LiveData<MomData[]> getAllMoms();

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Next, you should create a ViewModel implementation to contain all the data that your view needs to render. You could move the code to build your Database object in here, but if your app has multiple ViewModel classes, you might want to move that code to some helper method and implement the Singleton pattern.

import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModel;

public class MainActivityViewModel extends ViewModel {
    private LiveData<MomData[]> allMoms;
    private final CallMomDatabase database;

    public MainActivityViewModel() {
        RoomDatabase.Builder<CallMomDatabase> builder =
            Room.databaseBuilder(this, CallMomDatabase.class, "callmomdb");
            database = builder.build();
    }

    public LiveData<MomData[]> getAllMoms() {
        if (allMoms == null) {
            allMoms = database.getMomDao().getAllMoms();
        }
        return allMoms;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that database.close() is no longer called. This is because LiveData needs the database connection to stay open. Finally, in your Activity you need to create an Observer to listen to changes in your data, and update your view correspondingly. Targeting Java 8, the most readable way to do this is by using a Method Reference, in this case the this::allMomsChanged reference:

import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProviders;

public class MainActivity extends AppCompatActivity {
    private MainActivityViewModel model;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get an instance of the view model
        model = ViewModelProviders.of(this).get(MainActivityViewModel.class);

        // Start observing the changes by telling what method to call
        // when data is first received and when data changes
        model.getAllMoms().observe(this, this::allMomsChanged);
    }

    private void allMomsChanged(@Nullable final MomData[] allMoms) {
        // This is where you update the views using the new data
        // passed into this method.
    }
}
Enter fullscreen mode Exit fullscreen mode

The collaboration between Room and LiveData ensures that whenever data is changed in your database, the allMomsChanged method above is called automatically, to allow the UI to reflect the changes in data.

Setting alarms โฐ

A reminder app, such as Call Mom and Call Dad, need to be able to alert the user at specific times, even if their device is sleeping, and the alerts need to work correctly even if the device is rebooted. There is a mechanism in android called the Alarm Manager, which you can use to wake the app up and run code on a schedule. The AlarmManager class has lots of different methods to set these alarms, and AlarmManagerCompat can help you set alarms in a way that is consistent across Android versions. You need to be careful when selecting which method to use, because if you design your alarm badly, your app can drain the battery of a device.

Setting the alarm

I decided to use the AlarmManagerCompat.setAlarmClock method for these apps, because the main purpose of the alarms is to notify the user about a scheduled call. The setAlarmClock method limits the number of alarms to at most one per 15 minutes, so if your app needs to schedule code to run that don't notify the user, or that needs to run more than every 15 minutes, you should use some other method of the AlarmManager or AlarmManagerCompat classes, or use some different approach.

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.core.app.AlarmManagerCompat;

public class MyAlarms extends BroadcastReceiver
    private AlarmManager alarmManager;
    private Context appContext;
    private final static int REQUEST_CODE = 1;

    // The current application context must be passed into this constructor
    public MyAlarms(Context appContext) {
        this.appContext = appContext;

        // Get the AlarmManager
        alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
    }

    public void setAlarm(long timeInMilliseconds) {
        // Create an intent that references this class
        Intent intent = new Intent(context, getClass());

        // Create a pending intent (an intent to be used later)
        // If an identical pending intent already exists, the FLAG_UPDATE_CURRENT
        // flag ensures to not create duplicates
        PendingIntent pendingIntent = PendingIntent.getBroadcast(
            appContext, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        );

        // Set the alarm to call the onReceive method at the selected time
        AlarmManagerCompat.setAlarmClock(alarmManager, timeInMilliseconds, pendingIntent, pendingIntent);
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        // This method will get called when the alarm clock goes off
        // Put the code to execute here
    }
}
Enter fullscreen mode Exit fullscreen mode

To set an alarm, create an instance of MyAlarms and call the setAlarm method, passing in the millisecond timestamp for the desired alarm time:

// From inside an Activity or a Service:
MyAlarms myAlarms = new MyAlarms(this);

// Set the alarm to go off after an hour
// An hour = 60 minutes * 60 seconds * 1000 milliseconds
long afterAnHour = System.currentTimeMillis() + 60 * 60 * 1000;
myAlarms.setAlarm(afterAnHour);
Enter fullscreen mode Exit fullscreen mode

Detecting device reboots

One problem with using AlarmManager is that all scheduled alarms are lost when the user reboots their device. To allow alarms to work properly even after a reboot, your app needs to detect device reboots, and when a reboot is done, schedule the alarm again. This requires you to save the alarm time in some persistant storage, for example SharedPreferences, when the alarm is set, to read from storage when a reboot is detected, and schedule the same alarm again.

The operating system sends broadcast messages to all apps that listen to BOOT_COMPLETED actions. To have your app get notified, start by declaring the RECEIVE_BOOT_COMPLETED permission, and adding an intent-filter to your reciever in the AndroidManifest.xml file:

<manifest ...>
  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"> />
  ...

  <application ...>
    ...

    <receiver android:name=".MyAlarms" android:enabled="true">
      <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
      </intent-filter>
    </receiver>

  </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

Then in your BroadcastReceiver implementation, expand the onReceive method to check what type of message is received, and reschedule the alarm as needed. Also, when scheduling an alarm, save the alarm time in SharedPreferences.

public class MyAlarms extends BroadcastReceiver
    private AlarmManager alarmManager;
    private Context appContext;
    private final static int REQUEST_CODE = 1;
    private final static long TIME_NOT_SET = 0;

    public MyAlarms(Context appContext) {
        this.appContext = appContext;
        alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
    }

    public void setAlarm(long timeInMilliseconds) {
        Intent intent = new Intent(context, getClass());
        PendingIntent pendingIntent = PendingIntent.getBroadcast(
            appContext, REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT);
        );
        AlarmManagerCompat.setAlarmClock(alarmManager, timeInMilliseconds, pendingIntent, pendingIntent);

        // Open shared preferences and save the alarm time
        SharedPreferences prefs = appContext.getSharedPreferences("alarms", Context.MODE_PRIVATE);
        SharedPreferences.Editor prefsEditor = prefs.edit();
        prefsEditor.putLong("alarmtime", timeInMilliseconds);
        prefsEditor.apply();
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        // Check if this broadcast message is about a device reboot
        if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
            // Yes it is! Get the last saved alarm time from shared preferences
            SharedPreferences prefs = appContext.getSharedPreferences("alarms", Context.MODE_PRIVATE);
            long savedAlarmTime = prefs.getLong("alarmtime", TIME_NOT_SET);

            // Is there a saved alarm time?
            if (savedAlarmTime != TIME_NOT_SET) {
                // Reschedule the alarm!
                setAlarm(savedAlarmTime);
            }
        }
        else {
            // This is not a device reboot, so it must be the alarm
            // clock going off. Do what your app needs to do.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Showing notifications ๐Ÿšฉ

The main purpose of these apps is to notify the user when it's time to call. First of all, you'll need to create at least one Notification Channel, so that your app works in Android Oreo (version 26) and later. By creating channels, users can allow or deny notifications depending on their content. Be sure to provide good names and descriptions for your channels.

NotificationCompat

Notifications is one of those concepts that have changed a lot over the course of Android's history, so there are quite a lot of quirks to handle differently, depending on what version of Android your user's device runs. Luckily, AndroidX contains the NotificationCompat and NotificationManagerCompat classes that take some of those pains away.

public class MyNotifications {
    private final static String CHANNEL_ID = "MAIN";
    private final static int ID = 12345;
    private final static int IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT;

    // You should definitely get the NAME and DESCRIPTION from resources!
    private final static String NAME = "Call reminders";
    private final static String DESCRIPTION = "These notifications remind you to call your mom";

    public void createChannel(Context context) {
        // Only do this if running Android Oreo or later
        if (Build.VERSION.SDK_INT <>= Build.VERSION_CODES.O) return;

        // Get the NotificationManager
        NotificationManager notificationManager = context.getSystemService(NotificationManager.class);

        // Create and configure the channel
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID, NAME, IMPORTANCE);
        channel.setDescription(DESCRIPTION);
        channel.setShowBadge(true);
        channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);

        // Create the channel
        notificationManager.createNotificationChannel(channel);
    }

    // When a channel has been created, call this method to show the
    // notification, and pass a PendingIntent that will get started
    // when the user clicks the notification; preferably you will
    // pass an Activity intent to start.
    public void showNotification(Context context, String title, String text, PendingIntent intentToStart) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.my_notification_icon)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
            .setContentTitle(title)
            .setContentText(text)
            .setContentIntent(intentToStart)
            .setOnlyAlertOnce(true)
            .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL);

        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
        notificationManager.notify(ID, builder.build());
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary ๐Ÿ”–

  • For compatibility and modern UI elements, ignore the older Support Library, and use AndroidX
  • Implement simple key-value persistant storage with SharedPreferences
  • Do more complex persistent data storage with Room
  • Use Room entities and other app state with LiveData
  • To allow alarms to survive device restarts, listen for the BOOT_COMPLETED message
  • Show notifications correctly using NotificationCompat

Cover photo by Daria Nepriakhina on Unsplash

Top comments (0)