DbTradeAlert for Android: Add Settings and Finishing Touches

First post in this series: Introduction to DbTradeAlert

Previous post: Add Reminders


1. Add Settings

Android provides a quite sophisticated infrastructure for app settings – it even creates the settings screen’s layout for you. DbTradeAlert will use 2 settings:

  • Disable automatic download of quotes; shows how to act on settings changes and how to cancel alarms – to save battery or data or to avoid interruptions I would just set the phone in airplane mode.
  • Set the time at which exchanges are considered open; you’ll want to change this setting for example when moving out of your timezone.

1.1. Create a Basic Settings screen

Because a settings screen will basically create itself the app only needs some xml files that define what goes into that screen.

Create a new directory “xml” in the “res” directory. Then add a new file “preferences.xml” to that directory and fill it like this:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <CheckBoxPreference android:key="auto_refresh_preference" android:defaultValue="true" android:summary="Download quotes every hour" android:title="Auto Refresh"/>
</PreferenceScreen>

For now the app’s settings consist of a single checkbox controlling whether it automatically downloads and evaluates quotes.

Create a new Java class “SettingsFragment” and fill it like this:

package de.dbremes.dbtradealert;

import android.os.Bundle;
import android.preference.PreferenceFragment;

public class SettingsFragment extends PreferenceFragment {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preferences);
    } // onCreate()
} // class SettingsFragment

This class just connects itself to the preferences.xml file.

Now create an empty activity “SettingsActivity” and fill it like this:

package de.dbremes.dbtradealert;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

public class SettingsActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment())
                .commit();
    } // onCreate()
} // class SettingsActivity

This class just overwrites its screen contents with SettingsFragment’s. You can delete the automatically created “activity_settings.xml”.

Finally make the Settings screen available from the main screen’s overflow menu:

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.
        Intent intent;
        int id = item.getItemId();
        switch (id) {
        // ...
            case R.id.action_settings: {
                intent = new Intent(this, SettingsActivity.class);
                startActivity(intent);
                return true;
            }
            default:
                return super.onOptionsItemSelected(item);
        }
    } // onOptionsItemSelected()

    // ...
} // class WatchlistListActivity
Basic Settings screen

Basic Settings screen

Check if the Settings screen loads:

  1. Start the app and select “Settings” from its overflow menu
  2. The Settings screen appears with its single checkbox checked

1.2. Act on Settings Changes

When the user disables the automatic download of quotes DbTradeAlert’s alarm needs to be cancelled. The first step for that is to get notified when the setting has changed:

package de.dbremes.dbtradealert;

import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;

public class SettingsActivity extends AppCompatActivity
        implements SharedPreferences.OnSharedPreferenceChangeListener {
    private static final String CLASS_NAME = "SettingsActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment())
                .commit();
    } // onCreate()

    @Override
    protected void onPause() {
        super.onPause();
        PreferenceManager.getDefaultSharedPreferences(this)
                .unregisterOnSharedPreferenceChangeListener(this);
    } // onPause()

    @Override
    protected void onResume() {
        super.onResume();
        PreferenceManager.getDefaultSharedPreferences(this)
                .registerOnSharedPreferenceChangeListener(this);
    } // onResume()

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
        Log.d(CLASS_NAME, "onSharedPreferenceChanged: key = " + key);
        if (key.equals("auto_refresh_preference")) {
            Intent intent = new Intent(this, QuoteRefreshScheduler.class);
            sendBroadcast(intent);
        }
    } // onSharedPreferenceChanged()

} // class SettingsActivity

The SettingsActivity registers itself as a listener for preference changed events and in onSharedPreferenceChanged() sends a broadcast to QuoteRefreshScheduler if auto_refresh_preference has changed – that’s exactly how the initial schedule was created in WatchlistListActivity.startQuoteRefreshScheduleCreation().

The next step is to extend QuoteRefreshScheduler to handle disabled auto-refreshs:

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.preference.PreferenceManager;
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) {
        int requestCode = 0;
        Intent newIntent = new Intent(context, QuoteRefreshAlarmReceiver.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, requestCode,
                newIntent, PendingIntent.FLAG_UPDATE_CURRENT);
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        boolean isAutoRefreshEnabled = PreferenceManager
                .getDefaultSharedPreferences(context).getBoolean("auto_refresh_preference", false);
        if (isAutoRefreshEnabled == false) {
            alarmManager.cancel(pendingIntent);
            Log.d(CLASS_NAME, "onReceive(): quote refresh cancelled");
        } else {
            // Create schedule for quote refresh
            if (Utils.isAndroidBeforeMarshmallow()) {
                alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        AlarmManager.INTERVAL_HOUR, AlarmManager.INTERVAL_HOUR, pendingIntent);
            } else {
                alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
                        60 * 60 * 1000, 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
                    && 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

If auto-refresh is disabled QuoteRefreshScheduler cancels the alarm. That works by passing the same PendingIntent that was used to create the alarm.

To test if things work as expected disable auto-refresh. Then either wait an hour to see if the alarm doesn’t go off anymore 🙂 or check the file produced by “adb shell dumpsys alarm …”.

1.3. Integrate Network Usage Related Settings Into Android

Disabling auto-refresh affects network usage. Google recommends adding an intent filter for activities achieving this to provide users direct access to the respective setting from the system’s data usage screen for that app. The intent filter looks like this:

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

    <!-- uses-permission elements -->

    <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
        <!-- activity elements -->
        <activity android:name=".SettingsActivity">
            <intent-filter>
                <action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        <!-- activity elements -->

        <!-- receiver elements -->

        <service android:name=".QuoteRefresherService" />
    </application>

</manifest>
Network usage related settings integrated

Network usage related settings integrated

To try it out:

  1. After installing the app’s new version go to Settings | Apps | DbTradeAlert | Data usage
  2. The screen will show an active App Settings button and tapping it leads to the app’s Settings screen

Without the intent filter that button would be disabled. Users can also go via Settings | Data usage | <AppName> but the app might not be listed if it didn’t use much data.

1.4. Add a Setting for the Exchanges’ Business Days

DbTradeAlert defaults to business days being from Monday to Friday. But for example a lot of countries with predominantly Muslim population have their weekend on Friday and Saturday. So let’s add a setting for this.

Again everything starts with the XML but this time it’s a bit more involved: the set of days to choose from needs to be defined in an additional xml file and the default set of business days as well.

Add a new file “arrays.xml” to the “res/values” directory and fill it like this:

<resources>
    <string-array name="business_days_preference_default_values">
        <item>2</item>
        <item>3</item>
        <item>4</item>
        <item>5</item>
        <item>6</item>
    </string-array>
    <string-array name="business_days_preference_entries">
        <item>Monday</item>
        <item>Tuesday</item>
        <item>Wednesday</item>
        <item>Thursday</item>
        <item>Friday</item>
        <item>Saturday</item>
        <item>Sunday</item>
    </string-array>
    <string-array name="business_days_preference_values">
        <item>2</item>
        <item>3</item>
        <item>4</item>
        <item>5</item>
        <item>6</item>
        <item>7</item>
        <item>1</item>
    </string-array>
</resources>

The “business_days_preference_entries” array defines the values that will be shown to the user and “business_days_preference_values” defines the values that the app will use internally. Note that for Java the first day of the week is Sunday. Finally “business_days_preference_default_values” defines which subset of “business_days_preference_values” will be checked by default.

After that extend preferences.xml with a “MultiSelectListPreference” that makes use of those arrays:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <CheckBoxPreference android:key="auto_refresh_preference" android:defaultValue="true" android:summary="Download quotes every hour" android:title="Auto Refresh"/>
    <MultiSelectListPreference android:key="business_days_preference" android:defaultValue="@array/business_days_preference_default_values" android:entries="@array/business_days_preference_entries" android:entryValues="@array/business_days_preference_values" android:summary="Days on which auto refresh is active" android:title="Business Days" />
</PreferenceScreen>
Setting for business days

Setting for business days

When you start the app and go to Settings there will be a new “Business Days” entry. Tap that and you’ll see the default business days checked.
###(screenshot)

If you really mess up the first time – I did – it may be best to delete the app before trying the fix. That’s because settings are persisted to a file and those wrong settings won’t go away – the file survives cache clearing and deleting the app’s data will also delete the database. On an emulator you can download the file to have a look at it. The command for DbTradeAlert would be:

"adb pull /data/data/de.dbremes.dbtradealert/shared_prefs/de.dbremes.dbtradealert_preferences.xml %TEMP%/de.dbremes.dbtradealert_preferences.xml"

On a Windows 8 machine that command will copy the file to C:\Users\<AccountName>\AppData\Local\Temp\de.dbremes.dbtradealert_preferences.xml.

If the app is debuggable you can also access the file in a backup. Caveat: currently the preferences file will only be generated when you open the settings screen for the first time – until then even the shared_prefs directory doesn’t exist.

With the UI in place updating QuoteRefresherService.areExchangesOpenNow() is next:

public class QuoteRefresherService extends IntentService {
    // ...

    private boolean areExchangesOpenNow() {
        final String methodName = "areExchangesOpenNow";
        boolean result = false;
        Calendar now = Calendar.getInstance();
        boolean isBusinessHour = isBusinessHour(now);
        if (isBusinessHour) {
            result = isBusinessDay(now);
        }
        Log.d(CLASS_NAME, String.format(
                "%s(): Exchanges %sopen", methodName, result ? "" : "not "));
        return result;
    }// areExchangesOpenNow()

    // ...

    private boolean isBusinessDay(Calendar now) {
        boolean result = false;
        int thisDayOfWeek = now.get(Calendar.DAY_OF_WEEK);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessDays = sharedPreferences.getStringSet(
                "business_days_preference", Collections.<String>emptySet());
        if (businessDays != null) {
            Iterator<String> iterator = businessDays.iterator();
            while (iterator.hasNext()) {
                String businessDayString = iterator.next();
                if (Integer.valueOf(businessDayString).equals(thisDayOfWeek)) {
                    result = true;
                    break;
                }
            }
        }
        // Log result details
        String s = String.valueOf(thisDayOfWeek) + (result ? " is" : " is not")
                + " a business day (" + businessDays.toString() + ")";
        Log.v(CLASS_NAME, s);
        return result;
    } // isBusinessDay()

    private boolean isBusinessHour(Calendar now) {
        boolean result = false;
        int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
        if (hourOfDay >= 9 && hourOfDay <= 18) {
            result = true;
        }
        // Log result details
        String s = String.valueOf(hourOfDay) + (result ? " is" : " is not")
                + " in business hours (09 - 18)";
        Log.v(CLASS_NAME, s);
        return result;
    }

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

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

        // Without this the app's preferences will be empty until the user opens
        // its Settings screen for the 1st time
        boolean readAgain = false;
        PreferenceManager.setDefaultValues(this, R.xml.preferences, readAgain);

        // ...
    }

    // ...
}

As you saw the app settings automatically initialize from their default values when a user opens the settings screen. But now the app probably accesses its settings before that happens. For that reason PreferenceManager.setDefaultValues() is called. Note that in contrast to some Internet knowledge readAgain does what it says – control the settings cache. The user’s changes will never be overwritten.

Other than that it’s the same logic as before with just a little refactoring. But instead of checking hard-coded values isBusinessDay() now checks business_days_preference’s value. This can only be accessed as an unordered set of strings and there is nothing like isValueInSet(). The Log output looks like this:
... V/QuoteRefresherService: 5 is a business day ([5, 4, 3, 6, 2])

When testing this on Visual Studio’s emulator I found that Java’s Calendar.get(Calendar.HOUR_OF_DAY) returns the hour in 12h format. Even switching to 24h format in the emulator’s settings didn’t change that. Well, works on my phone :-).

1.5. Add a Setting for the Exchanges’ Business Hours

The orignal idea to set the business hours was to use some specialized control so the user can easily select a timespan. But Android doesn’t provide an appropriate control and creating one would be a project on its own. The next best thing would be using two SeekBars but that still has the problem of not preventing the user from entering a starting business hour that is after his ending business hour. Using EditTextPreferences would be even worse in that regard. So DbTradeAlert’s users are presented with a 24 entries in a MultiSelectListPreference – yuck! To make up for that DbTradeAlert will get user friendly summaries like “Hours on which auto refresh is active (09 – 18)” later.

The first step is to extend arrays.xml and preferences.xml which will lead to a settings screen for business hours like this:

Settings screen for business hours

Settings screen for business hours

After that QuoteRefresherService.isBusinessHour() needs to use the new setting:

public class QuoteRefresherService extends IntentService {
    // ...

    private boolean areExchangesOpenNow() {
        final String methodName = "areExchangesOpenNow";
        boolean result = false;
        Calendar now = Calendar.getInstance();
        boolean isBusinessHour = isBusinessHour(now);
        if (isBusinessHour) {
            result = isBusinessDay(now);
        }
        Log.d(CLASS_NAME, String.format(
                "%s(): Exchanges %sopen", methodName, result ? "" : "not "));
        return result;
    }// areExchangesOpenNow()

    // ...

    private boolean isBusinessDay(Calendar now) {
        boolean result = false;
        int thisDayOfWeek = now.get(Calendar.DAY_OF_WEEK);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessDays = sharedPreferences.getStringSet(
                "business_days_preference", Collections.<String>emptySet());
        if (businessDays != null) {
            Utils.BusinessTimesPreferenceExtremes
                    btpe = Utils.getBusinessTimesPreferenceExtremes(businessDays);
            result = (btpe.getFirstBusinessTime() <= thisDayOfWeek && btpe.getLastBusinessTime() >= thisDayOfWeek);
            // Log result details
            String s = String.valueOf(thisDayOfWeek) + (result ? " is" : " is not")
                    + String.format(" in business days (%d - %d)",
                    btpe.getFirstBusinessTime(),
                    btpe.getLastBusinessTime()
            );
            Log.v(CLASS_NAME, s);
        } else {
            Log.e(CLASS_NAME, "business_days_preference not found");
        }
        return result;
    } // isBusinessDay()

    private boolean isBusinessHour(Calendar now) {
        boolean result = false;
        int hourOfDay = now.get(Calendar.HOUR_OF_DAY);
        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        Set businessHours = sharedPreferences.getStringSet(
                "business_hours_preference", Collections.<String>emptySet());
        if (businessHours != null) {
            Utils.BusinessTimesPreferenceExtremes
                    btpe = Utils.getBusinessTimesPreferenceExtremes(businessHours);
            result = (btpe.getFirstBusinessTime() <= hourOfDay && btpe.getLastBusinessTime() >= hourOfDay);
            // Log result details
            String s = String.valueOf(hourOfDay) + (result ? " is" : " is not")
                    + String.format(" in business hours (%02d - %02d)",
                    btpe.getFirstBusinessTime(),
                    btpe.getLastBusinessTime()
            );
            Log.v(CLASS_NAME, s);
        } else {
            Log.e(CLASS_NAME, "business_hours_preference not found");
        }
        return result;
    } // isBusinessHour()

    // ...
}
public class Utils {
    // ...

    /**
     * getBusinessTimesPreferenceExtremes() returns
     * - the first and last business day of the week (for business_days_preference)
     * or
     * - the first and last business hour of the day (for business_hours_preference)
     * @param businessTimesSet must not be null
     */
    public static BusinessTimesPreferenceExtremes getBusinessTimesPreferenceExtremes(
            Set businessTimesSet) {
        ArrayList<String> businessTimesArray = new ArrayList<String>(businessTimesSet);
        Collections.sort(businessTimesArray);
        String firstBusinessTime = businessTimesArray.get(0);
        String lastBusinessTime = businessTimesArray.get(businessTimesArray.size() - 1);
        return new BusinessTimesPreferenceExtremes(
                Integer.valueOf(firstBusinessTime),
                Integer.valueOf(lastBusinessTime));
    } // getBusinessTimesPreferenceExtremes()

    // ...
    /**
     * BusinessTimesPreferenceExtremes holds
     * - the first and last business day of the week (for business_days_preference)
     * or
     * - the first and last business hour of the day (for business_hours_preference)
     */
    public static final class BusinessTimesPreferenceExtremes {
        private final Integer firstBusinessTime;
        private final Integer lastBusinessTime;

        public BusinessTimesPreferenceExtremes(
                Integer firstBusinessTime, Integer lastBusinessTime) {
            this.firstBusinessTime = firstBusinessTime;
            this.lastBusinessTime = lastBusinessTime;
        }

        public Integer getFirstBusinessTime() {
            return firstBusinessTime;
        }

        public Integer getLastBusinessTime() {
            return lastBusinessTime;
        }
    } // class BusinessTimesPreferenceExtremes
} // class Utils

The Set of businessHours is transformed into an ArrayList which provides a sort() method allowing easy access to the minimum and maximum values. With some refactoring isBusinessDay() profits from that, too. And it enables a more readable Log output:
... V/QuoteRefresherService: 18 is in business hours (09 - 18)

This also changed the business logic for isBusinessDay() a bit and assumes continuous business weeks now. That’s inline with the logic for business hours now. And I don’t see a use case for discontinuous business days / hours – allowing users to specify discontinuous business days / hours is actually a limitation of the UI.

1.6. Create User Friendly Summaries for Settings

As noted in the previous section I need to make good for that horrible 24-items list: users will not just see “Days on which auto refresh is active” but also the actual value “(Mon – Fri)” in the business days preference and a similar text in the business hours preference.

Most of the code was already created in the previous section: Utils.getBusinessTimesPreferenceExtremes() determines the first and the last business day / hour. The BusinessTimesPreferenceExtremes class just holds the 2 Integers.

The SettingsActivity is responsible for creating the preference summaries:

public class SettingsActivity extends AppCompatActivity
        implements SharedPreferences.OnSharedPreferenceChangeListener {
    // ...

    private void setBusinessTimesPreferenceSummary(String businessTimesPreferenceKey) {
        SettingsFragment settingsFragment
                = (SettingsFragment) getFragmentManager().findFragmentByTag(SETTINGS_FRAGMENT_TAG);
        MultiSelectListPreference businessTimesPreference
                = (MultiSelectListPreference) settingsFragment
                .findPreference(businessTimesPreferenceKey);
        Set businessDays = businessTimesPreference.getValues();
        Utils.BusinessTimesPreferenceExtremes
                btpe = Utils.getBusinessTimesPreferenceExtremes(businessDays);
        if (businessTimesPreferenceKey.equals(BUSINESS_DAYS_PREFERENCE_KEY)) {
            String[] shortDayNames = DateFormatSymbols.getInstance(Locale.US).getShortWeekdays();
            businessTimesPreference.setSummary(
                    String.format("Days on which auto refresh is active (%s - %s)",
                            shortDayNames[btpe.getFirstBusinessTime()],
                            shortDayNames[btpe.getLastBusinessTime()]));
        } else {
            businessTimesPreference.setSummary(
                    String.format("Hours on which auto refresh is active (%02d - %02d)",
                            btpe.getFirstBusinessTime(), btpe.getLastBusinessTime()));
        }
    } // setBusinessTimesPreferenceSummary()

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getFragmentManager().beginTransaction()
                .replace(android.R.id.content, new SettingsFragment(), SETTINGS_FRAGMENT_TAG)
                .commit();
        // Without this findFragmentByTag() would return null!
        getFragmentManager().executePendingTransactions();
        setBusinessTimesPreferenceSummary(BUSINESS_DAYS_PREFERENCE_KEY);
        setBusinessTimesPreferenceSummary(BUSINESS_HOURS_PREFERENCE_KEY);
    } // onCreate()

    // ...

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
        Log.d(CLASS_NAME, "onSharedPreferenceChanged: key = " + key);
        if (key.equals("auto_refresh_preference")) {
            Intent intent = new Intent(this, QuoteRefreshScheduler.class);
            sendBroadcast(intent);
        }
        setBusinessTimesPreferenceSummary(BUSINESS_DAYS_PREFERENCE_KEY);
        setBusinessTimesPreferenceSummary(BUSINESS_HOURS_PREFERENCE_KEY);
    } // onSharedPreferenceChanged()

} // class SettingsActivity

All the work is done in setBusinessTimesPreferenceSummary():

  • Find the SettingsFragment
  • Find the MultiSelectListPreference for business day / hour
  • Get the Set with its values
  • Let Utils.BusinessTimesPreferenceExtremes() find the first and last business day / hour from those values
  • If the method was called to set the business day summary it shows the day’s short names – fixed to US English like everything the UI
  • Otherwise it just shows the values formatted to 2 digits

The method is first called in onCreate() to set the initial preference summaries. It’s important to call FragmentManager.executePendingTransactions() before that or findFragmentByTag() will not find anything. Another necessary addition is to specify a tag when placing SettingsFragment into the activity because setBusinessTimesPreferenceSummary() finds the fragment by its tag.

After a business time related preference changed setBusinessTimesPreferenceSummary() of course needs to be called again.

Summaries now show the actual values

Summaries now show the actual values

When you start the app now and go to the Settings screen you’ll see preference values nicely integrated into the summaries.

Feature complete! Start polishing.

2. Finishing Touches

2.1. Add an icon

The app is still missing an icon. Its notifications got a nice one so let’s use that:

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

    <!-- uses-permission elements -->

    <application android:allowBackup="true" android:icon="@drawable/emo_im_money_mouth" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppTheme">
        <!-- ... -->
    </application>

</manifest>

As the icon was already included in the project I just needed to add a reference to it. If you use a new icon remember to copy it even if it’s one of the built-in icons because these are version dependend.

2.2 Format Timestamp in Report Properly

Reports currently show timestamps like “2016-07-14T15:29”. That’s unambiguous but not what most users expect. So let’s just show date and time in the short format that corresponds to the device’s region:

public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // ...

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int reportPosition) {
        // ...
        // LastPriceDateTimeTextView
        int columnIndex = cursor.getColumnIndex(QuoteContract.Quote.LAST_PRICE_DATE_TIME);
        String s = Utils.getDateTimeStringFromDbDateTime(cursor, columnIndex, true);
        viewHolder.LastPriceDateTimeTextView.setText(s);
        if (isLastTradeOlderThanOneDay) {
            viewHolder.LastPriceDateTimeTextView.setBackgroundResource(R.color.colorWarn);
        } else {
            viewHolder.LastPriceDateTimeTextView
                    .setBackgroundColor(android.R.attr.editTextBackground);
        }
        // ...
    }

    // ...
}

Utils.getDateTimeStringFromDbDateTime() already existed. Please note that from Android 5 on there is a bug where DateFormat.getDateTimeInstance() sometimes returns time in 12h format instead of 24h format. It somehow starts to ignore the device’s setting for that. To make getDateTimeInstance() work again just switch the setting (Settings | Date & time | Use 24-hour format) to 12h and back to 24h.

2.3 Make Edit Security Screen Accessible from Reports

Users should be able to open the Edit Security screen by long-tapping a report – for example to read the note. Most of the code is already in place – it just needs to be connected.

Most of the missing part goes into WatchlistListActivity:

public class WatchlistListActivity extends AppCompatActivity
        implements WatchlistFragment.OnListFragmentInteractionListener {
    // ...
    private static final int SECURITY_EDIT_REQUEST = 3;
    private static final int WATCHLISTS_MANAGEMENT_REQUEST = 4;
    // ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        final String methodName = "onActivityResult";
        switch (requestCode) {
            case REMINDERS_MANAGEMENT_REQUEST:
                // Nothing to do
                break;
            case WATCHLISTS_MANAGEMENT_REQUEST:
                // Even if user tapped Cancel in Manage Watchlists screen he may have OK'd
                // changes in Edit Watchlist screen
                watchlistListPagerAdapter.notifyDataSetChanged();
                break;
            case SECURITIES_MANAGEMENT_REQUEST:
            case SECURITY_EDIT_REQUEST:
                if (resultCode == RESULT_OK) {
                    refreshAllWatchlists();
                }
                break;
            default:
                Log.e(CLASS_NAME, String.format("%s(): unexpected requestCode = %d",
                        methodName, requestCode));
                break;
        }
        super.onActivityResult(requestCode, resultCode, data);
    } // onActivityResult()

    // ...

    @Override
    public void onListFragmentInteraction(String symbol) {
        Intent intent = new Intent(this, SecurityEditActivity.class);
        long securityId = dbHelper.getSecurityIdFromSymbol(symbol);
        intent.putExtra(SecurityEditActivity.SECURITY_ID_INTENT_EXTRA, securityId);
        startActivityForResult(intent, SECURITY_EDIT_REQUEST);
    } // onListFragmentInteraction()

    // ...
}

WatchlistListActivity.onListFragmentInteraction() creates an Intent to call the Edit Security screen and additionally passes the security’s Id. When the user closes the Edit Security screen onActivityResult() is called and handles SECURITY_EDIT_REQUEST just like SECURITIES_MANAGEMENT_REQUEST: all the watchlists are reloaded because a security may be shown in more than one of them.

DbHelper.getSecurityIdFromSymbol() existed as a private method that expected a SQLiteDatabase parameter. Because DbHelper’s clients don’t have to deal with databases I wrapped that method in a public one without the parameter. The public method just obtains a database with getReadableDatabase(), calls the private method and returns its result.

In case you forgot why the symbol is onListFragmentInteraction()’s parameter – I did:

/**
 * A fragment representing a list of Items.
 *

 * Activities containing this fragment MUST implement the {@link OnListFragmentInteractionListener}
 * interface.
 */
public class WatchlistFragment extends Fragment {

    // ...

    /**
     * This interface must be implemented by activities that contain this
     * fragment to allow an interaction in this fragment to be communicated
     * to the activity and potentially other fragments contained in that
     * activity.
     * <p/>
     * See the Android Training lesson <a href= * "http://developer.android.com/training/basics/fragments/communicating.html" * >Communicating with Other Fragments</a> for more information.
     */
    public interface OnListFragmentInteractionListener {
        void onListFragmentInteraction(String symbol);
    }
}
public class WatchlistRecyclerViewAdapter
        extends RecyclerView.Adapter<WatchlistRecyclerViewAdapter.ViewHolder> {
    // ...

    @Override
    public void onBindViewHolder(final ViewHolder viewHolder, int reportPosition) {
    // ...
        // setOnClickListener()
        viewHolder.View.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (listener != null) {
                    // Notify the active callbacks interface (the activity, if the
                    // fragment is attached to one) that an item has been selected.
                    listener.onListFragmentInteraction(viewHolder.Symbol);
                }
            }
        });
    }

    // ...

    @Override
    public void onListFragmentInteraction(String symbol) {
        Intent intent = new Intent(this, SecurityEditActivity.class);
        long securityId = dbHelper.getSecurityIdFromSymbol(symbol);
        intent.putExtra(SecurityEditActivity.SECURITY_ID_INTENT_EXTRA, securityId);
        startActivityForResult(intent, SECURITY_EDIT_REQUEST);
    } // onListFragmentInteraction()

    // ...
}

WatchlistFragment defines an OnListFragmentInteractionListener interface and WatchlistRecyclerViewAdapter.onBindViewHolder() passes viewHolder.Symbol to the onListFragmentInteraction method of that interface.

2.4 Add Help

As the post about using DbTradeAlert shows the app’s users need some help.

The original idea was to show GitHub’s  Readme.md page in the app but that had several drawbacks:

  • Doesn’t work offline
  • It seems one cannot force GitHub to serve the mobile view and the default view adds way too much packaging
  • Not all users will be OK with showing content from the Internet in an app
Help screen

Help screen

The solution was to create a static HTML page from that content and put it in the app’s assets folder:

  1. In Android Studio’s project view open the app item’s context menu and select New | Folder | Assets Folder
  2. In the Configure Component window click Finish
  3. Create the Help.html page and put it into the new folder

The next step is to add an empty activity named HelpActivity to the app and fill it with a WebView control.

Then the app’s menu needs a new Help item and clicking that needs to be processed in WatchlistListActivity.onOptionsItemSelected() by starting the HelpActivity which looks like this:

package de.dbremes.dbtradealert;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.webkit.WebView;

public class HelpActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_help);
        WebView webView = (WebView) findViewById(R.id.webView);
        webView.loadUrl("file:///android_asset/Help.html");
    } // onCreate()
} // class HelpActivity

HelpActivity.onCreate() simply loads the static file into the WebView control.

The drawback of course is having to edit 3 files when something usage related changes.

2.5 Features that didn’t make it

As always there are more features possible. Some which I debated and rejected or deferred:

  • Widget on the home screen: the app’s target group doesn’t want or need a feature that promotes permanent attention.
  • Show missing functionality: users can stop DbTradeAlert from sending notifications or fail to excempt it from battery optimization. Because that stops the app from working it should inform the user that there is a problem. Maybe I’ll add that later.
  • Animations: while animations when changing between screens are a nice touch they do not really fit the app’s style and purpose.
  • Night mode: Android can automatically switch apps to night mode – usually a darker theme – like your SatNav does in a tunnel. Nice but not needed.
  • Backup: if the user opted into Google’s backup service for his account DbTradeAlert’s data will be backed up by default on devices running Android Marshmallow and beyond. I still have to test that though.
    Being able to export the SQLite database to a PC for editing with SQLite Browser would also be nice but turned out to be tricky – work in progress.
  • Data Binding: support for data binding was anounced at Google I/O 2015 and quietly rolled out in the year’s fall. Data binding should get rid of a lot of boilerplate code and I’ll definitely try it.

Next post: Add Backup

Additional Resources

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

Leave a Reply

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

WordPress.com Logo

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

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s