DbTradeAlert for Android: Add Security and Watchlist Management – Part 4

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 3


2.4 Add Save Functionality for New Securities

Saving new securities is implemented like its watchlist counterpart with two additions:

  • Make shure date and float values were entered correctly
  • Stop the user from entering a duplicate symbol
public class SecurityEditActivity extends AppCompatActivity {
    // ...

    private Date getDateFromEditText(Integer editTextId) {
        Date result = null;
        EditText editText = (EditText) findViewById(editTextId);
        if (editText.length() > 0) {
            DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
            String text = editText.getText().toString();
            try {
                result = dateFormat.parse(text);
            } catch (ParseException e) {
                Log.e(CLASS_NAME, Utils.EXCEPTION_CAUGHT, e);
                Toast.makeText(
                        this, "Error: '" + text + "' is not a valid date", Toast.LENGTH_SHORT)
                        .show();
            }
        }
        return result;
    } // getDateFromEditText()

    // ...

    public void onOkButtonClick(View view) {
        Float basePrice = getFloatFromEditText(R.id.basePriceEditText);
        Date basePriceDate = getDateFromEditText(R.id.basePriceDateEditText);
        Float lowerTarget = getFloatFromEditText(R.id.lowerTargetEditText);
        Float maxPrice = getFloatFromEditText(R.id.maxPriceEditText);
        Date maxPriceDate = getDateFromEditText(R.id.maxPriceDateEditText);
        String notes = Utils.getStringFromEditText(this, R.id.notesEditText);
        String symbol = Utils.getStringFromEditText(this, R.id.symbolEditText);
        Float upperTarget = getFloatFromEditText(R.id.upperTargetEditText);
        Float trailingTarget = getFloatFromEditText(R.id.trailingTargetEditText);
        long[] watchlistIds = Utils.getSelectedListViewItemIds(this, R.id.watchlistsListView);
        String errorMessage = this.dbHelper.updateOrCreateSecurity(basePrice, basePriceDate,
                lowerTarget, maxPrice, maxPriceDate, notes, this.securityId,
                symbol, trailingTarget, upperTarget, watchlistIds);
        if (TextUtils.isEmpty(errorMessage)) {
            setResult(RESULT_OK, getIntent());
            finish();
        } else {
            Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show();
        }
    } // onOkButtonClick()

    // ...
}

The SQL is similar to that saving watchlists with the only twist of avoiding duplicate symbols. DbHelper.updateOrCreateSecurity() first checks whether securityId equals NEW_ITEM_ID. If that’s the case it looks for an existing security with the same symbol. If it finds one it aborts the transaction and returns an error message.

SecurityEditActivity.onOkButtonClick() checks whether DbHelper.updateOrCreateSecurity() returned an non-empty string. If that’s the case it shows a Toast with that string and stays on screen.

Finally SecurityManagementActivity needs to be extended:

  • onActivityResult() calls refreshSecuritiesListView() if resultCode was RESULT_OK
  • refreshSecuritiesListView() does exactly what its name says

Try the new functionality:

  1. Open the Manage Securities screen
  2. Tap New to show the Edit Security screen – it says “Add Security”
  3. Enter a symbol that’s already in use and tap OK – you’ll get a message that you cannot add another security with that symbol and the Edit Security screen stays
  4. Enter a new symbol and select one or more watchlists
  5. Tap OK – the Edit Security screen closes and the Manage Securities screen shows the new security – without its name because no quotes were downloaded yet
  6. Also tap OK in the Manage Securities screen to close it
  7. Navigate to one of the watchlists that include the new security – there is no report for it because no quotes were downloaded yet
  8. Tap refresh – DbTradeAlert shows a complete report for the new security
  9. Optional: commit changes

2.5 Add Edit Functionality for Existing Securities

Editing a security - symbol read-only

Editing a security – symbol read-only

Editing securities is implemented like editing watchlists and I’ll skip repeating the explanation. One difference is that SecurityEditActivity.onCreate() calls symbolEditText.setEnabled(false) for existing securities. And DbHelper.readSecurity() of course has to join the Quote table for its Name field.

When the user taps OK the security and its connection to watchlists are saved like for a new security. If the user entered a duplicate symbol the error message from DbHelper is shown and the screen stays open.

And finally WatchlistsManagementActivity.onActivityResult() receives a resultCode of RESULT_OK and refreshes is list of securities if this was an addition – as it shows only the symbols and these are immutable there is no need for a refresh after updating a security.

Give it a try:

  1. Open the Manage Securities screen
  2. Tap Edit on one of the securities to show the Edit Security screen – it says “Edit Security”
  3. Change one of the fields or the watchlists that will include the security
  4. Tap OK
  5. Also tap OK in the Manage Securities screen
  6. Check if the changes were applied correctly
  7. Optional: commit changes

2.6 Add Delete Functionality for Securities

The functionality to delete securities uses the same pattern as for editing them – for explanations see deleting watchlists.

Deleting the data is straightforward – DbHelper.deleteSecurity() first deletes the security’s quote, then the records connecting the security to any watchlists and after that deletes the security itself. Of course it wraps everything in a transaction. The log entries look like this if the security was in a single watchlist:
… V/DbHelper: deleteSecurity(): securityId = 6
… V/DbHelper: deleteSecurity(): result of db.delete() from quote = 1
… V/DbHelper: deleteSecurity(): result of db.delete() from securities_in_watchlists = 1
… V/DbHelper: deleteSecurity(): result of db.delete() from security = 1
… D/DbHelper: deleteSecurity(): success!

Deleting a security

Deleting a security

To try it:

  1. Open the Manage Securities screen
  2. Tap Delete on one of the securities
  3. Tap Ok in the confirmation dialog – note that it displays the security’s name to avoid any mishaps
  4. Also tap OK in the Manage Securities screen
  5. Check if the security is gone
  6. Optional: commit changes

Next post: Add Reminders

Advertisements
Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Security and Watchlist Management – Part 3

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 2


2. Add Securities Management

Securities management works exactly the same way watchlists management does. And adding securities management will follow the exact same steps adding watchlist management used. So let’s just fast forward through this.

2.1 Add the Manage Securities Screen

You need a new empty activity named “SecuritiesManagementActivity” with 3 Buttons, a ListView, and a TextView similar to activity_watchlists_management.xml.

And you need code for the Cancel and OK buttons and to set the screen’s title in onCreate() like in WatchlistsManagementActivity.java.

And finally you need a new menu item in menu_watchlist_list.xml.

Then you need to extend WatchlistListActivity to start the new activity in onOptionsItemSelected() and retrieve its result in onActivityResult().

Manage Securities screen - list of watchlists empty

Manage Securities screen – list of watchlists empty

Now try out the additions:

  1. Start the app
  2. In its overflow menu tap “Manage Securities”
  3. The “Manage Securities” screen appears
    1. Note the hint about adding a new security when there is none
    2. Note that there is no menu but a distinct title for the screen
  4. In the “Manage Securities” screen tap either OK or Cancel – tapping “New” will crash the app
  5. Optional: commit the changes

2.2 Fill List of Existing Securities

To list the existing securities DbTradeAlert needs a layout to show each security. It’s added like layout_watchlists_management_detail with one more TextView to show the security’s symbol.

Manage Securities screen listing securities

Manage Securities screen listing securities

The next step will be using an adapter to marry the ListView with its cursor and its detail layout. So create a new class named “SecuritiesManagementCursorAdapter” extending CursorAdapter like you did for WatchlistsManagementCursorAdapter.

For explanations see filling the list of existing watchlists.

Finally connect SecuritiesManagementCursorAdapter to securitiesListView in WatchlistsManagementActivity.onCreate() – to fill the list of securities DbHelper.getAllSecuritiesAndMarkIfInWatchlist() will be reused.

Test it: the securities show up in the Manage Securities screen. Optionally check in the changes.

2.3 Add an Activity to Edit Securities

Add a new empty activity named “SecurityEditActivity”. It hosts a lot of controls and I’ll just show the resulting layout:

Edit Security layout

Edit Security layout

The first step after creating the layout is to provide a handler for SecuritiesManagementActivity’s New button. The pattern is exactly like in WatchlistsManagementActivity:

  1. Create an intent pointing to SecurityEditActivity.class
  2. Add an Extra named SecurityEditActivity.SECURITY_ID_INTENT_EXTRA with the security ID of DbHelper.NEW_ITEM_ID to the intent
  3. Call startActivityForResult() passing the intent and SecurityEditActivity.CREATE_SECURITY_REQUEST_CODE

And SecurityEditActivity’s required extensions:

  1. It needs definitions for CREATE_SECURITY_REQUEST_CODE and SECURITY_ID_INTENT_EXTRA
  2. onCancelButtonClick():
    1. Sets result to RESULT_CANCELED
    2. Closes the screen
  3. onCreate():
    1. Grabs the ID from the intent’s Extras
    2. Clears the EditTexts for new securities
    3. Sets the screen’s title
    4. Calls refreshSecuritiesListView() passing the security’s ID
  4. refreshSecuritiesListView():
    1. Calls dbHelper.getAllWatchlistsAndMarkIfSecurityIsIncluded() passing the security’s ID
    2. Creates a SimpleCursorAdapter instance to connect cursor and ListView
    3. Sets the ListView’s EmptyView
    4. Marks the watchlists that include the currently shown security

Again this code works exactly like that for editing a watchlist – just with a lot more fields.

Finally DbHelper needs getAllWatchlistsAndMarkIfSecurityIsIncluded() which works like explained for DbHelper.getAllSecuritiesAndMarkIfInWatchlist().

Run the app again:

  1. Open the Manage Securities screen
  2. Tap New: the new Edit Security screen shows up listing all watchlists and ready for input
    1. again no menu but a distinct title of “Add Security”
  3. Tap Cancel – tapping OK will crash the app
  4. Optional: commit changes
Adding a new security

Adding a new security

The screen shows a security’s fields: at the top is the symbol field. Below that you see value and date for base and maximum price, targets, and the notes field. Note that the dates have to be entered according to the device’s locale. While Android provides a DatePickerDialog I found it too much hassle to implement and connect it to the EditTexts.

At the bottom of the screen is a list of all watchlists with those checked that contain this security.

Note that that I closed the automatically displayed on-screen keyboard for the screenshot so it doesn’t obstruct the activity’s screen.

Next post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 4

Additional Resources:

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Security and Watchlist Management – Part 2

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 1


1.4 Add Save Functionality for New Watchlists

“Add Save Functionality” simply means get WatchlistEditActivity’s OK button working:

public class WatchlistEditActivity extends AppCompatActivity {
    // ...

    public void onOkButtonClick(View view) {
        // Get name
        String name = "";
        EditText editText = (EditText) findViewById(R.id.nameEditText);
        if (editText.length() > 0) {
            name = editText.getText().toString();
        }
        // Get securities to include in watchlist
        ListView listView = (ListView) findViewById(R.id.securitiesListView);
        long[] securityIds = listView.getCheckedItemIds();
        // Save edits
        this.dbHelper.updateOrCreateWatchlist(name, securityIds, this.watchlistId);
        setResult(RESULT_OK, getIntent());
        finish();
    } // onOkButtonClick()

    // ...
}

The method extracts the (new) watchlist’s name and the securities to include in it from the controls and passes it to DbHelper.updateOrCreateWatchlist(). After that it sets the activity’s result to RESULT_OK and closes the screen.

public class DbHelper extends SQLiteOpenHelper {
    private final static String DELETE_RESULT_FORMAT = "%s(): result of db.delete() from %s = %d";
    private final static String INSERT_CONTENT_VALUES_FORMAT = "%s(): contentValues for %s: %s";
    // ...

    public void updateOrCreateWatchlist(String name, long[] securityIds,
                                        long watchlistId) {
        final String methodName = "updateOrCreateWatchlist";
        Long insertResult = null;
        String[] whereArgs = new String[] { String.valueOf(watchlistId) };
        SQLiteDatabase db = getWritableDatabase();
        try {
            db.beginTransaction();
            // Save watchlist data
            boolean isExistingWatchlist = (watchlistId != NEW_ITEM_ID);
            ContentValues contentValues = new ContentValues();
            contentValues.put(Watchlist.NAME, name);
            if (isExistingWatchlist) {
                Integer updateResult = db.update(Watchlist.TABLE,
                        contentValues, Watchlist.ID + " = ?", whereArgs);
                Log.v(CLASS_NAME, String.format(UPDATE_RESULT_FORMAT,
                        methodName, Watchlist.TABLE, updateResult));
            } else {
                insertResult = db.insert(Watchlist.TABLE, null, contentValues);
                Log.v(CLASS_NAME, String.format(INSERT_RESULT_FORMAT,
                        methodName, Watchlist.TABLE, insertResult));
                watchlistId = insertResult;
                Log.v(CLASS_NAME, String.format("%s(): new watchlistId = %d",
                        methodName, watchlistId));
            }
            // Delete existing connections to securities
            if (isExistingWatchlist) {
                Integer deleteResult = db.delete(
                        SecuritiesInWatchlists.TABLE,
                        SecuritiesInWatchlists.WATCHLIST_ID + " = ?", whereArgs);
                Log.v(CLASS_NAME, String.format(DELETE_RESULT_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE,
                        deleteResult));
            } else {
                Log.v(CLASS_NAME, String.format(
                        "%s(): New watchlist; skipping delete in %s",
                        methodName, SecuritiesInWatchlists.TABLE));
            }
            // Create specified connections to securities
            contentValues = new ContentValues();
            for (int i = 0; i < securityIds.length; i++) {
                contentValues.clear();
                contentValues.put(SecuritiesInWatchlists.SECURITY_ID, securityIds[i]);
                contentValues.put(SecuritiesInWatchlists.WATCHLIST_ID, watchlistId);
                Log.v(CLASS_NAME, String.format(INSERT_CONTENT_VALUES_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE,
                        contentValues));
                insertResult = db.insert(SecuritiesInWatchlists.TABLE, null, contentValues);
                Log.v(CLASS_NAME, String.format(INSERT_RESULT_FORMAT,
                        methodName, SecuritiesInWatchlists.TABLE, insertResult));
            }
            db.setTransactionSuccessful();
            Log.d(CLASS_NAME, methodName + "(): success!");
        } finally {
            db.endTransaction();
        }
    } // updateOrCreateWatchlist()
}

For a new watchlist updateOrCreateWatchlist() first inserts it into the watchlist table and then creates the connection to its securities in the securities_in_watchlists table. All of this is done in a transaction of course. We’ll ignore the code to update existing watchlists for now.

Now WatchlistManagementActivity needs code to deal with tapping OK in WatchlistEditActivity:

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK) {
            refreshWatchlistsListView();
        }
    } // onActivityResult()

    // ...

    private void refreshWatchlistsListView() {
        Cursor cursor = this.dbHelper.readAllWatchlists();
        this.watchlistManagementCursorAdapter.changeCursor(cursor);
    } // refreshWatchlistsListView()
}

Try the new functionality:

  1. Open the Manage Watchlists screen
  2. Tap New to show the Edit Watchlist screen – it says “Create Watchlist”
  3. Enter a name and select one or more securities
  4. Tap OK
  5. Also tap OK in the Manage Watchlists screen
  6. DbTradeAlert shows the new watchlist in the rightmost tab
  7. Optional: commit changes

What’s missing now is only functionality to edit and delete existing watchlists.

1.5 Add Edit Functionality for Existing Watchlists

Implementing functionality to edit watchlists is a bit involved because the button for it (and the one for deleting watchlists) isn’t in an activity like all the previous buttons. Instead, a ListView hosts a list of views and each view contains both a button for deleting and one for editing the watchlist it represents. For that reason the button’s click handler goes into the ListView’s CursorAdapter class which connects it in newView():

public class WatchlistManagementCursorAdapter extends CursorAdapter {
    // ...

    private View.OnClickListener editButtonClickListener = new View.OnClickListener() {

        public void onClick(View v) {
            WatchlistManagementDetailViewHolder holder
                    = (WatchlistManagementDetailViewHolder) ((View) v.getParent()).getTag();
            long watchListId = holder.watchListId;
            Intent intent = new Intent(holder.context, WatchlistEditActivity.class);
            intent.putExtra(WatchlistEditActivity.WATCHLIST_ID_INTENT_EXTRA, watchListId);
            ((Activity) holder.context).startActivityForResult(intent,
                    WatchlistEditActivity.UPDATE_WATCHLIST_REQUEST_CODE);
        }
    }; // editButtonClickListener

    // ...

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        View view = View.inflate(context, R.layout.layout_watchlists_management_detail, null);
        // could replace WatchListManagementDetailViewHolder in ICS and above with
        // view.setTag(R.id.my_view, myView);
        WatchListManagementDetailViewHolder holder = new WatchListManagementDetailViewHolder();
        holder.context = context;
        holder.deleteButton = (Button) view.findViewById(R.id.deleteButton);
        //holder.deleteButton.setOnClickListener(deleteButtonClickListener);
        holder.editButton = (Button) view.findViewById(R.id.editButton);
        holder.editButton.setOnClickListener(editButtonClickListener);
        holder.nameTextView = (TextView) view.findViewById(R.id.nameTextView);
        holder.watchListId = cursor.getLong(cursor.getColumnIndex(WatchlistContract.Watchlist.ID));
        view.setTag(holder);
        return view;
    } // newView()


    public class WatchListManagementDetailViewHolder {
        public Context context;
        public Button deleteButton;
        public Button editButton;
        public TextView nameTextView;
        public long watchListId;
    } // class WatchListManagementDetailViewHolder
} // class WatchlistManagementCursorAdapter

The code creates an OnClickListener instance from an anonymous class and provides an onClick() handler for it. The view representing the watchlist is passed as a parameter to onClick() and the WatchlistManagementDetailViewHolder instance connected to that view provides access to the watchlist’s Id and WatchlistsManagementActivity’s Context (see highlighted lines).

A necessary change for that was to add a Context field to WatchlistManagementDetailViewHolder. That makes it possible to access the WatchlistsManagementActivity instance inside editButtonClickListener.

The code then creates an intent to show an WatchlistEditActivity and provides the watchlist’s Id in its extras so WatchlistEditActivity knows which watchlist to load. When starting the activity the code uses WatchlistEditActivity.UPDATE_WATCHLIST_REQUEST_CODE to signal that it’s about editing an existing watchlist.

Actually WatchlistEditActivity.onCreate() only checks if watchlistId isn’t DbHelper.NewItemId to decide if that’s an edit:

public class WatchlistEditActivity extends AppCompatActivity {
    public final static int UPDATE_WATCHLIST_REQUEST_CODE = 1;
    // ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        final String methodName = "onCreate";
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_edit);
        this.dbHelper = new DbHelper(this);
        Bundle extras = getIntent().getExtras();
        if (extras != null) {
            this.watchlistId = extras.getLong(WATCHLIST_ID_INTENT_EXTRA);
            EditText nameEditText = (EditText) findViewById(R.id.nameEditText);
            if (this.watchlistId == DbHelper.NewItemId) {
                // Create mode
                nameEditText.setText("");
                setTitle("Create Watchlist");
            } else {
                // Update mode
                Cursor watchlistCursor = this.dbHelper.readWatchlist(this.watchlistId);
                if (watchlistCursor.getCount() == 1) {
                    watchlistCursor.moveToFirst();
                    nameEditText
                            .setText(watchlistCursor.getString(watchlistCursor
                                    .getColumnIndex(WatchlistContract.Watchlist.NAME)));
                } else {
                    Log.e(CLASS_NAME, String.format(
                            "%s(): readWatchlist() found %d watchlists with id = %d; expected 1!",
                            methodName, watchlistCursor.getCount(),
                            this.watchlistId));
                }
            }
        }
        refreshSecuritiesList(this.watchlistId);
    } // onCreate()

    // ...
}

It displays the watchlist’s name which is the only field provided by DbHelper.readWatchlist() and shows its securities.

public class DbHelper extends SQLiteOpenHelper {
    // ...

    private void logSql(String methodName, String[] columns, String orderBy,
                        String selection, String[] selectionArgs, String table) {
        StringBuilder sb = new StringBuilder();
        sb.append("SELECT ");
        if (columns != null) {
            for (int i = 0; i < columns.length; i++) {
                sb.append(columns[i]);
                if (i < columns.length - 1) {
                    sb.append(", ");
                }
            }
        } else {
            sb.append("*");
        }
        sb.append("\nFROM ");
        sb.append(table);
        if (TextUtils.isEmpty(selection) == false) {
            sb.append("\nWHERE ");
            sb.append(insertSelectionArgs(selection, selectionArgs));
        }
        if (TextUtils.isEmpty(orderBy) == false) {
            sb.append("\nORDER BY ");
            sb.append(orderBy);
        }
        Log.v(CLASS_NAME, methodName + "(): " + sb.toString());
    } // logSql()

    // ...

    public Cursor readWatchlist(long watchlistId) {
        final String methodName = "readWatchlist";
        Cursor cursor = null;
        Log.v(CLASS_NAME,
                String.format("%s(): watchlistId = %d", methodName, watchlistId));
        SQLiteDatabase db = getReadableDatabase();
        String selection = Watchlist.ID + " = ?";
        String[] selectionArgs = new String[]{String.valueOf(watchlistId)};
        String table = Watchlist.TABLE;
        logSql(methodName, null, null, selection, selectionArgs, table);
        cursor = db.query(table, null, selection, selectionArgs, null, null,
                null);
        Log.v(CLASS_NAME, String.format(CURSOR_COUNT_FORMAT, methodName, cursor.getCount()));
        if (cursor.getCount() != 1) {
            Log.e(CLASS_NAME, String.format(
                    "%s(): found %d watchlists with id = %d; expected 1!", methodName,
                    cursor.getCount(), watchlistId));
        }
        return cursor;
    } // readWatchlist()

    // ...
}

When the user taps OK the watchlist and its connection to securities are saved like for a new watchlist. And finally WatchlistsManagementActivity.onActivityResult() receives a resultCode of RESULT_OK and refreshes its list of watchlists to show a possibly changed name – again this was already implemented for creating new watchlists.

Give it a try:

  1. Open the Manage Watchlists screen
  2. Tap Edit on one of the watchlists to show the Edit Watchlist screen – it says “Edit Watchlist”
  3. Change the name or the securities to include
  4. Tap OK
  5. Also tap OK in the Manage Watchlists screen
  6. Check if the changes were applied correctly – either in the main screen or by using the Edit Watchlist screen again
  7. Optional: commit changes

1.6 Add Delete Functionality for Watchlists

The functionality to delete watchlists in WatchlistManagementCursorAdapter uses the same pattern as for editing them:

public class WatchlistManagementCursorAdapter extends CursorAdapter {
    public static final String WATCHLIST_DELETED_BROADCAST = "WatchlistDeletedBroadcast";
    // ...

    private View.OnClickListener deleteButtonClickListener = new View.OnClickListener() {

        public void onClick(final View v) {
            WatchListManagementDetailViewHolder holder
                    = (WatchListManagementDetailViewHolder) ((View) v.getParent()).getTag();
            String watchListName = holder.nameTextView.getText().toString();
            new AlertDialog.Builder(holder.context)
                    .setTitle("Delete?")
                    .setMessage(
                            String.format(
                                    "Delete watchlist '%s' and its connections to securities?",
                                    watchListName))
                    .setIcon(android.R.drawable.ic_dialog_alert)
                    .setPositiveButton(android.R.string.yes,
                            new DialogInterface.OnClickListener() {
                                public void onClick(DialogInterface dialog,
                                                    int whichButton) {
                                    WatchListManagementDetailViewHolder holder
                                            = (WatchListManagementDetailViewHolder) ((View) v
                                            .getParent()).getTag();
                                    long watchListId = holder.watchListId;
                                    dbHelper.deleteWatchlist(watchListId);
                                    Intent intent = new Intent(WATCHLIST_DELETED_BROADCAST);
                                    LocalBroadcastManager.getInstance(holder.context)
                                            .sendBroadcast(intent);
                                }
                            })
                    .setNegativeButton("Cancel", null)
                    .show();
        } // onClick()

    }; // deleteButtonClickListener

    // ...
}

If you aren’t used to Java the code for deleteButtonClickListener probably looks somewhat weird due to the anonymous classes and the builder pattern. The code itself is like the code to edit a watchlist but with an added twist: the user needs to tap OK in a confirmation dialog to actually delete a watchlist.

Creating that AlertDialog uses the builder pattern / a fluent API / method chaining. That’s syntactical sugar claiming to make the code more readable. Or maybe it makes it even less readable – you decide.

The setPositiveButton() method’s 2nd parameter is yet another OnClickListener and again it is provided as an anonymous class that only implements an onClick() handler. That handler determines the watchlist’s ID and passes it to DbHelper.deleteWatchlist(). After that it initiates a refresh of the list from which the watchlist was deleted.

WatchlistManagementCursorAdapter avoids referencing WatchlistsManagementActivity because that activity already uses the CursorAdapter. And because deleting a watchlist doesn’t show a new activity it can’t trigger WatchlistsManagementActivity.onActivityResult(). Again, time to send a local broadcast.

In WatchlistsManagementActivity a receiver for that broadcast has to be implemented, registered, and unregistered. Note that in lifecycle methods like onPause() and onResume() you always call super first.

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    private BroadcastReceiver watchlistDeletedBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent.getAction().equals(
                    WatchlistsManagementCursorAdapter.WATCHLIST_DELETED_BROADCAST)) {
                refreshWatchlistsListView();
            }
        }
    }; // watchlistDeletedBroadcastReceiver

    // ...

    @Override
    public void onPause() {
        super.onPause();
        // Unregister broadcast receiver for WATCHLIST_DELETED_BROADCAST
        LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
        broadcastManager.unregisterReceiver(watchlistDeletedBroadcastReceiver);
    } // onPause()

    @Override
    public void onResume() {
        super.onResume();
        // Register broadcast receiver for WATCHLIST_DELETED_BROADCAST
        LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(WatchlistsManagementCursorAdapter.WATCHLIST_DELETED_BROADCAST);
        broadcastManager.registerReceiver(watchlistDeletedBroadcastReceiver, intentFilter);
    } // onResume()

    // ...
}

Deleting the data is straightforward – DbHelper.deleteWatchlist() first deletes the records connecting the watchlist to any securities and after that deletes the watchlist itself. Of course it wraps everything in a transaction. The log entries look like this if the watchlist showed 2 securities:
… V/DbHelper: deleteWatchlist(): watchlistId = 4
… V/DbHelper: deleteWatchlist(): result of db.delete() from securities_in_watchlists = 2
… V/DbHelper: deleteWatchlist(): result of db.delete() from watchlist = 1
… D/DbHelper: deleteWatchlist(): success!

Deleting a watchlist

Deleting a watchlist

To try it:

  1. Open the Manage Watchlists screen
  2. Tap Delete on one of the watchlists
  3. Tap Ok in the confirmation dialog – note that it displays the watchlist’s name to avoid any mishaps
  4. Also tap OK in the Manage Watchlists screen
  5. Check if the watchlist is gone
  6. Optional: commit changes

Next post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 3

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Add Security and Watchlist Management – Part 1

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Schedule Quote Updates – Part 3


By now DbTradeAlert does what it’s supposed to do – inform the user about triggered signals. What’s missing is the ability to manage its entities:

  • Add, edit and delete securities
  • Add, edit and delete watchlists

For both entities the adding and deleting will be started in one activity and the actual editing will be done in a second activity.

Managing watchlists is easier so let’s start with this.

1. Add Watchlists Management

The first step will be to create the Watchlists Management screen and add code to invoke and dismiss it. In the Watchlists Management screen users see a list of all their watchlists and can Tap Edit or New to go to the – not yet existing – Edit Watchlist screen. They can also Tap Delete to get rid of an unused watchlist.

1.1 Add the Watchlists Management Screen

Add a new empty activity named “WatchlistsManagementActivity” (notice plural). After that add 3 Buttons, a ListView, and a TextView to activity_watchlists_management.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" tools:context=".WatchlistsManagementActivity">

    <Button android:id="@+id/newButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentTop="true" android:layout_centerHorizontal="true" android:onClick="onNewButtonClick" android:text="New" />

    <Button android:id="@+id/okButton" style="android:buttonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_alignParentRight="true" android:onClick="onOkButtonClick" android:text="@android:string/ok" />

    <Button android:id="@+id/cancelButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_alignParentLeft="true" android:onClick="onCancelButtonClick" android:text="Cancel" />

    <ListView android:id="@+id/watchlistsListView" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_above="@+id/okButton" android:layout_alignParentLeft="true" android:layout_below="@+id/newButton" />

    <TextView android:id="@+id/emptyTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="false" android:layout_alignParentTop="false" android:layout_centerInParent="true" android:text="(Click 'New' to create a new watchlist)" android:textAppearance="?android:attr/textAppearanceSmall" />
</RelativeLayout>

As the OK button shows Android provides translations for general strings like “OK”, “Cancel” or “New” that it will automatically use depending on the device’s language setting. But internationalizing an app properly is a huge effort which doesn’t make sense for DbTradeAlert and so I use hard-coded strings. The rest of the layout should look familiar by now.

Android Studio will automatically add the activity to AndroidManifest.xml.

Then add code for the Cancel and OK buttons and extend onCreate() to set the screen’s title. Using “android:label” in “layout/activity_watchlists_management.xml” would not work because “android:label” in “AndroidManifest.xml” will overrule it. setTitle() does the trick.

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    public void onCancelButtonClick(View view) {
        setResult(RESULT_CANCELED, getIntent());
        finish();
    } // onCancelButtonClick()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlists_management);
        setTitle("Manage Watchlists");
    } // onCreate()

    public void onOkButtonClick(View view) {
        setResult(RESULT_OK, getIntent());
        finish();
    } // onOkButtonClick()
}

Now add a new entry to the app’s menu:

And finally extend WatchlistListActivity to start the new activity and retrieve its result:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    private static final int WATCHLISTS_MANAGEMENT_REQUEST = 2;
    private WatchlistListPagerAdapter watchlistListPagerAdapter;
    // ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        final String methodName = "onActivityResult";
        switch (requestCode) {
            case WATCHLISTS_MANAGEMENT_REQUEST:
                watchlistListPagerAdapter.notifyDataSetChanged();
                break;
            default:
                Log.e(CLASS_NAME, String.format("%s(): unexpected requestCode = ",
                        methodName, requestCode));
                break;
        }
        super.onActivityResult(requestCode, resultCode, data);
    } // onActivityResult()

    // ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();
        switch (id) {
            case R.id.action_refresh: {
                setTitle(APP_NAME);
                Context context = getApplicationContext();
                Intent service = new Intent(context, QuoteRefresherService.class);
                service.putExtra(QuoteRefresherService.INTENT_EXTRA_IS_MANUAL_REFRESH, true);
                startService(service);
                return true;
            }
            case R.id.action_watchlists_management: {
                Intent intent = new Intent(this, WatchlistsManagementActivity.class);
                startActivityForResult(intent, WATCHLISTS_MANAGEMENT_REQUEST);
                return true;
            }
            case R.id.action_settings: {
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    }

    // ...
}

In onOptionsItemSelected() you start the activity by calling the superclass’ startActivityForResult() method with an intent specifying the activity’s class. That method also takes an ID that will be passed back to the superclass’ onActivityResult() method once the user has clicked OK or Cancel in the activity. Regardless of resultCode onActivityResult() will initiate a refresh of watchlistListPagerAdapter because while the user may have tapped Cancel in the Manage Watchlists screen he may have OK’d changes in the Edit Watchlist screen.

Not informing watchlistListPagerAdapter about this will result in an exception: “IllegalStateException: The application’s PagerAdapter changed the adapter’s contents without calling PagerAdapter#notifyDataSetChanged!”

To make this possible watchlistListPagerAdapter’s declaration needs to be moved to class level.

Manage Watchlists screen with empty list

Manage Watchlists screen with empty list

Now try out the additions:

  1. Start the app
  2. In its overflow menu tap “Manage Watchlists” – the “Manage Watchlists” screen appears
  3. Note the hint about creating a new watchlist when there is none; that’s the only work emptyTextView will ever do
  4. Note that there is no menu but a distinct title for the screen
  5. In the “Manage Watchlists” screen tap either OK or Cancel – tapping “New” will crash the app
  6. Optional: commit the changes

1.2 Fill List of Existing Watchlists

The next step will be to list the available watchlists. For that DbTradeAlert needs a layout to show each watchlist. To add one:

  1. In the res/layout folder’s context menu select New | XML | Layout XML File
  2. In the Configure Component window:
    1. Layout File Name: “layout_watchlists_management_detail”
    2. Root Tag: “RelativeLayout”
    3. Click Finish

Complete the layout by adding 2 Buttons and a textView:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent">

    <Button android:id="@+id/editButton" style="?android:attr/buttonStyleSmall" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_alignParentTop="true" android:text="Edit" />

    <Button android:id="@+id/deleteButton" style="?android:attr/buttonStyleSmall" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentTop="true" android:layout_toLeftOf="@+id/editButton" android:text="Delete" />

    <TextView android:id="@+id/nameTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignBaseline="@+id/deleteButton" android:layout_alignBottom="@+id/deleteButton" android:layout_alignParentLeft="true" android:text="Default watch list" android:textAppearance="?android:attr/textAppearanceMedium" />

</RelativeLayout>

Like for the previously created lists you’ll need an adapter to marry the ListView with its cursor and its detail layout. Only this time it will be more involved because of the edit and delete actions provided by the layout.

Create a new class named “WatchlistManagementCursorAdapter” extending CursorAdapter (android.support.v4.widget.CursorAdapter). Let Android Studio create the missing methods and a constructor with “Context context, Cursor c, boolean autoRequery” parameters. The next step is to extend the class like shown below including the private WatchListManagementDetailViewHolder class:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.database.Cursor;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CursorAdapter;
import android.widget.TextView;

import de.dbremes.dbtradealert.DbAccess.DbHelper;
import de.dbremes.dbtradealert.DbAccess.WatchlistContract;

public class WatchListManagementCursorAdapter extends CursorAdapter {
    private Context context;
    DbHelper dbHelper;

    public WatchListManagementCursorAdapter(Context context, Cursor c, boolean autoRequery) {
        super(context, c, autoRequery);
        this.context = context;
        this.dbHelper = new DbHelper(this.context);
    } // ctor()

    @Override
    public void bindView(View view, Context context, Cursor cursor) {
        WatchListManagementDetailViewHolder holder
                = (WatchListManagementDetailViewHolder) view.getTag();
        holder.nameTextView.setText(cursor.getString(cursor
                .getColumnIndex(WatchlistContract.Watchlist.NAME)));
        holder.watchListId = cursor.getLong(cursor
                .getColumnIndex(WatchlistContract.Watchlist.ID));
    } // bindView()

    @Override
    public View newView(Context context, Cursor cursor, ViewGroup parent) {
        View view = View.inflate(context, R.layout.layout_watchlists_management_detail, null);
        // could replace WatchListManagementDetailViewHolder in ICS and above with
        // view.setTag(R.id.my_view, myView);
        WatchListManagementDetailViewHolder holder = new WatchListManagementDetailViewHolder();
        //holder.deleteButton = (Button) view.findViewById(R.id.deleteButton);
        holder.deleteButton.setOnClickListener(deleteButtonClickListener);
        holder.editButton = (Button) view.findViewById(R.id.editButton);
        //holder.editButton.setOnClickListener(editButtonClickListener);
        holder.nameTextView = (TextView) view.findViewById(R.id.nameTextView);
        holder.watchListId = cursor.getLong(cursor.getColumnIndex(WatchlistContract.Watchlist.ID));
        view.setTag(holder);
        return view;
    } // newView()


    private class WatchListManagementDetailViewHolder {
        public Button deleteButton;
        public Button editButton;
        public TextView nameTextView;
        public long watchListId;
    } // class WatchListManagementDetailViewHolder
} // class WatchListManagementCursorAdapter

As always the ViewHolder’s job is to save the time required for finding controls with findViewById(). newView() creates a new WatchListManagementDetailViewHolder instance, stores references to the controls in it and saves it to the View’s tag. After that bindView() is called and uses those references to update the control’s data. Because bindView() is called way more often than newView() the time savings from storing references to the views add up. Even more important: bindView() is called when the screen updates and that’s where split seconds count.

Finally connect WatchlistManagementCursorAdapter to watchListsListView in WatchlistsManagementActivity.onCreate(). Again, the pattern should be familiar by now.

public class WatchlistsManagementActivity extends AppCompatActivity {
    private Cursor cursor;
    private DbHelper dbHelper;
    private WatchListManagementCursorAdapter watchListManagementCursorAdapter
    // ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlists_management);
        dbHelper = new DbHelper(this);
        this.cursor = dbHelper.readAllWatchlists();
        this.watchListManagementCursorAdapter
                = new WatchListManagementCursorAdapter(this, this.cursor, false);
        ListView watchListsListView = (ListView) findViewById(R.id.watchListsListView);
        TextView emptyTextView = (TextView) findViewById(R.id.emptyTextView);
        watchListsListView.setEmptyView(emptyTextView);
        watchListsListView.setAdapter(watchListManagementCursorAdapter);
    } // onCreate()

    // ...
}
Watchlists Management screen showing watchlists

Watchlists Management screen showing watchlists

Now start the app and test again: its watchlists show up in the Manage Watchlists screen – be aware that tapping the Edit or Delete buttons will crash the app. Again a good time to check in the changes.

A prerequisite for working New and Edit buttons is yet another new activity: one to edit a watchlist’s details. So let’s create that activity.

1.3 Add an Activity to Edit Watchlists

Add a new empty activity named “WatchlistEditActivity” (notice singular). The layout needs 2 Buttons, a ListView, a TextEdit, and 3 TextViews. That ends up being 250 lines of xml so I’ll not post it – just get the code from GitHub or look at the screenshot below.

The first step is to provide code for WatchlistsManagementActivity’s New button:

public class WatchlistsManagementActivity extends AppCompatActivity {
    // ...

    public void onNewButtonClick(View view) {
        Intent intent = new Intent(this, WatchlistEditActivity.class);
        intent.putExtra(WatchlistEditActivity.INTENT_EXTRA_WATCHLIST_ID,
                DbHelper.NEW_ITEM_ID);
        startActivityForResult(intent,
                WatchlistEditActivity.CREATE_WATCHLIST_REQUEST_CODE);
    } // onNewButtonClick()

} // class WatchlistsManagementActivity()

The pattern around startActivityForResult() is as used before. But this time an ID is transferred in the intent’s extras so the target activity knowns which watchlist to work on. As onNewButtonClick() starts the creation of a new watchlist it transfers a special ID that DbTradeAlert uses to signal a new item.

And WatchlistEditActivity’s required extensions:

public class WatchlistEditActivity extends AppCompatActivity {
    private final static String CLASS_NAME = "WatchlistEditActivity";
    public final static int CREATE_WATCHLIST_REQUEST_CODE = 0;
    public final static String INTENT_EXTRA_WATCHLIST_ID = "de.dbremes.dbtradealert.watchlistId";
    private DbHelper dbHelper;
    private long watchlistId = DbHelper.NewItemId;

    public void onCancelButtonClick(View view) {
        setResult(RESULT_CANCELED, new Intent());
        finish();
    } // onCancelButtonClick()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        final String methodName = "onCreate";
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_edit);
        this.dbHelper = new DbHelper(this);
        Bundle extras = getIntent().getExtras();
        if (extras != null) {
            this.watchlistId = extras.getLong(INTENT_EXTRA_WATCHLIST_ID);
            EditText nameEditText = (EditText) findViewById(R.id.nameEditText);
            if (this.watchlistId == DbHelper.NewItemId) {
                // Create mode
                nameEditText.setText("");
                setTitle("Create Watchlist");
            } 
        }
        refreshSecuritiesList(this.watchlistId);
    } // onCreate()

    private void refreshSecuritiesList(long watchListId) {
        final String methodName = "showStockList";
        SimpleCursorAdapter adapter;
        // Connect securities list to cursor
        String[] fromColumns = { SecurityContract.Security.SYMBOL };
        int[] toViews = { android.R.id.text1 };
        Cursor securitiesCursor = this.dbHelper.getAllSecuritiesAndMarkIfInWatchlist(watchListId);
        int flags =0;
        adapter = new SimpleCursorAdapter(this,
                android.R.layout.simple_list_item_multiple_choice,
                securitiesCursor, fromColumns, toViews, flags);
        ListView securitiesToIncludeListView
                = (ListView) findViewById(R.id.securitiesToIncludeListView);
        TextView emptyTextView = (TextView) findViewById(R.id.emptyTextView);
        securitiesToIncludeListView.setEmptyView(emptyTextView);
        securitiesToIncludeListView.setAdapter(adapter);
        // Mark securities that are included in this watchlist
        int isInWatchListIncludedPosition = securitiesCursor
                .getColumnIndex(DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS);
        for (int i = 0; i < securitiesCursor.getCount(); i++) {
            securitiesCursor.moveToPosition(i);
            Log.v(CLASS_NAME, String.format("%s(): securitiesCursor[%d].%s = %d",
                    methodName, i, DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS,
                    securitiesCursor.getInt(isInWatchListIncludedPosition)));
            if (securitiesCursor.getInt(isInWatchListIncludedPosition) == 1) {
                securitiesToIncludeListView.setItemChecked(i, true);
            }
        }
    } // refreshSecuritiesList()
} // class WatchlistEditActivity

WatchlistEditActivity.onCreate() checks the watchlist ID from the intent’s extras. If it’s the special one for a new watchlist it empties the Name TextView and sets the screen’s title appropriately. Finally it calls refreshSecuritiesList() which updates securitiesListView with a cursor containing all the securities from the database and marks those securities with DbHelper.IS_SECURITY_IN_WATCHLIST_ALIAS == 1 (see below). Of course the cursor cannot be closed as it has been passed over to the SimpleCursorAdapter.

Finally DbHelper’s required extensions:

public class DbHelper extends SQLiteOpenHelper {
    public final static long NEW_ITEM_ID = -1L;
    // Alias for generated columns of getAllSecuritiesAndMarkIfInWatchlist() and readAllWatchlists()
    public final static String IS_SECURITY_IN_WATCHLIST_ALIAS = "isSecurityInWatchlist";
    // ...

    /**
     * Gets a list of all securities with those in the specified watchlist marked,
     * ordered by symbol
     *
     * @param idOfWatchlistToMark
     *            If a stock is included in this watchlist,
     *            is_in_watchlist_included will be 1, otherwise 0
     * @return A list (_id, is_included_in_watchlist, symbol) of all securities
     * with those in the specified watchlist marked, ordered by symbol
     */
    public Cursor getAllSecuritiesAndMarkIfInWatchlist(long idOfWatchlistToMark) {
        final String methodName = "getAllSecuritiesAndMarkIfInWatchlist";
        Cursor cursor = null;
        Log.v(CLASS_NAME, String.format("%s(): idOfWatchlistToMark = %d",
                methodName, idOfWatchlistToMark));
        SQLiteDatabase db = this.getReadableDatabase();
        String sql = "SELECT tmp._id AS "
                + Security.ID
                + ", tmp.symbol AS "
                + Security.SYMBOL
                + ", q.name AS "
                + Quote.NAME
                + ", MAX(tmp.isInWatchList) AS "
                + IS_SECURITY_IN_WATCHLIST_ALIAS
                + "\nFROM ("
                + "\n\tSELECT " + Security.ID + ", " + Security.SYMBOL + ", 1 AS isInWatchList"
                + "\n\tFROM " + Security.TABLE + " s"
                + "\n\t\tLEFT JOIN " + SecuritiesInWatchlists.TABLE + " siwl ON "
                + SecuritiesInWatchlists.SECURITY_ID + " = " + Security.ID
                + "\n\tWHERE siwl." + SecuritiesInWatchlists.WATCHLIST_ID + " = ?"
                + "\n\tUNION ALL"
                + "\n\tSELECT " + Security.ID + ", " + Security.SYMBOL + ", 0 AS isInWatchList"
                + "\n\tFROM " + Security.TABLE + " s"
                + "\n) AS tmp"
                + "\n\tLEFT OUTER JOIN " + Quote.TABLE + " q ON q." + Quote.SECURITY_ID + " = tmp._id"
                + "\nGROUP BY tmp._id, tmp.symbol, " + Quote.NAME
                + "\nORDER BY tmp.symbol ASC";
        String[] selectionArgs = new String[] { String.valueOf(idOfWatchlistToMark) };
        logSql(methodName, sql, selectionArgs);
        cursor = db.rawQuery(sql, selectionArgs);
        Log.v(CLASS_NAME, String.format(
                CURSOR_COUNT_FORMAT, methodName, cursor.getCount()));
        return cursor;
    } // getAllSecuritiesAndMarkIfInWatchlist()

    // ...
}

This is the SQL generated by DbHelper.getAllSecuritiesAndMarkIfInWatchlist():

 SELECT tmp._id AS _id, tmp.symbol AS symbol, q.name AS name, MAX(tmp.isInWatchlist) AS isSecurityInWatchlist
 FROM (
	SELECT _id, symbol, 1 AS isInWatchlist
	FROM security s
		LEFT JOIN securities_in_watchlists siwl ON security_id = _id
	WHERE siwl.watchlist_id = -1
	UNION ALL
	SELECT _id, symbol, 0 AS isInWatchlist
	FROM security s
 ) AS tmp
	LEFT OUTER JOIN quote q ON q.security_id = tmp._id
 GROUP BY tmp._id, tmp.symbol, name
 ORDER BY tmp.symbol ASC

The SQL contains an outer and 2 inner SELECTs. The first inner SELECT lists all the securities that are in the watchlist – none in this case as this is a new watchlist – and sets isInWatchlist to 1 for them. The second inner SELECT lists all the securities no matter if they are included in any watchlist and sets isInWatchlist to 0 for them.

Both lists of securities are then combined into a new temporary list by a UNION. The temporary list will have two items for each security in the watchlist – one with isInWatchlist == 0 and one with isInWatchlist == 1. By selecting only the one with MAX(tmp.isInWatchlist) into the final list it will contain a single item for each security and those in the watchlist will have isInWatchlist == 1.

New Edit Watchlist screen

New Edit Watchlist screen

Run the app again:

  1. Open the Manage Watchlists screen
  2. Tap New:
    1. The new WatchlistEditActivity shows up listing all securities and ready to receive a name
    2. Again no menu but a distinct title
  3. Tap Cancel – tapping OK will crash the app
  4. Optional: commit changes

Note that that I closed the automatically displayed on-screen keyboard so it doesn’t obstruct the activity’s screen.

For now watchlist management is all form but no function. Time to make it operational.

Next post: DbTradeAlert for Android: Add Security and Watchlist Management – Part 2

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Schedule Quote Updates – Part 3

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Schedule Quote Updates – Part 2


2. Provide Signals so They Reach a User Even With His Phone in the Pocket

Currently signals only show up in the respective security’s reports. To actually alert the user DDbTradeAlert will use Android’s notification infrastructure like for example mail apps do.

Again this can be broken down to separate steps:

  1. Create the SQL to find triggered signals
  2. Build and send notifications

2.1. Create the SQL to Find Triggered Signals

Finding the triggered signals actually needs two queries: one to find the securities’ max prices and one to find the triggered signals. DbHelper’s updateSecurityMaxPrice() generates this SQL:

SELECT s._id, max_price, max_price_date, s.symbol, days_high, SUBSTR(last_price_date_time, 0, 11)
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE COALESCE(max_price, 0) < COALESCE(days_high, 0)
	AND COALESCE(max_price_date, '') < SUBSTR(last_price_date_time, 0, 11)

This finds all securities where Quote.DAYS_HIGH is greater than Security.MAX_PRICE. As a sanity check Security.MAX_PRICE_DATE must also be before Quote.LAST_PRICE_DATE_TIME.

SUBSTR(last_price_date_time, 0, 11) returns the date part from last_price_date_time. COALESCE(max_price, 0) returns max_price if that isn’t NULL and otherwise 0 – remember that the DB may contain NULL for any field except LAST_PRICE and _ID.

If any securities to update are found their MAX_PRICE and MAX_PRICE_DATE fields are updated by using Security.ID as a key and of course in a transaction. This has to be done in a separate step because SQLite doesn’t support JOINs in UPDATEs.

The SQL used by DbHelper.readAllTriggeredSignals() to find triggered signals is a bit longer:

/* Lower target signal */ 
 SELECT 'low' AS actual_name, days_low AS actual_value, 'L' AS signal_name, lower_target AS target_value, s.symbol
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		lower_target IS NOT NULL
		AND days_low IS NOT NULL
		AND days_low <= lower_target ) 
 UNION ALL /* Upper target signal */ 
 SELECT 'high' AS actual_name, days_high AS actual_value, 'U' AS signal_name, upper_target AS target_value, s.symbol 
 FROM security s
 	LEFT JOIN quote q ON q.security_id = s._id 
 WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		upper_target IS NOT NULL
		AND days_high IS NOT NULL
		AND days_high >= upper_target
	) 
 UNION ALL /* Trailing stop loss signal */
 SELECT 'low' AS actual_name, days_low AS actual_value, 'T' AS signal_name, max_price * (100 - trailing_target) / 100 AS target_value, s.symbol
 FROM security s
	LEFT JOIN quote q ON q.security_id = s._id
 WHERE (last_price_date_time >= date('now','-1 day'))
	AND (
		trailing_target IS NOT NULL
		AND days_low IS NOT NULL
		AND days_low <= max_price * (100 - trailing_target) / 100
	) 
 ORDER BY s.symbol ASC

This consists of a UNION of 3 queries, each finding a type (lower, trailing, upper) of triggered signal (you’d only have to provide field names in the 1st query). Each query first checks if the quotes are up to date, then if the respective values are not NULL, and finally if the target was hit.

2.2. Build and Send Notifications

Notifications require an icon so you’ll need to add one before building them. Android Studio doesn’t provide anything suitable this time but the SDK does at:
C:\Users\\AppData\Local\Android\sdk\platforms\android-23\data\res\

Like the Refresh button’s icon this one has to be usable on various devices so you need to copy “emo_im_money_mouth.png” four times to the respective subfolder in your project. In my case they are below:
C:\Users\\Documents\AndroidStudioProjects\DbTradeAlert\app\src\main\res\

And the source folders are:

  • drawable-hdpi
  • drawable-mdpi
  • drawable-xhdpi
  • drawable-xxhdpi

Android Studio will pick the new icons up immediately – awesome!

After that sendNotificationForTriggeredSignals() can build and send the notifications:

public class QuoteRefresherService extends IntentService {
    // ...

    private String buildNotificationLineFromCursor(Cursor cursor) {
        String result = null;
        String actualName = cursor.getString(0);
        float actualValue = cursor.getFloat(1);
        String signalName = cursor.getString(2);
        float signalValue = cursor.getFloat(3);
        String symbol = cursor.getString(4);
        result = String.format(Locale.getDefault(),
                "%s(): %s = %01.2f; %s = %01.2f", symbol, actualName,
                actualValue, signalName, signalValue);
        return result;
    } // buildNotificationLineFromCursor()

    @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(INTENT_EXTRA_IS_MANUAL_REFRESH, false);
            if (isManualRefresh || areExchangesOpenNow()) {
                if (isConnected()) {
                        quoteCsv = downloadQuotes(url);
                        DbHelper dbHelper = new DbHelper(this);
                        dbHelper.updateOrCreateQuotes(quoteCsv);
                        // Notify user of triggered signals even if app is sleeping
                        dbHelper.updateSecurityMaxPrice();
                        sendNotificationForTriggeredSignals(dbHelper);
                } else {
                    sendLocalBroadcast(BROADCAST_EXTRA_ERROR + "no Internet!");
                    Log.d(CLASS_NAME, BROADCAST_EXTRA_ERROR + "no Internet!");
                }
                // ...
            } else {
                Log.d(CLASS_NAME,
                    "onHandleIntent(): exchanges closed and not a manual reefresh - skipping alarm");
            }
        } catch (IOException e) {
              // ...
            }
            Log.e(CLASS_NAME, exceptionMessage, e);
        }
    } // onHandleIntent()

    // ...

    private void sendNotificationForTriggeredSignals(DbHelper dbHelper) {
        final String methodName = "sendNotificationForTriggeredSignals";
        Cursor cursor = dbHelper.readAllTriggeredSignals();
        if (cursor.getCount() > 0) {
            Context context = getApplicationContext();
            NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                    .setColor(Color.GREEN)
                    .setDefaults(Notification.DEFAULT_ALL)
                    .setNumber(cursor.getCount())
                    .setSmallIcon(R.drawable.emo_im_money_mouth);
            // Specify which intent to show when user taps notification
            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 (cursor.getCount() == 1) {
                cursor.moveToFirst();
                String s = buildNotificationLineFromCursor(cursor);
                builder.setContentTitle("Target hit").setContentText(s);
                Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
            } else {
                builder.setContentTitle(cursor.getCount() + " Targets hit");
                NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
                builder.setStyle(inboxStyle);
                while (cursor.moveToNext()) {
                    String s = buildNotificationLineFromCursor(cursor);
                    inboxStyle.addLine(s);
                    Log.v(CLASS_NAME, String.format("%s(): Notification = %s", methodName, s));
                }
            }
            // Show notification
            NotificationManager notificationManager = (NotificationManager) context
                    .getSystemService(Context.NOTIFICATION_SERVICE);
            // Show new notification or update pending one
            final int notificationId = 1234;
            notificationManager.notify(notificationId, builder.build());
        }
        Log.d(CLASS_NAME,
                String.format("%s(): created %d notifications", methodName, cursor.getCount()));
        DbHelper.closeCursor(cursor);
    } // sendNotificationForTriggeredSignals()
}

After reading triggered signals the code first sets up a NotificationCompat.Builder object specifying the newly added icon with a circle of Win color behind it. Notification.DEFAULT_ALL will cause the device to use default sound, vibration and notification lights. And the amount of notifications will be visible separately.

The code then sets the intent to show when a user taps the notification with setContentIntent(). And it creates the backstack is so the device’s Back button works as expected.

If only one signal was triggered a single line of text created by buildNotificationLineFromCursor() and the title complete the notification.

If multiple signals were triggered the builder’s style will be NotificationCompat.InboxStyle which allows for multiple lines of text. Again buildNotificationLineFromCursor() creates those lines.

Finally a NotificationManager instance fires off the notification. Note that notificationId can be any number but should be unique. It’s used to update a notification with new content.

Everything happens in QuoteRefresherService.onHandleIntent() to inform the user even if his device is asleep.

The screenshots show the initial icon in the top left corner visualizing the notification – the phone will vibrate and sound depending on settings too – and the expanded notification. You see Visual Studio’s emulator because on a real device the USB related notifications would obscure DbTradeAlert.

Notification icon top left

Notification icon top left

Opened notification

Opened notification

The notification shows a trailing stop loss triggered for NOVN.VX. The trailing target is at 92.07 CHF (sample data: 90 % of the stock’s maximum price of 102.30 CHF) and the day’s low was at 79.55 CHF. It’s visible in the watchlist’s report as well (“T” and red background color in signal TextView).

Android Marshmallow added a twist to notifications: users can turn them off. They can do that for each app in Settings | Sound & notification | App notifications and turn off its notifications, prevent notifications from peeking – which DbTradeAlert doesn’t use – or even allow them in “Do not disturb” mode. Each individual app’s Settings screen also got a Notifications item for configuration.

To check if an app is blocked from sending notifications use NotificationManagerCompat.areNotificationsEnabled() and NotificationManagerCompat.getImportance() let’s you know whether your app’s notifications are priorized. Android provides no way to nag the user for enabling notifications or change their priority because the user has already decided to block explicitly this app’s notifications.

If the device uses a pin or pattern lock there is a fourth setting: hide sensitive notification content. In that case you should create different types of notifications by calling NotificationCompat.Builder.setVisibility() with one of the visibility types like NotificationCompat.VISIBILITY_SECRET. DbTradeAlert doesn’t give away any secrets so I didn’t bother with different visibility types.

Next post: Add Security and Watchlist Management

Additional Resources

Posted in Uncategorized | Tagged , | Leave a comment

DbTradeAlert for Android: Schedule Quote Updates – Part 2

First post in this series: Introduction to DbTradeAlert

Previous post: DbTradeAlert for Android: Schedule Quote Updates – Part 1


1.6 Handling Marshmallow’s Doze and App Standby Modes

Now let’s have a look at the addditional requirements Android Marshmallow creates with Doze mode and App Standby mode. They both aim at saving battery power and affect all apps runnning on Marshmallow irrespective of their target SDK.

Because there is enough documentation – and speculation – on the Internet I’ll skip the theory. Fast facts:

  • Both modes defer regular AlarmManager alarms and JobScheduler jobs for inactive devices (Doze) and apps (App Standby)
  • Alarms created by setAndAllowWhileIdle() or setExactAndAllowWhileIdle() – methods added with Marshmallow – continue to go off
  • Both modes take away network access and wake locks from affected apps
  • Users can exempt an app from most of both modes’ restrictions in Settings | Battery | Overflow | Battery optimization
  • Users can manually toggle an app between active and standby in Settings | Developer Options | Inactive Apps

Let’s see how those battery optimizations affect DbTradeAlert. First change the schedue to 1 minute:

alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, 1 * 60 * 1000, 1 * 60 * 1000, pendingIntent);

Then create the new schedule by deleting the app from the device before running it from Android Studio. Its logcat window should then show a message for the schedule creation followed by:

... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
... ...
... D/QuoteRefresherService: downloadQuotes(): got 485 characters
... ...
... D/DbHelper: updateOrCreateQuotes(): success!
... D/QuoteRefresherService: onHandleIntent(): quotes updated - initiating screen refresh
... D/BroadcastReceiver: quotesRefreshedBroadcastReceiver triggered UI update

Those entries repeat about every minute. After a while the screen shuts off resulting in:
… D/WatchlistListActivity: onPause(): quoteRefresherMessageReceiver unregistered

From then on quotesRefreshedBroadcastReceiver doesn’t run anymore but the app still receives quote refresh alarms about once a minute, gets data from the internet, and writes to and reads from the database.

Now let’s see how the app behaves in Doze mode. To force the device into Doze mode execute this line in Android Studio’s Terminal window:

C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle force-idle

The answer should be “Now forced in to idle mode”. If the answer is “Unable to go idle; not enabled” execute this line:

C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle enable

The answer should be “Idle mode enabled”. Now you can force the device into Doze mode.

After that switch to the logcat window. On my device the alarms still reach the app every minute (they shouldn’t, more on this later) but QuoteRefresherService reports an exception like this:

java.net.SocketTimeoutException: failed to connect to download.finance.yahoo.com/66.196.66.213 (port 80) after 15000ms

That means the app has no network access when the device is in Doze mode.

To fix that exempt the app from battery optimization:

  1. On the device go to Settings | Battery | Overflow | Battery optimization
  2. Select All Apps
  3. Find and tap DbTradeAlert
  4. In the dialog select “Don’t optimize” and tap “Finish”

After that go back to the logcat window. The app can load quotes from the internet again.

You can check the device’s state via this command:

C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle > %TEMP%\dumpsys_deviceidle1.txt

Open “dumpsys_deviceidle1.txt” (path on Windows 8.1 is “C:\Users\<AccountName>\AppData\Local\Temp\”). Near the bottom will be a line like this: “mState=IDLE”

To get the device back to normal just turn the screen on. If you then create the file again like above the line will read “mState=ACTIVE”.

Of course your users won’t know that they need to exempt DbTradeAlert from battery optimization. Here is how to ask them for it:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...

    @SuppressWarnings("NewApi")
    private void ensureExemptionFromBatteryOptimizations() {
        if (Utils.isAndroidBeforeMarshmallow() == false) {
            String packageName = getPackageName();
            PowerManager powerManager = getSystemService(PowerManager.class);
            if (powerManager.isIgnoringBatteryOptimizations(packageName) == false) {
                String explanation = "DbTradeAlert needs to download quotes even when in background!";
                Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
                Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
                        .setData(Uri.parse("package:" + packageName));
                startActivity(intent);
            }
        }
    } // ensureExemptionFromBatteryOptimizations()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_watchlist_list);

        this.dbHelper = new DbHelper(this);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        // Set up the ViewPager with the watchlist adapter.
        WatchlistListPagerAdapter watchlistListPagerAdapter
                = new WatchlistListPagerAdapter(getSupportFragmentManager(), dbHelper);
        mViewPager = (ViewPager) findViewById(R.id.container);
        mViewPager.setAdapter(watchlistListPagerAdapter);
        // Request user to whitelist app from Doze and App Standby
        ensureExemptionFromBatteryOptimizations();
        // Create initial quote refresh schedule (just overwrite existing ones)
        Log.d(CLASS_NAME, "onCreate(): creating quote refresh schedule");
        createQuoteRefreshSchedule();
    } // onCreate()

    // ...
}
package de.dbremes.dbtradealert;

import android.database.Cursor;
import android.os.Build;

public class Utils {
    public static  boolean isAndroidBeforeMarshmallow() {
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M;
    } // isAndroidBeforeMarshmallow()

    // ...
} // class Utils
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.dbremes.dbtradealert">

    <!-- other uses-permission elements -->
    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

    <!-- application element -->

</manifest>

WatchlistListActivity.ensureExemptionFromBatteryOptimizations() shows a Toast explaining the app’s needs while asking for exemption from battery optimization. Asking for exemption from battery optimization requires android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS but the Toast is optional.

Without the “@SuppressWarnings(“NewApi”)” annotation Android Studio would complain that the overload of Context.getSystemService() taking a class and PowerManager.isIgnoringBatteryOptimizations() require API level 23.

Be aware that quite a few apps got banned from the Play Store for asking users to exempt them from battery optimization – or maybe even for just asking for android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS in the manifest. Be sure to check Google’s “Acceptable Use Cases for Whitelisting” before you implement this.

Asking for exemption

Asking for exemption

That said first delete the app from the device. Only then run it from Android Studio so onCreate() gets trigggered. When the app starts you’ll see a dialog and an explaining Toast. Tap “Yes” to give DbTradeAlert network access even when the device is in Doze mode or the app is in App Standby mode.

Optionally check if the app is now exempt from battery optimization:
C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys deviceidle > %TEMP%\dumpsys_deviceidle2.txt

Under “Whitelist user apps:” you should see “de.dbremes.dbtradealert”.

Surprisingly the alarms kept going off every minute even when my device was in Doze mode. It turns out that my Moto G 2nd Gen. doesn’t support the new battery optimization even though it runs Marshmallow and the guys from Cyanogen Mod got it working for that device. My experience with emulators – both Google’s and Visual Studio’s – was even worse. So here is what should work even on a device that properly supports Doze and App Standby:

package de.dbremes.dbtradealert;

import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.SystemClock;
import android.util.Log;

public class QuoteRefreshScheduler extends BroadcastReceiver {
    final static String CLASS_NAME = "QuoteRefreshScheduler";

    @Override
    @SuppressWarnings("NewApi")
    public void onReceive(Context context, Intent intent) {
        // Create schedule for quote refresh
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        int requestCode = 0;
        Intent newIntent = new Intent(context, QuoteRefreshAlarmReceiver.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode,
                newIntent, PendingIntent.FLAG_UPDATE_CURRENT);
        // Create schedule for quote refresh (every hour, starting 1 hour from now)
        if (Utils.isAndroidBeforeMarshmallow()) {
                alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
                        AlarmManager.INTERVAL_HOUR, pendingIntent);
            } else {
                alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR, pendingIntent);
        }
        // Use only for testing Doze and App Standby modes!
//        alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
//                1 * 60 * 1000, 1 * 60 * 1000, pendingIntent);
        // Log what was done
        String scheduleCreationType = "";
        if (intent.getAction() != null) {
            if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
                scheduleCreationType = "after reboot";
            } else {
                scheduleCreationType = "by " + intent.getAction();
            }
        }
        Log.d(CLASS_NAME,
                "onReceive(): quote refresh schedule created " + scheduleCreationType);
    } // onReceive()
} // class QuoteRefreshScheduler

AlarmManager.setAndAllowWhileIdle() cannot create repeating alarms. If you want to see how the result of “adb shell dumpsys alarm” reflects this:

  1. Reinstall the app now in Android Marshmallow or a later version and take a quick snapshot of its alarms – repeatInterval will be 0
  2. Wait for the alarm to go off and take another snapshot – the alarm definition is gone and only the statistics part is left
package de.dbremes.dbtradealert;

import android.content.Context;
import android.content.Intent;
import android.support.v4.content.WakefulBroadcastReceiver;
import android.util.Log;

public class QuoteRefreshAlarmReceiver extends WakefulBroadcastReceiver {
    // Logging tag can have at most 23 characters
    final static String CLASS_NAME = "QuoteRefreshAlarmRec.";

    @Override
    public void onReceive(Context context, Intent intent) {
        scheduleNextQuoteRefresh(context);
        Log.d(CLASS_NAME,
                "onReceive(): quote refresh alarm received; starting QuoteRefresherService");
        Intent service = new Intent(context, QuoteRefresherService.class);
        startWakefulService(context, service);
    } // onReceive()

    private void scheduleNextQuoteRefresh(Context context) {
        // Starting with Android Marshmallow only
        // AlarmManager.setAndAllowWhileIdle() works in Doze / App Standby mode
        // and unlike setInexactRepeating() cannot set repeating alarms. So apps
        // need to set the next alarm themselves each time an alarm goes off.
        if (Utils.isAndroidBeforeMarshmallow() == false) {
            Log.d(CLASS_NAME,
                    "scheduleNextQuoteRefresh(): ");
            Intent intent = new Intent(context, QuoteRefreshScheduler.class);
            intent.setAction("QuoteRefreshAlarmReceiver.scheduleNextQuoteRefresh()");
            context.sendBroadcast(intent);
        }
    } // scheduleNextQuoteRefresh()

} // class QuoteRefreshAlarmReceiver

QuoteRefreshAlarmReceiver simply tells QuoteRefreshScheduler to create another alarm. For logging purposes it also sets an action and WatchlistListActivity.createQuoteRefreshSchedule() will do this too.

As I have no device supporting battery optimization properly I didn’t bother to test DbTradeAlert with App Standby. And hey: It works on my machine! 🙂

Next post: DbTradeAlert for Android: Schedule Quote Updates – Part 3

Additional Resources

  • Why No Doze for Moto G 2014: http://forum.xda-developers.com/moto-g-2014/help/insomnia-doze-t3262507
  • Optimizing for Doze and App Standby (contains Google’s “Acceptable Use Cases for Whitelisting”): https://developer.android.com/training/monitoring-device-state/doze-standby.html
  • Behavior Changes with Android N: https://developer.android.com/preview/behavior-changes.html
  • Diving Into Android ‘M’ Doze: https://newcircle.com/s/post/1739/2015/06/12/diving-into-android-m-doze
  • When do you absolutely need WakefulBroadcastReceiver: http://porcupineprogrammer.blogspot.de/2014/02/when-do-you-absolutely-need.html
  • Posted in Uncategorized | Tagged , | Leave a comment

    DbTradeAlert for Android: Schedule Quote Updates – Part 1

    First post in this series: Introduction to DbTradeAlert

    Previous post: Update Quotes


    As its name implies DbTradeAlert wants to alert the user when a target has been reached. For that DbTradeAlert needs to:

    • Constantly monitor targets
    • Provide signals so they reach a user even with his phone in the pocket

    1. Constantly Monitor Targets

    1.1 Some Theory

    To be able to constantly monitor targets the app needs to constantly update quotes which by now only happens when the user taps Refresh. For several reasons “constantly” doesn’t mean real-time though:

    • The app’s target group of I-check-my-portfolio-every-weekend-and-that’s-enough people certainly doesn’t need it
    • Yahoo Finance doesn’t provide real-time data for most markets; for example while Frankfurt Stock Exchange reports real-time all other German stock exchanges’ reports are delayed by 15 minutes and Swiss Exchange’s reports are even delayed by 30 minutes
    • Real-time information is useless without real-time action which DbTradeAlert doesn’t provide
    • A phone doesn’t provide the availability which relying on real-time quotes would require
    • Constantly downloading quotes can get expensive without an unlimited data plan

    For those reasons DbTradeAlert will only update quotes once per hour. Of course this needs to happen even if the app isn’t active or the device is asleep. A short rundown of the classes that can run tasks periodically in regard to these requirements:

    • AlarmManager:
      • Can start an app and even wake up the device
      • Schedule gets lost when device reboots
    • GcmNetworkManager: replaced by Firebase JobDispatcher
    • Firebase JobDispatcher: backport of JobScheduler that may or may not qualify – found it too late and on Android Marshmallow and later it won’t do what DbTradeAlert needs (see Doze and App Standby below)
      • Works with pre-Lollipop Android
      • Requires Google Play Services
      • Schedule survives reboots but not Google Play Services update
    • JobScheduler works like AlarmManager and additionally:
      • Jobs can be restricted to conditions like connectivity
      • Schedule survives reboots but not Google Play Services update
      • Requires API 21 / Lollipop
    • ScheduledExecutorService: only works as long as the app runs
    • TimerTask: only works as long as the app runs

    While it’s possible to use a service to get around the requirement of a running app it would waste memory and battery life – Android pauses apps for a reason. And while my Moto G2 runs Marshmallow most developers will want their apps to be able to run on Pre-Lollipop Android so AlarmManager wins.

    With Doze mode and App Standby mode Android Marshmallow adds another twist to scheduling tasks. I’ll skip dealing with this here both to make things easier to follow and provide readers interested in Marshmallow requirements with a coherent solution later.

    Scheduling quote updates will be a huge change so let’s see what’s ahead:

    1. To create an hourly schedule DbTradeAlert needs:
      1. to register a new class extending BroadcastReceiver as a receiver of the BOOT_COMPLETED action to recreate the schedule after each reboot
      2. to create the schedule for AlarmManager in this new class’ onReceive() handler
      3. to send a broadcast from WatchlistListActivity.onCreate() to the new class to initially create the schedule
    2. The AlarmManager needs to start a quote refresh. But as there is no more user interaction involved the device could fall asleep at any time. A special pattern is advised to prevent that:
      1. AlarmManager sends a broadcast; the process is guaranteed to stay alive until the receiver’s onReceive() method returns
      2. The work can’t be done in onReceive() itself as it would block the UI because it runs on the main thread
      3. To stop the device from falling asleep before the work is finished the app needs to hold a wakelock for that time
      4. To make using the wakelock easier the WakefulBroadcastReceiver exists; this class requires the work to be done by an IntentService which in contrast to normal services runs on a worker thread
    3. It makes no sense to download quotes when the respective exchanges are closed. Therefore the IntentService will check – for now hard-coded – opening hours of exchanges when the refresh wasn’t started manually. For that the app needs:
      1. the IntentService being able to differentiate between both instantiations

    This requires the following changes to DbTradeAlert:

    1. Replace QuoteRefresherAsyncTask with a new class deriving from IntentService
    2. Create a new class extending WakefulBroadcastReceiver that instantiates the new IntentService when receiving the broadcast from AlarmManager and holds a wakelock until the IntentService tells it to release that lock
    3. Replace the existing start of QuoteRefresherAsyncTask by an instantiation of the new IntentService specifying that this is a manual refresh

    Let’s start with the replacement of QuoteRefresherAsyncTask.

    1.2 Create QuoteRefresherService

    As it will be started by a WakefulBroadcastReceiver QuoteRefresher’s super class had to be changed from AsyncTask to IntentService. Accordingly QuoteRefresherAsyncTask and its file were renamed to QuoteRefresherService.

    public class QuoteRefresherService extends IntentService {
        private static final String CLASS_NAME = "QuoteRefresherService";
        public static final String BROADCAST_ACTION_NAME = "QuoteRefresherAction";
        public static final String BROADCAST_EXTRA_ERROR = "Error: ";
        public static final String BROADCAST_EXTRA_NAME = "Message";
        public static final String BROADCAST_EXTRA_REFRESH_COMPLETED = "Refresh completed";
        public static final String INTENT_EXTRA_IS_MANUAL_REFRESH = "isManualRefresh";
        private static final String exceptionMessage = "Exception caught";
        // ...
        
        private boolean areExchangesOpenNow() {
            final String methodName = "areExchangesOpenNow";
            boolean result = false;
            Calendar now = Calendar.getInstance();
            int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
            if (hourOfDay >= 9 && hourOfDay <= 18) {
                int dayOfWeek = now.get(Calendar.DAY_OF_WEEK);
                if (dayOfWeek != Calendar.SATURDAY && dayOfWeek != Calendar.SUNDAY) {
                    result = true;
                } else {
                    Log.d(CLASS_NAME, String.format(
                            "%s(): Exchanges closed on weekends (day = %d)",
                            methodName, dayOfWeek));
                }
            } else {
                Log.d(CLASS_NAME, String.format(
                        "%s(): Exchanges closed after hours (hour = %d)",
                        methodName, hourOfDay));
            }
            if (result) {
                Log.d(CLASS_NAME, String.format(
                        "%s(): Exchanges open", methodName));
            }
            return result;
        }// areExchangesOpenNow()
    
        @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(INTENT_EXTRA_IS_MANUAL_REFRESH, false);
                if (isManualRefresh || areExchangesOpenNow()) {
                    if (isConnected()) {
                        quoteCsv = downloadQuotes(url);
                        DbHelper dbHelper = new DbHelper(this);
                        dbHelper.updateOrCreateQuotes(quoteCsv);
                    } else {
                        sendLocalBroadcast(BROADCAST_EXTRA_ERROR + "no Internet!");
                        Log.d(CLASS_NAME, BROADCAST_EXTRA_ERROR + "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(BROADCAST_EXTRA_ERROR + "broken Internet connection!");
                    Log.d(CLASS_NAME, BROADCAST_EXTRA_ERROR + "broken Internet connection!");
                }
                // TODO: cannot rethrow in else case as that doesn't match overridden methods signature?
            }
            finally {
                QuoteRefreshAlarmReceiver.completeWakefulIntent(intent);
            }
        } // onHandleIntent()
    
        // ...
    }
    

    All the work is done in onHandleIntent() which keeps the code from doInBackground() and just adds some error checking and reporting. Losing the ability to pass a Context isn’t a problem because IntentService is indirectly derived from Context. But the intent parameter’s extras come in handy to transport isManualRefresh.

    As already noted there is no way to avoid waking up the device without requiring API 21 – the app can only avoid to start the radio now. onHandleIntent() therefore checks for isManualRefresh and areExchangesOpenNow().

    After completing work the super class’ completeWakefulIntent() is called to signal the WakefulBroadcastReceiver to release the wakelock – no onPostExecute() needed.

    1.3 Start QuoteRefresherService

    The manual start of QuoteRefresherAsyncTask is changed to instantiate QuoteRefresherService and set isManualRefresh:

    public class WatchlistListActivity extends AppCompatActivity
            implements WatchlistFragment.OnListFragmentInteractionListener {
        // ...
    
        @Override
        public boolean onOptionsItemSelected(MenuItem item) {
            // Handle action bar item clicks here. The action bar will
            // automatically handle clicks on the Home/Up button, so long
            // as you specify a parent activity in AndroidManifest.xml.
            int id = item.getItemId();
            switch (id) {
                case R.id.action_refresh: {
                    boolean addTimestamp = false;
                    updateTitle(addTimestamp);
                    Context context = getApplicationContext();
                    Intent service = new Intent(context, QuoteRefresherService.class);
                    service.putExtra(QuoteRefresherService.INTENT_EXTRA_IS_MANUAL_REFRESH, true);
                    startService(service);
                    return true;
                }
                case R.id.action_settings: {
                    return true;
                }
                default:
                    return super.onOptionsItemSelected(item);
            }
        }
    
        // ...
    }
    

    As a service QuoteRefresherService has to be declared in AndroidManifest.xml – see line 14 in the excerpt below:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.dbremes.dbtradealert">
    
        <!-- other uses-permission elements ... -->
        <uses-permission android:name="android.permission.WAKE_LOCK" />
    
        <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
    
            <!-- activity element ... -->
    
            <receiver android:name=".QuoteRefreshAlarmReceiver" >
            </receiver>
    
            <service android:name=".QuoteRefresherService" />
        </application>
    </manifest>
    

    1.4 Create QuoteRefreshAlarmReceiver

    Now to the new WakefulBroadcastReceiver. QuoteRefreshAlarmReceiver is the new class that receives broadcasts from AlarmManager to start QuoteRefresherService:

    package de.dbremes.dbtradealert;
    
    import android.content.Context;
    import android.content.Intent;
    import android.support.v4.content.WakefulBroadcastReceiver;
    import android.util.Log;
    
    public class QuoteRefreshAlarmReceiver extends WakefulBroadcastReceiver {
        // Logging tag can have at most 23 characters
        final static String CLASS_NAME = "QuoteRefreshAlarmRec.";
    
        @Override
        public void onReceive(Context context, Intent intent) {
            Log.d(CLASS_NAME,
                    "onReceive(): quote refresh alarm received; starting QuoteRefresherService");
            Intent service = new Intent(context, QuoteRefresherService.class);
            startWakefulService(context, service);
        }
    }
    

    It just creates the appropriate intent and passes it to the super class’ startWakefulService(). As a WakefulBroadcastReceiver holds a wakelock the appropriate permission has to be declared in AndroidManifest.xml – see line 5 in the excerpt above.

    Any class eligible for receiving broadcasts needs to be registered with Android. As AlarmManager isn’t part of the app it cannot use local broadcasts. And broadcasts will arrive even if the app isn’t alive so the registration has to be permanent. This is achieved by extending AndroidManifest.xml – see lines 11 to 12 in the excerpt above.

    1.5 Create QuoteRefreshScheduler

    QuoteRefreshScheduler is the new class that creates the schedule for AlarmManager:

    package de.dbremes.dbtradealert;
    e(addTimestamp
    import android.app.AlarmManager;
    import android.app.PendingIntent;
    import android.content.BroadcastReceiver;
    import android.content.Context;
    import android.content.Intent;
    import android.util.Log;
    
    public class QuoteRefreshScheduler extends BroadcastReceiver {
        final static String CLASS_NAME = "QuoteRefreshScheduler";
    
        @Override
        public void onReceive(Context context, Intent intent) {
            AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
            int requestCode = 0;
            Intent newIntent = new Intent(context, QuoteRefreshAlarmReceiver.class);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode,
                    newIntent, PendingIntent.FLAG_UPDATE_CURRENT);
                    alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                            SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
                            AlarmManager.INTERVAL_HOUR, pendingIntent);
            // Log what was done
            String scheduleCreationType;
            if (intent.getAction() != null
                    && intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) {
                scheduleCreationType = "after reboot";
            } else {
                scheduleCreationType = "initially";
            }
            Log.d(CLASS_NAME, "onReceive(): quote refresh schedule created " + scheduleCreationType);
        } // onReceive()
    } // class QuoteRefreshScheduler
    

    It uses an explicit intent. That means because the receiver’s class is specified Android will deliver the broadcast only to instances of this class and ignore any intent filters. The intent is then wrapped into an PendingIntent to make it usable from outside the app – for example by AlarmManager. As FLAG_UPDATE_CURRENT is specified any existing schedule for this intent will be overwritten with the new one.

    The last step is to actually create the schedule: setInexactRepeating() allows Android to save energy by batching alarms. So INTERVAL_HOUR results in intervals between 1 and 2 hours which is good enough for the app’s purpose. Note that since API level 19 even setRepeating() works like this and the only difference left is the ability to specify not only predefined intervals.

    Specifying ELAPSED_REALTIME_WAKEUP does two things:

    • It’s based on the time passed since last boot and not on a wall clock like RTC_WAKEUP; this avoids having to deal with things like daylight saving time and timezone changes
    • It wakes up the device if it’s asleep

    Lastly setInexactRepeating()’s second parameter determines at which time to start and the code sets it to now.

    This is the broadcast receiver’s registration:

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.dbremes.dbtradealert">
    
        <!-- other uses-permission elements ... -->
        <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
        <uses-permission android:name="android.permission.WAKE_LOCK" />
    
        <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
    
            <!-- activity element ... -->
    
            <receiver android:name=".QuoteRefreshAlarmReceiver" />
            <receiver android:name=".QuoteRefreshScheduler">
                <intent-filter>
                    <action android:name="android.intent.action.BOOT_COMPLETED" />
                </intent-filter>
            </receiver>
    
            <service android:name=".QuoteRefresherService" />
        </application>
    </manifest>
    

    This not only registers QuoteRefreshScheduler as a broadcast receiver but also makes it receive the BOOT_COMPLETED broadcast so the app can recreate its schedule after each reboot. And finally the app asks for permission to receive the ACTION_BOOT_COMPLETED broadcast from Android.

    Before you run the app take a snapshot of the device’s existing alarms. Snapshots like this will be the only way to check if a new alarm is scheduled as planned because there is no way to programmatically get a list of even your own app’s alarms. And seeing the sheer amount of alarms waiting to go off will also make Google’s decision to batch them more understandable.

    To create a snapshot of the device’s alarms go to Android Studio’s Terminal window and enter this line:
    C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell dumpsys alarm > %TEMP%\dumpsys_alarm1.txt

    This will create the file C:\Users\<AccountName>\AppData\Local\Temp\dumpsys_alarm1.txt (path on Windows 8.1) – more than 3.600 lines for my device. Searching for the package name – “de.dbremes.dbtradealert” in my case – should return no results.

    Now run the app. Android Studio’s logcat window should then contain a line like this:
    “… D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created initially”

    After that take another snapshot of the device’s alarms and name it “dumpsys_alarm2.txt”. Open the file and search for your package name again. You should find the new alarm schedule now which will look like this:

    Batch{78a4fff num=7 start=1527556349 end=1527580188 flgs=0x8}:
    (... more alarms ...)

    ELAPSED_WAKEUP #3: Alarm{63ded1b type 2 when 1526400000 de.dbremes.dbtradealert}
    tag=*walarm*:de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver
    type=2 whenElapsed=+5m45s877ms when=+5m45s877ms
    window=+45m0s0ms repeatInterval=3600000 count=0 flags=0x0
    operation=PendingIntent{47e5eb8: PendingIntentRecord{15f9c8d de.dbremes.dbtradealert broadcastIntent}}
    (... more alarms ...)

    Explanation:

    • Batch with ID 78a4fff has 7 alarms
    • The new alarm is the 3rd alarm in the batch, has ID 63ded1b, is of type ELAPSED_WAKEUP / 2, and was set by package “de.dbremes.dbtradealert”
    • It will go off in 5 minutes, 45 seconds, 877 milliseconds and send a broadcast to “de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver”
    • Its repeat interval is 3.600.000 milliseconds or 1 hour
    • count=0 means the alarm was never skipped

    Near the bottom you’ll additionally find alarm statistics for your package:

    u0a146:de.dbremes.dbtradealert +18ms running, 1 wakeups:
    +18ms 1 wakes 1 alarms, last -15s103ms:
    *walarm*:de.dbremes.dbtradealert/.QuoteRefreshAlarmReceiver

    In this case the app received 1 alarm which did wake up the device if necessary. Processing that alarm took 18 milliseconds and the last alarm happened 15 seconds ago.

    There is no official documentation of the output’s format. And note that force-stopping an app destroys all its alarm schedules.

    So in this case after about 6 minutes the reports should update and the app’s title should be expanded by a timestamp like “DbTradeAlert @ 10:46” – unless the exchanges are closed (remember to correct the timezone if you use an emulator – its timezone defaults to UTC).

    Android Studio’s logcat window should also contain lines like these:
    ... D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created initially
    ... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
    ... D/QuoteRefresherService: areExchangesOpenNow(): Exchanges open
    ... D/QuoteRefresherService: downloadQuotes(): got 487 characters
    ... ...
    ... D/DbHelper: updateOrCreateQuotes(): success!
    ... D/QuoteRefresherService: onHandleIntent(): quotes updated - initiating screen refresh
    ... D/BroadcastReceiver: quotesRefreshedBroadcastReceiver triggered UI update
    ... D/WatchlistListActivity: refreshAllWatchLists(): changed cursor for recyclerView with tag = 1
    ... D/WatchlistListActivity: refreshAllWatchLists(): changed cursor for recyclerView with tag = 2

    If that didn’t work check which log entry is the first one missing. Then check the correspoding class’ declaration and possibly required permission in AndroidManifest.xml – there is usually a missing or wrong entry.

    Once the initial schedule is created and alarms go off as expected: test if the app recreates the schedule on reboots. The easiest test is to send the BOOT_COMPLETED broadcast directly to your app. Beware that sending this simply to all apps may cause havoc and some Nexus devices seem to actually reboot. That said enter this line into Android Studio’s Terminal window:
    C:\Users\<AccountName>\AppData\Local\Android\sdk\platform-tools\adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -c android.intent.category.HOME -n de.dbremes.dbtradealert/.QuoteRefreshScheduler

    The response should look like this:

    Broadcasting: Intent { act=android.intent.action.BOOT_COMPLETED cat=[android.intent.category.HOME] cmp=de.dbremes.dbtradealert/.QuoteRefreshScheduler (has extras) }
    Broadcast completed: result=0

    And Android Studio’s logcat window should show entries like those:
    ... D/QuoteRefreshScheduler: onReceive(): quote refresh schedule created after reboot
    ... D/QuoteRefreshAlarmRec.: onReceive(): quote refresh alarm received; starting QuoteRefresherService
    ... ...

    If recreating the schedule worked you should of course try an actual reboot. And if you use a VCS it’s a good time to check in all those changes now.

    Next post: DbTradeAlert for Android: Schedule Quote Updates – Part 2

    Additional Resources

    Posted in Uncategorized | Tagged , | Leave a comment