Anders Marzi Tornblad
Posted on September 23, 2019
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.
- no lambda expressions
- difficult date/time support
- no Stream API, and nothing even close to LINQ and its extension methods for collections
- no switch statements on strings
- no type inference for local variables
- type erasure in generics, which makes reflection a nightmare
- no null-coalesce or null-conditional operators
- no value types (immutable data structures)
- no explicitly nullable or non-nullable variable declarations
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 ofActivity
:
import androidx.appcompat.app.AppCompatActivity;
public class MyActivity extends AppCompatActivity {
}
- Use
ContextCompat
instead of callingContext
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);
- Use
NotificationManagerCompat
instead ofNotificationManager
:
import androidx.core.app.NotificationManagerCompat;
// Get the Notification Manager like this:
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
// And not like this:
NotificationManager = getSystemService(NOTIFICATION_SERVICE);
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);
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();
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;
}
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);
}
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; theonConflict
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 theMomData
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();
}
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();
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();
// ...
}
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;
}
}
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.
}
}
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
}
}
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);
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>
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.
}
}
}
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());
}
}
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
Posted on September 23, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.