Wednesday, December 23, 2015

My Notes: Strict Mode

I finally decided to enable strict mode in my app.  Strict mode is a great tool to catch performance problems early in development.   It very configurable so when it detects an issue, it can either crash the app, add log entry or show a dialog on the screen.

I highly recommend it for development to prevent catching potential issues late in the game, but it is development feature that will negatively affect that user experience if it accidentally escapes into the wild.   That is can be touchy subject for some developers.  

If you do use strict mode, I highly recommend that you turn it off in the release build.  The code will still exist in the code base, but it shouldn’t affect the user.   It will only appear in the debug builds.

I always add my strict mode block to a custom Application class.  In this case it goes into MyNotesApplication.  I created a new method, startStrictModeForDebuging, that contains all of the logic for initializing strict mode and only turning it on in the debug build,  I call this method in the applications onCreate method.

@Override
    public void onCreate() {
        super.onCreate();
        sApplicationContext = getApplicationContext();
        startStrictModeForDebugging();
        prepareDatabase(this);
    }


    /**
     * Start strict mode only when the app is built in debug mode.
     */
    private void startStrictModeForDebugging() {
            String packageName = Strings.nullToEmpty(sApplicationContext.getPackageName());
            if (packageName.endsWith(".debug")) {
                StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
                        .detectAll()
                        .penaltyLog()
                        .penaltyDialog()
                        .build());

                StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll()
                        .penaltyLog()
                        .build());
            }
    }

I set up my strict mode to report violations to the log. Using the log instead of a dialog or crash requires more diligence on my part since I have to scan the log for problems instead of appearing as a slap-in-the-face notification.

I prefer this approach because it is less intrusive. I will break the rules when I am experimenting with features and I don’t want nuisance issues to get in the way. Reconfiguring strict mode for those times isn’t a big deal, but I also have to remember to undo the changes, which I tend to forget to do.

The other part of this code is the check for the debug build. I quick check on StackOverflow will give you several ways to achieve this, but I prefer to change the package name in debug mode. I can then check the package name to determine if the running build is the debug build or the release build.

This also has the added benefit that I can have the released version of my app installed side-by-side with the development version. That can make testing a lot nicer.

To do this requires a change in the gradle build file.

buildTypes {
        debug {
            applicationIdSuffix ".debug"
        }

        release {
            ...
        }
    }

The one line addition of the applicationIdSuffix takes care of the package name change between the modes.  All it does is append “.debug” to default package name.  I can then look for that suffix in the code to programmatically detect the build type.

Friday, December 18, 2015

I'm Back!

Recovering from surgery is hard work and I forgot how exhausting it is.   The crutches were brutal to my hands and I needed to minimize the typing to only what was needed for my day job.    Basically, something had to give so I took a hiatus from my extracurricular activities.

I am almost completely back on my feet and recharging my batteries during my winter vacation. That means that I can devote some free time to the blog again.  Yeah!!


Realistically,  I could release the app as is, but I do want to improve the user experience on tablets. So that will be the focus of my next round of changes.

Wednesday, October 28, 2015

Tips & Tricks: Dangerous Permissions, Part 2

This entry is going to focus on what happens when a User revokes a permission while the app is backgrounded.  

The user can change a permission status at anytime by going to the settings app=>apps=>pick any app=>Permissions.

If the user grants a permission this way then nothing special happens.  Your app (if it is up) just keeps chugging along.

The real interesting stuff happens when the user revokes a permission for your app.  If the app isn’t running, then nothing happens.  If the app is running, then it will fully re-create.  This means that the old application is destroyed.  It will take down all of its Services and all static data is lost.  It will then start a new version of the app.   This will call the Application objects onCreate.  

The really fun problem comes in when the user foregrounds your app again.  The system will restore to its last known point using any saved bundles.

This can leave your app in a weird state: where it has restarted, but also restored to a state without any breadcrumbs.  As one of my coworkers put it: “It’s like a game where you dies and regenerate right in front of the Boss’s door without any of the goodies you built up along the way”.  Pretty apt way to describe it.

For most apps, this isn’t a big deal because they automatically create the database, etc. as part of app start.  However, it is important to understand to avoid painting yourself into a corner data wise.

I included an demonstration of this in my Example using counters.  I maintain a static counter and a local counter.

   1:  
   2:     /**
   3:      * The static counter.  This is here to show that static data resets when revoking a permission
   4:      * via the settings app.
   5:      */
   6:     private static int sStaticCounter;
   7:  
   8:  
   9:     /**
  10:      * a local counter.  This is here to show that activity restores when revoking a permission via
  11:      * the settings app.
  12:      */
  13:     private int mCounter;
  14:  

  Each time the Toolbar’s “Click Me” is clicked I increment both counters.



   1:  
   2:  
   3:     @Override
   4:     public boolean onOptionsItemSelected(MenuItem item) {
   5:         int id = item.getItemId();
   6:  
   7:         //noinspection SimplifiableIfStatement
   8:         if (id == R.id.action_settings) {
   9:             ++mCounter;
  10:             ++sStaticCounter;
  11:  
  12:             updateCounterUi();
  13:             requestPermissions();
  14:             return true;
  15:         }
  16:  
  17:         return super.onOptionsItemSelected(item);
  18:     }

The value of the counters is shown in the UI:


device-2015-10-10-211138


The local counter value is saved into save state bundle in onSaveInstance().  This value is then restored in the activity’s onCreate method.


So here is the recreation:



  • Start the app, notice that both counters are 0.

start



  • Click the “Click Me”, notice that both counters are 1.

device-2015-10-10-211138



  • Click the “Click Me” in the Snackbar. 
  • Grant the permission.
  • Background the app
  • Start the Settings=> App=> PermissionsExample=>Permissions.
  • Notice that the “Contacts” permission is granted
  • Turn off the “Contacts” permission.

    • At this point, the app recreates

  • Foreground the PermissionsExample app.  Notice that the static data is reset, but the data from the savedInstance bundle restored correctly.

device-2015-10-10-212749


A word of Advice


Regardless of whether a permission is not dangerous, I always check the permission status under the app settings.  This is because some normal permissions are grouped under an umbrella permission that can be revoked.   If any permissions shows up then you have to be prepared for the above scenario to occur. 


For instance in the example, I am using the GET_ACCOUNTS permissions which is technically normal*.  However, it is grouped under the “Contacts” group, which can be revoked. That means that I have to deal with the above situations even though the specific permission I am using cannot be revoked.


*There is an issue reported to Google to make GET_ACCOUNTS dangerous.  As of this writing the change is implemented, but not delivered.


Example


The example can be found in my GitHub PermissionsExample repository.

Wednesday, October 21, 2015

Tips & Tricks: Dangerous Permissions, Part 1

The biggest new feature of Marshmallow is the permissions model change.  A permission is now either dangerous or normal.  Normal permissions behave just like the old permissions.  They are automatically granted at app download and cannot revoked by the User.

Dangerous permissions are the really interesting ones because the user must explicitly grant the app that permission at run-time.   The user can also revoke the permission anytime via the device settings app.

Even if the permission is normal, it can be classed into a grouping that is revocable.  This doesn’t mean that the user can deny your specific permission, but it will cause your app to recreate if the user revokes it in the background.

Basics

If you don’t have permission for a feature, then attempting to use that feature will cause an exception.  To avoid that you need to do the following each time you want to use the feature protected by the permission:

Permission Model - New Page

Obviously it look a little different in code so here is my step-by-step implementation.

Checking Permissions:

This all done in a custom requestPermissions method of the MainActiviity:

   1: private void requestPermissions() {
   2:  
   3:         //use the static ActivityCompat methods if the minimum app level is less than 23.
   4:         if (ActivityCompat.checkSelfPermission(this, permission.GET_ACCOUNTS) == PackageManager.PERMISSION_DENIED) {
   5:  
   6:             //The permission is denied, so ask the OS if we should show a rationale that explains
   7:             //why the permission is needed.
   8:  
   9:             if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission.GET_ACCOUNTS)) {
  10:                 showRationale();
  11:             }
  12:             else {
  13:                 requestAccountsPermission();
  14:             }
  15:         }
  16:         else {
  17:             Toast.makeText(this, "Activity: Permission Already Granted!", Toast.LENGTH_LONG).show();
  18:         }
  19:     }

This method uses the ActivityCompat static methods to check the permissions.  Using those methods eliminates the pesky logic breaks for handling Marshmallow vs pre-Marshmallow devices. 


Basically,  it calls checkSelfPermission to determine if the desired permission is granted.  If it is then is just shows a toast.  If then permission is denied, then it needs to determine whether to show a rationale for the permission to the user.  This is done via the shouldShowRequestPermissionRationale method.  This will normally return true only if the user has denied the permission before.  You are not obligated to show the rationale, but it is a really good idea to give the user an explanation for why they should grant the permission.


How and what to show in the rationale is completely up to you.  In the example, I use a Snackbar.  When the user clicks on the Snackbar’s “Click Me” I request the OS to display the Permission prompt.  This is done in the requestAccountsPermission method.



   1: private void requestAccountsPermission() {
   2:      //use the static ActivityCompat methods if the minimum app level is less than 23.
   3:      ActivityCompat.requestPermissions(MainActivity.this, PERMISSIONS, PERMISSION_REQUEST_CODE);
   4:  }

This method calls the ActivityCompat’s requestPermissions method.  This method takes a string array of the permissions to request and a code to identify the request.



   1:  
   2:     private static final int PERMISSION_REQUEST_CODE = 1;
   3:  
   4:     private static final String PERMISSIONS[] = {permission.GET_ACCOUNTS};

Since the user prompt for the permissions is handled by the OS, the activity will get the response when its onRequestPermissionsResult method is called.



   1:  
   2:     @Override
   3:     public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
   4:         super.onRequestPermissionsResult(requestCode, permissions, grantResults);
   5:  
   6:         if (requestCode == PERMISSION_REQUEST_CODE) {
   7:             if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
   8:                 Toast.makeText(this, "Activity: Permission Granted!", Toast.LENGTH_LONG).show();
   9:             }
  10:             else {
  11:                 Toast.makeText(this, "Activity: Permission Denied!", Toast.LENGTH_LONG).show();
  12:             }
  13:         }
  14:  
  15:     }

From here you can proceed with the feature if the permission is granted or discontinue if the permission is still denied.


Fragment Quirk


Fragments can follow the same process for requesting permissions, but be careful cannot using ActivityCompat methods. If the ActivityCompat requestPermissions method is called, then the Fragments onRequestPermissionsResult will not be called. Ever.   Instead,  use the built-in requestPermissions method only.


Example


The example can be found in my GitHub PermissionsExample repository.

Wednesday, October 14, 2015

MyNotes: Marshmallow

 

I really didn’t have to do much for marshmallow. I did notice that I was using the WRITE_EXTERNAL_STORAGE permission unecessarily so I removed it.   Then, I up-leved my gradle files and ran the app on marshmallow.  

Since I don’t have any permissions, I don’t have to worry about the on-demand permission issues. You have no idea how happy I am about that because it can be a real PIA to retrofit an app for them. I did create an example project to demonstrate how to deal with permissions, but that is a different post.

That leave only  Doze mode and the Auto Backup to deal with.  Neither is a big deal.  I tested the Auto Backup with the instructions found here.  Testing Doze Mode was a little tougher because the instructions didn’t really work.   I included my steps below.

In either case, my app tested without any glitches so I am claiming victory for Marshmallow.

Detour: Testing Doze Mode

The documentation for testing doze mode is a little sparse, but here is the procedure I used to test it.

Enabling Doze Mode:

  1. Start an “M” emulator
  2. Install your app on the device
  3. Start a terminal
    1. adb shell dumpys deviceidle enable
    2. adb shell dumpsys battery unplug
      • (emulator screen turns off)
    3. adb shell dumpsys deviceidle step
      • output is IDLE_PENDING
    4. adb shell dumpsys deviceidle step
      • output is SENSING
    5. adb shell dumpsys deviceidle step
      • output is IDLE
    6. adb shell dumpsys deviceidle step
      • output is IDLE_MAINTENANCE

Disabling Dose Mode:

  1. In a terminal:
    1. adb shell dumpys deviceidle disable
    2. adb shell dumpsys battery ac
  2. Go to the emulator and press escape
    • The screen turns back on

Commits

The changes can be found at https://github.com/fsk-software/mynotes/commit/75bd19218d46e23066e067b9506895ab9a56359f.

Wednesday, October 7, 2015

MyNotes: Goodbye Guava

 

I have noticed a few odd build failures lately that were not due to syntax errors.  These were the java.exe failing with error code 2.  This is a big red flag that there is too  much going on library wise.   The root of the problem is that android has issues when an app has more than 65k methods.  This includes all of the libraries that get built into the app.

Guava is a huge monolithic library.  And I brought it into both my common library module and the mynotes module.  In addition, it looks like ButterKnife also brings in a Guava version.  That would be three compilations of a very large library for  very small app.

I tried doing some selective behavior with gradle to minimize the library impact, but none of them work really well and made the files very messy.  I also thought about enabling multidexing, which would also fix the problem, but that seemed ridiculous to do for a tiny app.

So that left me with analyzing whether I really needed Guava.  The short answer is no.  I am only using two classes: Preconditions and Strings.  And I wasn’t even using the majority of the methods in those classes.

As a result, I took out Guava and created my own Preconditions and Strings classes.  I kept the method and class names the same, but spun my own implementations.  I stuck those classes in the Common Library for now.

Ta Da:  weird build problems magically go away.  The moral of the story: be careful bringing in libraries unless you really need them.

Commits

The changes can be found at https://github.com/fsk-software/mynotes/commit/75bd19218d46e23066e067b9506895ab9a56359f.

Wednesday, September 30, 2015

My Notes: Observer Pattern

Android provides the components to implement the Observer pattern.  For those unfamiliar with the pattern it is a way to notify observers when something changes in the observable object.  This is actually a great thing to use when monitoring data and works really well in conjunction with Loaders. 

The important classes are named Observer and Observable. The item being observed extends the Observable class.  Whenever observed data is changed, the Observable calls the setChanged method.  It can then call notifyObservers to notify any registered observers.  It is very important that the setChanged method be called first, otherwise the observers are never notified about a change in the data.

The Observers will implement the Observer interface.  This requires the class to implement the update method, which is called in response to a changed the observable object.

For me the obvious case for the pattern is the Note class.  I want to modify the UI in response to changes in the note color, text, or saved status.  The easiest way to do this is to turn the Note objects into Observables. 

   1: package com.fsk.mynotes.data;
   2:  
   3:  
   4: import android.content.ContentValues;
   5: import android.database.sqlite.SQLiteDatabase;
   6: import android.os.Parcel;
   7: import android.os.Parcelable;
   8: import android.support.annotation.NonNull;
   9: import android.util.Log;
  10:  
  11: import com.fsk.common.database.DatabaseStorable;
  12: import com.fsk.common.threads.ThreadUtils;
  13: import com.fsk.mynotes.constants.NoteColor;
  14: import com.fsk.mynotes.data.database.MyNotesDatabaseModel.Columns;
  15: import com.fsk.mynotes.data.database.MyNotesDatabaseModel.Tables;
  16: import com.google.common.base.Preconditions;
  17:  
  18: import java.util.Observable;
  19:  
  20:  
  21: /**
  22:  * The Note data model.
  23:  */
  24: public class Note extends Observable implements Parcelable, DatabaseStorable {
  25:  
  26:     /**
  27:      * Standard Creator Pattern
  28:      */
  29:     public static final Parcelable.Creator<Note> CREATOR = new Parcelable.Creator<Note>() {
  30:         @Override
  31:         public Note createFromParcel(final Parcel source) {
  32:             return new Note(source);
  33:         }
  34:  
  35:  
  36:         @Override
  37:         public Note[] newArray(final int size) {
  38:             return new Note[size];
  39:         }
  40:     };
  41:  
  42:  
  43:     /**
  44:      * The id for notes that are not stored in the database.
  45:      */
  46:     public static final int NOT_STORED = NoteAttributes.UNKNOWN;
  47:  
  48:  
  49:     /**
  50:      * The current data for the note.
  51:      */
  52:     private NoteAttributes mCurrentData;
  53:  
  54:  
  55:     /**
  56:      * The last persisted data for the note
  57:      */
  58:     private NoteAttributes mOriginalData;
  59:  
  60:  
  61:     /**
  62:      * Constructor
  63:      *
  64:      * @param startingData
  65:      *         the initial starting data for the note.
  66:      *
  67:      * @throws CloneNotSupportedException
  68:      *         when the starting data cannot be cloned.
  69:      */
  70:     Note(@NonNull NoteAttributes startingData) throws CloneNotSupportedException {
  71:         Preconditions.checkNotNull(startingData);
  72:  
  73:         mOriginalData = startingData;
  74:         mCurrentData = startingData.clone();
  75:     }
  76:  
  77:  
  78:     /**
  79:      * Constructor.
  80:      *
  81:      * @param source
  82:      *         the parcel containing the data for the note.
  83:      */
  84:     public Note(Parcel source) {
  85:         mOriginalData = source.readParcelable(NoteAttributes.class.getClassLoader());
  86:         mCurrentData = source.readParcelable(NoteAttributes.class.getClassLoader());
  87:     }
  88:  
  89:  
  90:     /**
  91:      * Get the note color.
  92:      *
  93:      * @return The non-null note color.  By default, this returns {@link NoteColor#YELLOW}.
  94:      */
  95:     public NoteColor getColor() {
  96:         return mCurrentData.getColor();
  97:     }
  98:  
  99:  
 100:     /**
 101:      * Set the color for the note.
 102:      *
 103:      * @param color
 104:      *         the color for the note.
 105:      */
 106:     public void setColor(@NonNull NoteColor color) {
 107:         boolean modified = mCurrentData.setColor(color);
 108:         notifyObserversOnChange(modified);
 109:     }
 110:  
 111:  
 112:     /**
 113:      * Get the text for the note.
 114:      *
 115:      * @return the non-null text for the note.
 116:      */
 117:     public String getText() {
 118:         return mCurrentData.getText();
 119:     }
 120:  
 121:  
 122:     /**
 123:      * Set the note text.
 124:      *
 125:      * @param text
 126:      *         the text for the note.  If null, then an empty string is set.
 127:      */
 128:     public void setText(String text) {
 129:         boolean modified = mCurrentData.setText(text);
 130:         notifyObserversOnChange(modified);
 131:     }
 132:  
 133:  
 134:     /**
 135:      * Get the id for the note.
 136:      *
 137:      * @return the id for the note.
 138:      */
 139:     public long getId() {
 140:         return mCurrentData.getId();
 141:     }
 142:  
 143:  
 144:     /**
 145:      * Determine if the Note has been modified without the changes being persisted.
 146:      *
 147:      * @return true if the Note has been modified without the changes being persisted.
 148:      */
 149:     public boolean isDirty() {
 150:         return !mCurrentData.equals(mOriginalData);
 151:     }
 152:  
 153:  
 154:     /**
 155:      * Notify any {@link java.util.Observer}(s) of change to the data.
 156:      *
 157:      * @param changed
 158:      *         true if the data was changed.
 159:      */
 160:     private void notifyObserversOnChange(boolean changed) {
 161:         if (changed) {
 162:             setChanged();
 163:             notifyObservers();
 164:         }
 165:     }
 166:  
 167:  
 168:     @Override
 169:     public int describeContents() {
 170:         return 0;
 171:     }
 172:  
 173:  
 174:     @Override
 175:     public void writeToParcel(final Parcel dest, final int flags) {
 176:         dest.writeParcelable(mOriginalData, flags);
 177:         dest.writeParcelable(mCurrentData, flags);
 178:     }
 179:  
 180:  
 181:     /**
 182:      * Convert the object into a {@link android.content.ContentValues} object for storage in the
 183:      * database.
 184:      *
 185:      * @return The {@link android.content.ContentValues} initialized with the values.  It will
 186:      * contain the following entries:<p> key=>{@link com.fsk.mynotes.data.database
 187:      * .MyNotesDatabaseModel.Columns#NOTE_ID}<br> value=> long<br> description=> The note id.  this
 188:      * only exists when {@link #getId()} is not {@link #NOT_STORED}.
 189:      * <p/>
 190:      * key=>{@link com.fsk.mynotes.data.database.MyNotesDatabaseModel.Columns#NOTE_TEXT}<br>
 191:      * value=>String<br> description=> The note's text.
 192:      * <p/>
 193:      * key=>{@link com.fsk.mynotes.data.database.MyNotesDatabaseModel.Columns#NOTE_COLOR}<br>
 194:      * value=>Integer<br> description=> The note colors ordinal.
 195:      */
 196:     ContentValues createContentValues() {
 197:         ContentValues returnValue = new ContentValues();
 198:         if (getId() != NOT_STORED) {
 199:             returnValue.put(Columns.NOTE_ID, getId());
 200:         }
 201:  
 202:         returnValue.put(Columns.NOTE_TEXT, getText());
 203:         returnValue.put(Columns.NOTE_COLOR, getColor().ordinal());
 204:  
 205:         return returnValue;
 206:     }
 207:  
 208:  
 209:     /**
 210:      * Save the note to the database.
 211:      *
 212:      * @throws com.fsk.common.threads.ThreadException
 213:      *         when call from the UI thread.
 214:      */
 215:     @Override
 216:     public void save(final SQLiteDatabase db) {
 217:         new ThreadUtils().checkOffUIThread();
 218:         long row = db.insertWithOnConflict(Tables.NOTES, null, createContentValues(),
 219:                                            SQLiteDatabase.CONFLICT_REPLACE);
 220:  
 221:         if (row != NOT_STORED) {
 222:             onPersistenceUpdate(row);
 223:         }
 224:     }
 225:  
 226:  
 227:     /**
 228:      * Delete the note from the database.
 229:      *
 230:      * @throws com.fsk.common.threads.ThreadException
 231:      *         when call from the UI thread.
 232:      */
 233:     @Override
 234:     public void delete(final SQLiteDatabase db) {
 235:  
 236:         new ThreadUtils().checkOffUIThread();
 237:  
 238:         if (getId() != NOT_STORED) {
 239:             int deletedRows = db.delete(Tables.NOTES, Columns.NOTE_ID + " = ?",
 240:                                         new String[] { Long.toString(getId()) });
 241:             if (deletedRows > 0) {
 242:                 onPersistenceUpdate(NOT_STORED);
 243:             }
 244:         }
 245:     }
 246:  
 247:  
 248:     /**
 249:      * Reset the {@link #mOriginalData} to the current and update the id to the specified id.
 250:      *
 251:      * @param newId
 252:      *         the new Id for the note.
 253:      */
 254:     private void onPersistenceUpdate(long newId) {
 255:         try {
 256:             mCurrentData.setId(newId);
 257:             boolean dirty = isDirty();
 258:             mOriginalData = mCurrentData.clone();
 259:             notifyObserversOnChange(dirty);
 260:         }
 261:         catch (CloneNotSupportedException e) {
 262:             Log.i("MyNotes", "Something really bad happened");
 263:         }
 264:     }
 265:  
 266:  
 267:     /**
 268:      * The Builder that will create a new {@link Note}.
 269:      */
 270:     public static class Builder {
 271:         /**
 272:          * The starting data for the new {@link Note}.
 273:          */
 274:         private final NoteAttributes mNoteAttributes = new NoteAttributes();
 275:  
 276:  
 277:         /**
 278:          * Set the color for the note.
 279:          *
 280:          * @param color
 281:          *         the color for the note.
 282:          *
 283:          * @return the builder to allow method chaining.
 284:          */
 285:         public Builder setColor(@NonNull NoteColor color) {
 286:             mNoteAttributes.setColor(color);
 287:             return this;
 288:         }
 289:  
 290:  
 291:         /**
 292:          * Set the note text.
 293:          *
 294:          * @param text
 295:          *         the text for the note.  If null, then an empty string is set.
 296:          *
 297:          * @return the builder to allow method chaining.
 298:          */
 299:         public Builder setText(String text) {
 300:             mNoteAttributes.setText(text);
 301:             return this;
 302:         }
 303:  
 304:  
 305:         /**
 306:          * Set the note id.
 307:          *
 308:          * @param id
 309:          *         the id for the note.  This must be {@link #NOT_STORED} or a natural number.
 310:          *
 311:          * @return the builder to allow method chaining.
 312:          */
 313:         public Builder setId(long id) {
 314:             mNoteAttributes.setId(id);
 315:             return this;
 316:         }
 317:  
 318:  
 319:         /**
 320:          * Create a new {@link Note} with the specified data.
 321:          *
 322:          * @return a new {@link Note} with the specified data.
 323:          *
 324:          * @throws CloneNotSupportedException
 325:          *         when the note data cannot be created.
 326:          */
 327:         public Note build() throws CloneNotSupportedException {
 328:             return new Note(mNoteAttributes);
 329:         }
 330:     }
 331: }

I created a method, notifyObserversOfChange that centralizes the setChanged and notifyObservers method calls.  The caller decides if the data has actually changed.   This method is called whenever the text, color, or peristenance status changes.



   1: /**
   2:      * Notify any {@link java.util.Observer}(s) of change to the data.
   3:      *
   4:      * @param changed
   5:      *         true if the data was changed.
   6:      */
   7:     private void notifyObserversOnChange(boolean changed) {
   8:         if (changed) {
   9:             setChanged();
  10:             notifyObservers();
  11:         }
  12:     }

I then setup the FilteredNoteLoader to observe the note data.   That way when any note changes, it can be automatically reflected in the UI list of notes.



   1: public class FilteredNoteLoader extends AsyncTaskLoader<List<Note>> implements Observer {
   2:  
   3:     /**
   4:      * The filtered notes retrieved from persistent storage.
   5:      */
   6:     private List<Note> mNotes;
   7:  
   8:  
   9:     /**
  10:      * The notes manager that retrieves data from persistent storage.
  11:      */
  12:     private final NotesManager mNotesManager;
  13:  
  14:  
  15:     /**
  16:      * The filter manager that retrieves data from persistent storage.
  17:      */
  18:     private final NoteFilterPreferences mNoteFilterPreferences;
  19:  
  20:  
  21:     /**
  22:      * A flag that indicates that the loader is monitoring changes to the notes or note filter.
  23:      */
  24:     private boolean mObserverRegistered;
  25:  
  26:  
  27:     /**
  28:      * The listener to changes in the filtered note colors.
  29:      */
  30:     private SharedPreferences.OnSharedPreferenceChangeListener mNoteFilterPreferenceListener =
  31:             new SharedPreferences.OnSharedPreferenceChangeListener() {
  32:                 @Override
  33:                 public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
  34:                                                       final String key) {
  35:                     onContentChanged();
  36:                 }
  37:             };
  38:  
  39:  
  40:     /**
  41:      * Constructor.
  42:      *
  43:      * @param context
  44:      *         The context to associate with this loader.
  45:      */
  46:     public FilteredNoteLoader(@NonNull Context context) {
  47:         super(context);
  48:  
  49:         mNotesManager = new NotesManager(DatabaseHelper.getDatabase());
  50:         mNoteFilterPreferences = new NoteFilterPreferences(context);
  51:     }
  52:  
  53:  
  54:     @Override
  55:     public List<Note> loadInBackground() {
  56:         Set<NoteColor> enabledFilters = mNoteFilterPreferences.getEnabledColors();
  57:         List<Note> returnValue;
  58:         try {
  59:             returnValue = mNotesManager.getNotesWithColors(new ArrayList<>(enabledFilters));
  60:         }
  61:         catch (Exception e) {
  62:             returnValue = null;
  63:         }
  64:         return returnValue;
  65:     }
  66:  
  67:  
  68:     @Override
  69:     public void deliverResult(List<Note> data) {
  70:         if (isReset()) {
  71:             return;
  72:         }
  73:  
  74:         mNotes = data;
  75:  
  76:         if (isStarted()) {
  77:             super.deliverResult(data);
  78:         }
  79:     }
  80:  
  81:  
  82:     @Override
  83:     protected void onStartLoading() {
  84:         if (mNotes != null) {
  85:             deliverResult(mNotes);
  86:         }
  87:  
  88:         registerReceivers();
  89:  
  90:         if (takeContentChanged() || mNotes == null) {
  91:             forceLoad();
  92:         }
  93:     }
  94:  
  95:  
  96:     @Override
  97:     protected void onStopLoading() {
  98:         cancelLoad();
  99:     }
 100:  
 101:  
 102:     @Override
 103:     protected void onReset() {
 104:         onStopLoading();
 105:  
 106:         if (mNotes != null) {
 107:             mNotes = null;
 108:         }
 109:  
 110:         unregisterReceivers();
 111:     }
 112:  
 113:  
 114:     @Override
 115:     public void update(final Observable observable, final Object data) {
 116:         onContentChanged();
 117:     }
 118:  
 119:  
 120:     /**
 121:      * Register the observer to monitor changes to the note or note filter.
 122:      */
 123:     private void registerReceivers() {
 124:         if (!mObserverRegistered) {
 125:             mNoteFilterPreferences.registerListener(mNoteFilterPreferenceListener);
 126:  
 127:             for (Note note : MoreObjects.firstNonNull(mNotes, new ArrayList<Note>())) {
 128:                 note.addObserver(this);
 129:             }
 130:  
 131:             mObserverRegistered = true;
 132:         }
 133:     }
 134:  
 135:  
 136:     /**
 137:      * Stop monitoring changes to the note and note filter.
 138:      */
 139:     private void unregisterReceivers() {
 140:         mObserverRegistered = false;
 141:  
 142:         for (Note note : MoreObjects.firstNonNull(mNotes, new ArrayList<Note>())) {
 143:             note.deleteObserver(this);
 144:         }
 145:  
 146:         mNoteFilterPreferences.unregisterListener(mNoteFilterPreferenceListener);
 147:     }
 148:  
 149:  
 150: }

 


I also set up my EditNoteActivity as an Observer. A loader for that activity is overkill since it only manipulates a single note.  This lets me update the UI to reflect the note, independent of the Objects actually manipulating the note. It dramatically simplifies the logic to try to keep the data and UI consistent across multiple objects.



package com.fsk.mynotes.presentation.activity;
 
 
import android.animation.Animator;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.ViewCompat;
import android.support.v7.app.AppCompatActivity;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
 
import com.fsk.common.presentation.SimpleTextWatcher;
import com.fsk.common.presentation.utils.animations.BackgroundAnimatorHelper;
import com.fsk.common.presentation.utils.animations.SimpleAnimatorListener;
import com.fsk.common.versions.Versions;
import com.fsk.mynotes.R;
import com.fsk.mynotes.constants.NoteColor;
import com.fsk.mynotes.constants.NoteExtraKeys;
import com.fsk.mynotes.data.Note;
import com.fsk.mynotes.presentation.components.NoteEditColorPalette;
import com.fsk.mynotes.presentation.components.NoteEditOptionsBar;
import com.fsk.mynotes.presentation.components.NoteEditToolbar;
import com.fsk.mynotes.services.DeleteNoteService;
import com.fsk.mynotes.services.SaveNoteService;
import com.google.common.base.Preconditions;
 
import java.util.Observable;
import java.util.Observer;
 
import butterknife.ButterKnife;
import butterknife.InjectView;
 
 
/**
 * This activity is responsible for providing the UI to the user that allows them to modify or
 * create a new {@link Note}.
 */
public class EditNoteActivity extends AppCompatActivity implements Observer {
 
    /**
     * Create an intent for this activity that will allow an existing note to be modified.
     *
     * @param context
     *         The context to use for creating the intent.
     * @param note
     *         The note to edit.
     *
     * @return An intent that will start this activity.
     */
    public static Intent createIntentForExistingNote(@NonNull Context context, @NonNull Note note) {
        Preconditions.checkNotNull(context);
        Preconditions.checkNotNull(note);
 
        Intent intent = new Intent(context, EditNoteActivity.class);
        intent.putExtra(NoteExtraKeys.NOTE_KEY, note);
        return intent;
    }
 
 
    /**
     * Create an intent for this activity that will create a new note..
     *
     * @param context
     *         The context to use for creating the intent.
     *
     * @return An intent that will start this activity.
     */
    public static Intent createIntentForNewNote(@NonNull Context context) {
        Preconditions.checkNotNull(context);
        return new Intent(context, EditNoteActivity.class);
    }
 
 
    @InjectView(R.id.activity_single_note_toolbar)
    NoteEditToolbar mToolbar;
 
 
    @InjectView(R.id.activity_single_note_edit_text)
    EditText mEditText;
 
 
    @InjectView(R.id.activity_single_note_edit_container)
    View mNoteContainerView;
 
 
    /**
     * The duration in milliseconds to cross-blend the note colors.
     */
    int mColorShiftDuration;
 
 
    /**
     * The note being edited.
     */
    Note mNote;
 
 
    /**
     * The listener to any text being altered in {@link #mEditText}.
     */
    final TextWatcher mTextWatcher = new SimpleTextWatcher() {
        @Override
        public void afterTextChanged(final Editable s) {
 
            mNote.setText(s.toString());
        }
    };
 
 
    /**
     * A listener to modify the UI coloring when the User changes the note color.
     */
    final NoteEditColorPalette.OnColorSelectedListener mOnColorSelectedListener =
            new NoteEditColorPalette.OnColorSelectedListener() {
 
                @Override
                public void onColorSelected(@NonNull final NoteColor color) {
                    changeColor(color);
                }
            };
 
 
    /**
     * A listener to react to user requests to change the persistency of the note (saving or
     * deleting).
     */
    final NoteEditOptionsBar.OnPersistenceClickListener mOnPersistenceClickListener =
            new NoteEditOptionsBar.OnPersistenceClickListener() {
 
                @Override
                public void onSaveClicked() {
                    saveNote();
                }
 
 
                @Override
                public void onPurgeClicked() {
                    deleteNote();
                }
            };
 
 
    /**
     * A runnable that will update the toolbar.  This exists to allow the update to handle on the UI
     * thread.
     */
    final Runnable mUpdateToolbarRunnable = new Runnable() {
        @Override
        public void run() {
            mToolbar.updateNote(mNote);
        }
    };
 
 
    /**
     * An Animator listener that will finish the activity when the animator completes or is
     * cancelled.
     */
    private final SimpleAnimatorListener mFinishWhenAnimationCompleteListener =
            new SimpleAnimatorListener() {
 
 
                @Override
                public void onAnimationEnd(final Animator animation) {
                    super.onAnimationEnd(animation);
                    safelyFinish();
                }
 
 
                @Override
                public void onAnimationCancel(final Animator animation) {
                    super.onAnimationCancel(animation);
                    safelyFinish();
                }
            };
 
 
    @Override
    protected void onCreate(@Nullable final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_single_note);
        ButterKnife.inject(this);
 
        mColorShiftDuration = getResources().getInteger(android.R.integer.config_mediumAnimTime);
 
        initializeNote((savedInstanceState == null) ? getIntent().getExtras() : savedInstanceState);
        mNote.addObserver(this);
 
        mToolbar.setOnColorSelectedListener(mOnColorSelectedListener);
        mToolbar.setOnPersistenceClickListener(mOnPersistenceClickListener);
        mToolbar.updateNote(mNote);
 
        changeColor(mNote.getColor());
        mEditText.setText(mNote.getText());
        mEditText.addTextChangedListener(mTextWatcher);
 
        ViewCompat.setTransitionName(mEditText, Long.toString(mNote.getId()));
    }
 
 
    @Override
    protected void onSaveInstanceState(final Bundle outState) {
        super.onSaveInstanceState(outState);
 
        outState.putParcelable(NoteExtraKeys.NOTE_KEY, mNote);
    }
 
 
    @Override
    public void update(final Observable observable, final Object data) {
        mToolbar.post(mUpdateToolbarRunnable);
    }
 
 
    /**
     * Initialize the note based on the bundle data.
     *
     * @param bundle
     *         the bundle containing the note data.
     */
    void initializeNote( Bundle bundle) {
        mNote = null;
 
        //read the note data if the bundle and key exists.
        if ((bundle != null) && bundle.containsKey(NoteExtraKeys.NOTE_KEY)) {
            mNote = bundle.getParcelable(NoteExtraKeys.NOTE_KEY);
        }
 
        /**
         * No note exists, so create a new one.  If anything fails, pack up your toys and go home.
         */
        if (mNote == null) {
            try {
                mNote = new Note.Builder().build();
            }
            catch (Exception e) {
                Toast.makeText(this, R.string.unrecoverable_edit_error, Toast.LENGTH_LONG).show();
                finish();
            }
        }
    }
 
 
    /**
     * Animate the UI color change based on the note colors.
     *
     * @param newColor
     *         the new color for the note.
     */
    void changeColor(@NonNull NoteColor newColor) {
        NoteColor oldColor = mNote.getColor();
        mNote.setColor(newColor);
 
        //change the color of the edit text.
        BackgroundAnimatorHelper.crossBlendColorResource(mEditText, oldColor.colorResourceId,
                                                         newColor.colorResourceId, 0,
                                                         mColorShiftDuration, null);
 
        //change the border color around the edit text.
        BackgroundAnimatorHelper
                .crossBlendColorResource(mNoteContainerView, oldColor.darkColorResourceId,
                                         newColor.darkColorResourceId, 0, mColorShiftDuration,
                                         null);
 
        //change the toolbar color.
        BackgroundAnimatorHelper.crossBlendColorResource(mToolbar, oldColor.darkColorResourceId,
                                                         newColor.darkColorResourceId, 0,
                                                         mColorShiftDuration, null);
    }
 
 
    /**
     * Start the save note service and then finish the activity.
     */
    void saveNote() {
        SaveNoteService.startService(this, mNote);
        safelyFinish();
    }
 
 
    /**
     * Start the delete note service and animate the note deletion.  The activity will finish when
     * the animation completes.
     */
    void deleteNote() {
 
        NoteColor noteColor = mNote.getColor();
        BackgroundAnimatorHelper
                .crossBlendColorResource(mNoteContainerView, noteColor.darkColorResourceId,
                                         android.R.color.transparent, 0, mColorShiftDuration, null);
        mEditText.animate().alpha(0f).setStartDelay(mColorShiftDuration)
                 .setDuration(mColorShiftDuration).setListener(mFinishWhenAnimationCompleteListener)
                 .start();
 
        DeleteNoteService.startService(this, mNote);
    }
 
 
    /**
     * Safely finish this activity.  If the build is at least lollipop, then the transition is
     * allowed to occur before the finish.
     */
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void safelyFinish() {
        if (Versions.isAtLeastLollipop()) {
            finishAfterTransition();
        }
        else {
            finish();
        }
    }
}

Commits


The majority of the  changes can be found on github at https://github.com/fsk-software/mynotes/commit/ed209572c5e1302ac03288a84b4e2003cb5894e7.