DbTradeAlert for Android: Add Reminders

First post in this series: Introduction to DbTradeAlert

Previous post: Add Security and Watchlist Management


Reminders result in trade alerts like signals do. The difference is that a user explicitly sets the due date leading to the trade alert.

I add reminders after completing DbTradeAlert’s core features because a) it’s the only new feature I wanted after using the app for years  and b) to see how Android and DbTradeAlert facilitate additions like this.

1. Store Reminders in the Database

Like for the existing types reminders need for storage:

  • A ReminderContract class defining the type’s database fields
  • A DbHelper.createReminderTable() method to create the database table
  • To call createReminderTable() in DbHelper.createTables()
  • To create some sample data in DbHelper.createSampleData()

I’ll skip diving into the code as you have seen code almost identical to it. The reminder table’s fields are:

  • Due date: the date at which the trade alert will be shown
  • Heading: text that shows in the trade alert
  • ID: SQLite auto-increment field
  • Is active: you can deactivate a reminder to keep its data for future use; inactive reminders won’t trigger trade alerts
  • Notes: explanatory text showing only in the Edit Reminder screen and not in the trade alert

2. Send Notifications for Reminders

If you aren’t interested in your peers’ rants skip the next 4 paragraphs.

How much do you hate users who absolutely want a feature and change their mind as soon as they get that feature? Well, turns out I’m one of them.

Originally I wanted a trade alert for each reminder. That was mostly to try custom actions on notifications – the user should be able to deactivate or delete a reminder from its notification without starting the app.

But the first sentiment when seeing 3 of DbTradeAlert’s icons (1 for signals + 2 for reminders) in the notification area was “wow, that’s intrusive”. And that was before even opening the notification drawer.

Showing more than one trade alert for reminders and getting the actions to work (tap on notification: open Edit Reminder screen with that reminder; tap on Deactivate and Delete buttons: send a broadcast and have the receiver call the respective DbHelper method) turned out to be a bit tricky. That’s because the underlying intents overwrite those for the previous notification which results in all those notification’s actions pointing to the wrong reminder. Of course I had to get that working even while I knew I wasn’t going to use it – SHA-1: c9ce01938895f52db1d7900f8bef6ef890ea05fc. Now let’s continue with a usable implementation.

Trade alerts for reminders will be integrated into the trade alert for triggered signals in QuoteRefresherService.onHandleIntent(). This way trade alerts are sent from the service even if the app itself is asleep.

public class QuoteRefresherService extends IntentService {
    // ...

    private String buildNotificationFromDueReminder(Cursor dueRemindersCursor) {
        String result = "";
        int headingColumnIndex
                = dueRemindersCursor.getColumnIndex(ReminderContract.Reminder.HEADING);
        result = dueRemindersCursor.getString(headingColumnIndex);
        return result;
    } // buildNotificationFromDueReminder()

    // ...

    @Override
    protected void onHandleIntent(Intent intent) {
        String baseUrl = "http://download.finance.yahoo.com/d/quotes.csv";
        String url = baseUrl
                + "?f=" + DbHelper.QuoteDownloadFormatParameter
                + "&s=" + getSymbolParameterValue();
        String quoteCsv = "";
        try {
            boolean isManualRefresh = intent.getBooleanExtra(
                    QUOTE_REFRESHER_BROADCAST_IS_MANUAL_REFRESH_INTENT_EXTRA, false);
            if (isManualRefresh || areExchangesOpenNow()) {
                if (isConnected()) {
                    quoteCsv = downloadQuotes(url);
                    DbHelper dbHelper = new DbHelper(this);
                    dbHelper.updateOrCreateQuotes(quoteCsv);
                    // Notify user of triggered signals and reminders even if app is sleeping
                    dbHelper.updateSecurityMaxPrice();
                    sendNotification(dbHelper);
                    Log.d(CLASS_NAME,
                            "onHandleIntent(): quotes updated - initiating screen refresh");
                    sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_REFRESH_COMPLETED_EXTRA);
                } else {
                    sendLocalBroadcast(QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "no Internet!");
                    Log.e(CLASS_NAME, QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "no Internet!");
                }
            } else {
                Log.d(CLASS_NAME,
                        "onHandleIntent(): exchanges closed and not a manual refresh - skipping alarm");
            }
        } catch (IOException e) {
            Log.e(CLASS_NAME, exceptionMessage, e);
            if (e instanceof UnknownHostException) {
                // java.net.UnknownHostException:
                // Unable to resolve host "download.finance.yahoo.com":
                // No address associated with hostname
                sendLocalBroadcast(
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "broken Internet connection!");
                Log.e(CLASS_NAME,
                        QUOTE_REFRESHER_BROADCAST_ERROR_EXTRA + "broken Internet connection!", e);
            }
            // TODO: cannot rethrow in else case as that doesn't match overridden methods signature?
        } finally {
            QuoteRefreshAlarmReceiver.completeWakefulIntent(intent);
        }
    } // onHandleIntent()

    // ...

    private void sendNotification(DbHelper dbHelper) {
        final String methodName = "sendNotification";
        Cursor dueRemindersCursor = dbHelper.readAllDueReminders();
        Cursor triggeredSignalsCursor = dbHelper.readAllTriggeredSignals();
        try {
            int notificationCount
                    = triggeredSignalsCursor.getCount() + dueRemindersCursor.getCount();
            if (notificationCount > 0) {
                Context context = getApplicationContext();
                NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                        .setColor(Color.GREEN)
                        .setDefaults(Notification.DEFAULT_ALL)
                        .setNumber(notificationCount)
                        .setSmallIcon(R.drawable.emo_im_money_mouth);
                // Tapping notification should lead to app's main screen
                Intent watchlistListIntent = new Intent(this, WatchlistListActivity.class);
                PendingIntent watchlistListPendingIntent
                        = PendingIntent.getActivity(context, 0, watchlistListIntent, 0);
                builder.setContentIntent(watchlistListPendingIntent);
                // Build back stack
                TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
                stackBuilder.addParentStack(WatchlistListActivity.class);
                stackBuilder.addNextIntent(watchlistListIntent);
                // Create notification
                if (notificationCount == 1) {
                    String contentText;
                    if (dueRemindersCursor.getCount() == 1) {
                        dueRemindersCursor.moveToFirst();
                        contentText = buildNotificationFromDueReminder(dueRemindersCursor);
                    } else {
                        triggeredSignalsCursor.moveToFirst();
                        contentText = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                    }
                    builder.setContentTitle("Notification").setContentText(contentText);
                    Log.v(CLASS_NAME,
                            String.format("%s(): Notification = %s", methodName, contentText));
                } else {
                    // Wrap all notifications into one inboxStyle notification
                    builder.setContentTitle(notificationCount + " Notifications");
                    NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                    builder.setStyle(inboxStyle);
                    while (dueRemindersCursor.moveToNext()) {
                        String s = buildNotificationFromDueReminder(dueRemindersCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                    while (triggeredSignalsCursor.moveToNext()) {
                        String s = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                }
                // Show notification
                NotificationManager notificationManager = (NotificationManager) context
                        .getSystemService(Context.NOTIFICATION_SERVICE);
                // Update pending notification if existing
                final int notificationId = 1234;
                notificationManager.notify(notificationId, builder.build());
            }
            Log.d(CLASS_NAME, String.format(
                    "%s(): created notification for %d reminders + signals",
                    methodName, notificationCount));
        } finally {
            DbHelper.closeCursor(dueRemindersCursor);
            DbHelper.closeCursor(triggeredSignalsCursor);
        }
    } // sendNotification()
}

You’ll already know most of the code above from implementing the trade alerts for triggered signals.

The last missing part is DbHelper.readAllDueReminders(). Again I’ll skip showing the code as it’s really simple: get Header and Id for each active reminder with a due date before today.

Trade alert with sample reminder

Trade alert with sample reminder

Try it:

  1. Delete the app to force creation of a new database containing the reminder table and a sample reminder due today
  2. Start the app and tap Refresh – a trade alert for “Sample reminder” will show up – possibly combined with trade alerts for triggered signals

Remember that only the topmost notification shows its full contents. For others you need to open the trade alert manually.

3. Add Reminder Management

Reminder management will consist of an Edit Reminder screen and a Manage Reminders screen – just like watchlist and security management.

3.1 Implement the Manage Reminders Screen

Implementing the Manage Reminders screen starts with creating a new blank activity named “RemindersManagementActivity”.

Its layout is created identical to layout/activity_watchlists_management.xml except the ListView is now called “remindersListView”.

Each reminder in remindersListView will be shown in an additional XML layout named layout/layout_reminders_management_detail.xml. Again, it’s created identical to layout/layout_watchlists_management_detail.xml except the TextView is now called “headingTextView”.

And the last new artefact needed is a Java class named “RemindersManagementCursorAdapter”. To implement it just copy WatchlistsManagementCursorAdapter’s contents except the editButtonClickListener.

After that replace:

  • watchlistId with reminderId
  • nameTextView with headingTextView
  • layout_watchlists_management_detail with layout_reminders_management_detail
  • WatchlistsManagementDetailViewHolder with RemindersManagementDetailViewHolder
  • WATCHLIST_DELETED_BROADCAST with REMINDER_DELETED_BROADCAST including the content
  • WatchlistContract’s fields with the respective ReminderContract fields

Rework some additional strings and you’re done (DbHelper.deleteReminder() not implemented yet).

Similarly you can copy most from WatchlistsManagementActivity to RemindersManagementActivity:

  • Create, register and unregister reminderDeletedBroadcastReceiver
  • Code for onCancelButtonClick() and onOkButtonClick()
  • refreshWatchlistsListView() which you rework for refreshing remindersListView (DbHelper.readAllReminders() not implemented yet)
  • Copy and rework onCreate() and you’re done

Now DbHelper needs a readAllReminders() and a deleteReminder() method. Deleting a reminder by its Id is trivial. When reading all reminders select their heading and ID and sort ascending by due date.

The last step is to connect the Manage Reminders screen:

  1. Extend menu/menu_watchlist_list.xml with an action_reminders_management item
  2. Define a REMINDERS_MANAGEMENT_REQUEST
  3. Extend onActivityResult() to accept REMINDERS_MANAGEMENT_REQUEST – nothing to do
  4. Extend onOptionsItemSelected() to start the respective activity if it encounters the action_reminders_management item
Manage Reminders screen

Manage Reminders screen

Test the app:

  1. In the main screen open the overflow menu and tap Manage Reminders
  2. In the Manage Reminders screen tap Delete for the sample reminder and confirm the delete
  3. Tap OK or Cancel to close the Manage Reminders screen
  4. Tap Refresh: you either get no trade alert or one without the sample reminder

A nice addition would be to remove the trade alert if it was only shown due for the deleted reminder. But I’ll ignore that special case.

3.2 Implement the Edit Reminder Screen

Edit Reminder screen

Edit Reminder screen

Implementing the Edit Reminder screen is very similar to implementing the Edit Security screen. Only the multiline TextEdit is a first because the Edit Security screen needed the space for its list of watchlists. Also note that specifying no “android:text” attribute (for easier alignment) on the CheckBox makes it hard to tap.

ReminderEditActivity’s code checks in onCreate() if the activity is in create or update mode and fills the controls appropriately. Some methods like getDateFromEditText() and setTextFromDateColumn() had to be moved to Utils so ReminderEditActivity can use them, too.

While playing around with date inputs I found that setLenient(false) in getDateFromEditText() is a must for DateFormat.parse(). Otherwise “53.2.16” leads to “Thu Mar 24 00:00:00 GMT+01:00 2016”. That’s actually correct as February 2016 has 29 days but most probably not what the user wanted to enter.

An interesting method is onOkButtonClick() because it forces the user to provide a heading and a due date:

public class ReminderEditActivity extends AppCompatActivity {
    // ...

    public void onOkButtonClick(View view) {
        String errorMessage = "";
        Date dueDate = null;
        try {
            dueDate = Utils.getDateFromEditText(this, R.id.dueDateEditText);
        } catch (ParseException e) {
            errorMessage = e.getMessage();
        }
        if (dueDate == null && TextUtils.isEmpty(errorMessage)) {
            errorMessage = "Please enter a due date";
        }
        String heading = Utils.getStringFromEditText(this, R.id.headingEditText);
        if (TextUtils.isEmpty(heading)) {
            errorMessage = "Please enter a heading";
        }
        CheckBox isReminderActiveCheckBox
                = (CheckBox) findViewById(R.id.isReminderActiveCheckBox);
        boolean isReminderActive = isReminderActiveCheckBox.isChecked();
        String notes = Utils.getStringFromEditText(this, R.id.notesEditText);
        if (TextUtils.isEmpty(errorMessage)) {
            this.dbHelper.updateOrCreateReminder(
                    dueDate, heading, isReminderActive, notes, this.reminderId);
            setResult(RESULT_OK, getIntent());
            finish();
        } else {
            Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show();
        }
    } // onOkButtonClick()
}
public class Utils {
    // ...

    public static Date getDateFromEditText(Activity activity, Integer editTextId)
            throws ParseException {
        Date result = null;
        EditText editText = (EditText) activity.findViewById(editTextId);
        if (editText.length() > 0) {
            DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
            // Without setLenient(false) DateFormat would accept weird date inputs. For example
            // "53.2.16" -> "Thu Mar 24 00:00:00 GMT+01:00 2016"
            // ("correctly" as February 2016 has 29 days :-))
            dateFormat.setLenient(false);
            String text = editText.getText().toString();
            result = dateFormat.parse(text);
        }
        return result;
    } // getDateFromEditText()

    // ...
}

Note that you can use EditText.setError() to show an error message and icon right were the user messed up. I chose a Toast because onOkButtonClick() doesn’t have access to the EditTexts.

Necessary extensions to RemindersManagementActivity consist of a onNewButtonClick() handler that starts ReminderEditActivity in create mode and an onActivityResult() handler that calls refreshRemindersListView() if ReminderEditActivity’s resultCode was RESULT_OK.

The RemindersManagementCursorAdapter loses REMINDER_ID_INTENT_EXTRA to ReminderEditActivity. To compensate for that it gets a new editButtonClickListener() that starts ReminderEditActivity in update mode.

And of course DbHelper is affected: readReminder() returns all fields for the specified reminder and updateOrCreateReminder() tries to UPDATE a specified reminder and if that fails does an INSERT. Of course it could instead check if reminderId equals NEW_ITEM_ID.

To try it:

  1. Create a new reminder – first try to leave a mandatory field empty or try an invalid due date
  2. After successful creation the reminder shows up in the Manage Reminders screen
  3. Tap refresh – if the reminder is due and active it should show up in a corresponding trade alert
  4. Edit the reminder and check if the changes were applied correctly

Again it would be nice if changes to a reminder affected its trade alert. And again I’ll ignore that special case.

4. Add an Action to the Trade Alert

Android Jellybean / API level 19 added custom actions on notifications. Before that a user could only tap the notification itself which means only a single action was possible.

It would be nice if DbTradeAlert’s users could go to the Manage Reminders screen directly from the notification if it has content related to at least one reminder. Tapping the notification itself should still lead to the app’s main screen.

For the new action QuoteRefresherService.onHandleIntent() needs an extension:

public class QuoteRefresherService extends IntentService {
    // ...

    private void addOpenManageRemindersScreenAction(NotificationCompat.Builder builder) {
        Intent remindersManagementIntent = new Intent(this, RemindersManagementActivity.class);
        PendingIntent remindersManagementPendingIntent
                = PendingIntent.getActivity(this, 0, remindersManagementIntent, 0);
        int icon = R.drawable.ic_go_search_api_holo_light;
        String title = "Reminders";
        builder.addAction(icon, title, remindersManagementPendingIntent);
    } // addOpenManageRemindersScreenAction()

    // ...

    private void sendNotification(DbHelper dbHelper) {
        final String methodName = "sendNotification";
        Cursor dueRemindersCursor = dbHelper.readAllDueReminders();
        Cursor triggeredSignalsCursor = dbHelper.readAllTriggeredSignals();
        try {
            int notificationCount
                    = triggeredSignalsCursor.getCount() + dueRemindersCursor.getCount();
            if (notificationCount > 0) {
                Context context = getApplicationContext();
                NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                        .setColor(Color.GREEN)
                        .setDefaults(Notification.DEFAULT_ALL)
                        .setNumber(notificationCount)
                        .setSmallIcon(R.drawable.emo_im_money_mouth);
                // Tapping notification should lead to app's main screen
                Intent watchlistListIntent = new Intent(this, WatchlistListActivity.class);
                PendingIntent watchlistListPendingIntent
                        = PendingIntent.getActivity(context, 0, watchlistListIntent, 0);
                builder.setContentIntent(watchlistListPendingIntent);
                // Build back stack
                TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
                stackBuilder.addParentStack(WatchlistListActivity.class);
                stackBuilder.addNextIntent(watchlistListIntent);
                // Create notification
                if (notificationCount == 1) {
                    String contentText;
                    if (dueRemindersCursor.getCount() == 1) {
                        dueRemindersCursor.moveToFirst();
                        contentText = buildNotificationFromDueReminder(dueRemindersCursor);
                    } else {
                        triggeredSignalsCursor.moveToFirst();
                        contentText = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                    }
                    builder.setContentTitle("Notification").setContentText(contentText);
                    Log.v(CLASS_NAME,
                            String.format("%s(): Notification = %s", methodName, contentText));
                } else {
                    // Wrap all notifications into one inboxStyle notification
                    builder.setContentTitle(notificationCount + " Notifications");
                    NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                    builder.setStyle(inboxStyle);
                    while (dueRemindersCursor.moveToNext()) {
                        String s = buildNotificationFromDueReminder(dueRemindersCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                    while (triggeredSignalsCursor.moveToNext()) {
                        String s = buildNotificationFromTriggeredSignal(triggeredSignalsCursor);
                        inboxStyle.addLine(s);
                        Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                    }
                }
                if (dueRemindersCursor.getCount() > 0) {
                    addOpenManageRemindersScreenAction(builder);
                }
                // Show notification
                NotificationManager notificationManager = (NotificationManager) context
                        .getSystemService(Context.NOTIFICATION_SERVICE);
                // Update pending notification if existing
                final int notificationId = 1234;
                notificationManager.notify(notificationId, builder.build());
            }
            Log.d(CLASS_NAME, String.format(
                    "%s(): created notification for %d reminders + signals",
                    methodName, notificationCount));
        } finally {
            DbHelper.closeCursor(dueRemindersCursor);
            DbHelper.closeCursor(triggeredSignalsCursor);
        }
    } // sendNotification()
    // ...
}

Like the notification’s main action the new one is based on an intent which points to the RemindersManagementActivity class. And like any intent that will be executed from outside the app it is wrapped in a PendingIntent. After that the action is added with an appropriate icon and title. Remember to copy the icons to your project even if Android provides them because those icons may change.

Trade alert with custom action for reminders

Trade alert with custom action for reminders

Does it work?

  1. Run the app with a due reminder – its heading and the new action should show in DbTradeAlert’s trade alert
  2. Tap the action – you should be taken to the Manage Reminders screen
  3. Deactivate all reminders, close the Manage Reminders screen and tap Refresh
  4. If you have triggered signals only their lines should show in the trade alert and no action button

Next post: Add Settings and Finishing Touches

Additional Resources

Advertisements
This entry was posted in Uncategorized and tagged , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s