|
| 1 | +## Basic app |
| 2 | +__SHA1__: todo |
| 3 | + |
| 4 | +This is not a tutorial in making apps in general, so the starting point |
| 5 | +is a working local-only app. No synchronization or network connectivity |
| 6 | +is implemented at all. The app simply stores a list of links in its local |
| 7 | +database. A user can share a link from anywhere and select 'Add to Links' |
| 8 | +to add links easily and quickly. |
| 9 | + |
| 10 | +TODO: Pictures here |
| 11 | + |
| 12 | +## Adding a SyncAdapter |
| 13 | +__SHA1__: todo |
| 14 | + |
| 15 | +Synchronization needs to be done on a background thread. One could use an |
| 16 | +AsyncTask, but we are going to go all the way and a SyncAdapter here instead. |
| 17 | +Why? A SyncAdapter handles all the syncing for you. There is no need to |
| 18 | +request a sync manually, you set a period and you're done. Even better, |
| 19 | +a SyncAdapter respects the user's global sync setting. So if the user has |
| 20 | +turned off sync, our app will respect that. |
| 21 | + |
| 22 | +Setting up a SyncAdapter is fairly well covered in the docs so I won't go |
| 23 | +too far into specifics there. What needs to be clarified are the bits that |
| 24 | +make it work with the user's Google account. |
| 25 | + |
| 26 | +Note below that I specify _"com.google"_ as the account type. This means |
| 27 | +that our app will show up in the global sync settings under the Google |
| 28 | +account. The authority is the same as specified in the ContentProvider. |
| 29 | + |
| 30 | +__syncadapter.xml:__ |
| 31 | +```xml |
| 32 | +<?xml version="1.0" encoding="utf-8"?> |
| 33 | +<!-- |
| 34 | + Important to use my own authority |
| 35 | + also specify that we want to use standard google account as the type |
| 36 | + also we want to be able to upload etc... |
| 37 | +--> |
| 38 | + |
| 39 | +<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android" |
| 40 | + android:contentAuthority="com.nononsenseapps.linksgcm.database.AUTHORITY" |
| 41 | + android:accountType="com.google" |
| 42 | + android:supportsUploading="true" |
| 43 | + android:userVisible="true" |
| 44 | +/> |
| 45 | +``` |
| 46 | + |
| 47 | +The SyncAdapter itself is very simple, here is the onPerform method |
| 48 | +as the rest is just short boilerplate: |
| 49 | +```java |
| 50 | +@Override |
| 51 | + public void onPerformSync(Account account, Bundle extras, String authority, |
| 52 | + ContentProviderClient provider, SyncResult syncResult) { |
| 53 | + try { |
| 54 | + // Need to get an access token first |
| 55 | + final String token = SyncHelper.getAuthToken(getContext(), |
| 56 | + account.name); |
| 57 | + |
| 58 | + if (token == null) { |
| 59 | + Log.e(TAG, "Token was null. Aborting sync"); |
| 60 | + // Sync is rescheduled by SyncHelper |
| 61 | + return; |
| 62 | + } |
| 63 | + |
| 64 | + // token should be good. Transmit |
| 65 | + final LinksServer server = SyncHelper.getRESTAdapter(); |
| 66 | + DatabaseHandler db = DatabaseHandler.getInstance(getContext()); |
| 67 | + |
| 68 | + // Upload stuff |
| 69 | + for (LinkItem item : db.getAllLinkItems(LinkItem.COL_SYNCED |
| 70 | + + " IS 0 OR " + LinkItem.COL_DELETED + " IS 1", null, null)) { |
| 71 | + if (item.deleted != 0) { |
| 72 | + // Delete the item |
| 73 | + server.deleteLink(token, item.sha); |
| 74 | + syncResult.stats.numDeletes++; |
| 75 | + db.deleteItem(item); |
| 76 | + } |
| 77 | + else { |
| 78 | + server.addLink(token, item); |
| 79 | + syncResult.stats.numInserts++; |
| 80 | + item.synced = 1; |
| 81 | + db.putItem(item); |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + // Download stuff |
| 86 | + // Check if we synced before |
| 87 | + final String lastSync = PreferenceManager |
| 88 | + .getDefaultSharedPreferences(getContext()).getString( |
| 89 | + KEY_LASTSYNC, null); |
| 90 | + |
| 91 | + final LinkItems items; |
| 92 | + if (lastSync != null && !lastSync.isEmpty()) { |
| 93 | + items = server.listLinks(token, "true", lastSync); |
| 94 | + } |
| 95 | + else { |
| 96 | + items = server.listLinks(token, "false", null); |
| 97 | + } |
| 98 | + |
| 99 | + if (items != null && items.links != null) { |
| 100 | + for (LinkItem item : items.links) { |
| 101 | + if (item.deleted == 0) { |
| 102 | + db.putItem(item); |
| 103 | + } |
| 104 | + else { |
| 105 | + db.deleteItem(item); |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // Save sync timestamp |
| 111 | + PreferenceManager.getDefaultSharedPreferences(getContext()).edit() |
| 112 | + .putString(KEY_LASTSYNC, items.latestTimestamp).commit(); |
| 113 | + } |
| 114 | + catch (RetrofitError e) { |
| 115 | + Log.e(TAG, e.getResponse().toString()); |
| 116 | + // An HTTP error was encountered. |
| 117 | + switch (e.getResponse().getStatus()) { |
| 118 | + case 401: // Unauthorized |
| 119 | + syncResult.stats.numAuthExceptions++; |
| 120 | + break; |
| 121 | + case 404: // No such item, should never happen, programming error |
| 122 | + case 415: // Not proper body, programming error |
| 123 | + case 400: // Didn't specify url, programming error |
| 124 | + syncResult.databaseError = true; |
| 125 | + break; |
| 126 | + default: // Default is to consider it a networking problem |
| 127 | + syncResult.stats.numIoExceptions++; |
| 128 | + break; |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | +``` |
| 133 | + |
| 134 | +The general idea is as follows: |
| 135 | +1. Get an access token |
| 136 | +2. Upload new items and deletions from the client |
| 137 | +3. Download new items and deletions from the server (if we have synced before, only fetch items newer than last time) |
| 138 | +4. Save the timestamp from this sync for next time |
| 139 | + |
| 140 | +The syncing model is simple because the app doesn't really have the idea |
| 141 | +of updates. There is no way to update individual entries, only add new |
| 142 | +ones or delete them. Hence we avoid the problem of sync conflicts |
| 143 | +entirely. If your use case involves updating things, you'll have to |
| 144 | +consider some kind of conflict resolution. |
| 145 | + |
| 146 | +Let's have look at the SyncHelper class next. That's where the |
| 147 | +access token is retrieved: |
| 148 | + |
| 149 | +__SyncHelper.java:__ |
| 150 | +```java |
| 151 | +public class SyncHelper { |
| 152 | + |
| 153 | + public static final String KEY_ACCOUNT = "key_account"; |
| 154 | + public static final String SCOPE = "oauth2:https://www.googleapis.com/auth/userinfo.profile"; |
| 155 | + static final String TAG = "Links"; |
| 156 | + |
| 157 | + public static LinksServer getRESTAdapter() { |
| 158 | + RestAdapter restAdapter = new RestAdapter.Builder().setServer( |
| 159 | + LinksServer.API_URL).build(); |
| 160 | + return restAdapter.create(LinksServer.class); |
| 161 | + } |
| 162 | + |
| 163 | + public static String getSavedAccountName(final Context context) { |
| 164 | + return PreferenceManager.getDefaultSharedPreferences(context) |
| 165 | + .getString(SyncHelper.KEY_ACCOUNT, null); |
| 166 | + } |
| 167 | + |
| 168 | + public static String getAuthToken(final Context context) { |
| 169 | + final String accountName = getSavedAccountName(context); |
| 170 | + if (accountName == null || accountName.isEmpty()) { |
| 171 | + return null; |
| 172 | + } |
| 173 | + |
| 174 | + return getAuthToken(context, accountName); |
| 175 | + } |
| 176 | + |
| 177 | + /** |
| 178 | + * Only use this in a background thread, i.e. the syncadapter. |
| 179 | + */ |
| 180 | + public static String getAuthToken(final Context context, |
| 181 | + final String accountName) { |
| 182 | + try { |
| 183 | + return GoogleAuthUtil.getTokenWithNotification(context, |
| 184 | + accountName, SCOPE, null, ItemProvider.AUTHORITY, null); |
| 185 | + } |
| 186 | + catch (UserRecoverableNotifiedException userRecoverableException) { |
| 187 | + // Unable to authenticate, but the user can fix this. |
| 188 | + Log.e(TAG, |
| 189 | + "Could not fetch token: " |
| 190 | + + userRecoverableException.getMessage()); |
| 191 | + } |
| 192 | + catch (GoogleAuthException fatalException) { |
| 193 | + Log.e(TAG, "Unrecoverable error " + fatalException.getMessage()); |
| 194 | + } |
| 195 | + catch (IOException e) { |
| 196 | + Log.e(TAG, e.getMessage()); |
| 197 | + } |
| 198 | + return null; |
| 199 | + } |
| 200 | + |
| 201 | + public static Account getAccount(final Context context, |
| 202 | + final String accountName) { |
| 203 | + final AccountManager manager = AccountManager.get(context); |
| 204 | + Account[] accounts = manager |
| 205 | + .getAccountsByType(GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE); |
| 206 | + for (Account account : accounts) { |
| 207 | + if (account.name.equals(accountName)) { |
| 208 | + return account; |
| 209 | + } |
| 210 | + } |
| 211 | + return null; |
| 212 | + } |
| 213 | + |
| 214 | + public static void manualSync(final Context context) { |
| 215 | + final String email = getSavedAccountName(context); |
| 216 | + |
| 217 | + if (email != null) { |
| 218 | + // Set it syncable |
| 219 | + final Account account = getAccount(context, email); |
| 220 | + |
| 221 | + if (!ContentResolver.isSyncActive(account, ItemProvider.AUTHORITY)) { |
| 222 | + Bundle options = new Bundle(); |
| 223 | + // This will force a sync regardless of what the setting is |
| 224 | + // in accounts manager. Only use it here where the user has |
| 225 | + // manually desired a sync to happen NOW. |
| 226 | + // options.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); |
| 227 | + ContentResolver.requestSync(account, ItemProvider.AUTHORITY, |
| 228 | + options); |
| 229 | + } |
| 230 | + } |
| 231 | + } |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +_accountName_ is the e-mail address of the user. If you're confused, |
| 236 | +focus entirely on the _getAuthToken_ method. It returns an access token |
| 237 | +if the user has/will authorized the app, and null if the user declined. |
| 238 | +This is supposed to be used in the SyncAdapter, and what happens the first |
| 239 | +time if unauthorized is that a notification will appear. If clicked, |
| 240 | +the user gets a question if he/she wants to authorize the app to |
| 241 | +access the profile information. That means we can potentially access |
| 242 | +name, gender, birthday and a profile photo but we don't care about |
| 243 | +those. |
| 244 | + |
| 245 | +The actual network communication is handled by the excellent |
| 246 | +[Retrofit](http://square.github.io/retrofit/) library. It basically |
| 247 | +does for Java what Bottle did for the server app. Observe: |
| 248 | + |
| 249 | +__LinksServer.java:__ |
| 250 | +```java |
| 251 | +public interface LinksServer { |
| 252 | + |
| 253 | + public static final String API_URL = "http://192.168.1.17:5500"; |
| 254 | + |
| 255 | + public static class LinkItems { |
| 256 | + String latestTimestamp; |
| 257 | + List<LinkItem> links; |
| 258 | + } |
| 259 | + |
| 260 | + public static class Dummy { |
| 261 | + // Methods must have return type |
| 262 | + } |
| 263 | + |
| 264 | + @GET("/links") |
| 265 | + LinkItems listLinks(@Header("Bearer") String token, |
| 266 | + @Query("showDeleted") String showDeleted, |
| 267 | + @Query("timestampMin") String timestampMin); |
| 268 | + |
| 269 | + @GET("/links/{sha}") |
| 270 | + LinkItem getLink(@Header("Bearer") String token, @Path("sha") String sha); |
| 271 | + |
| 272 | + @DELETE("/links/{sha}") |
| 273 | + Dummy deleteLink(@Header("Bearer") String token, @Path("sha") String sha); |
| 274 | + |
| 275 | + @POST("/links") |
| 276 | + LinkItem addLink(@Header("Bearer") String token, @Body LinkItem item); |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | +You define an interface, and the library takes care of building an |
| 281 | +actual object that talks to the server. Note that because the database |
| 282 | +object _LinkItem_ has public fields, we can use it directly in this |
| 283 | +interface. This is seriously __ALL__ the code required to talk |
| 284 | +with a rest server. Notice also that the definitions match those |
| 285 | +in the server. |
| 286 | + |
| 287 | +That was __IT__. There are a few additional convenience classes and such |
| 288 | +that I included to make the app more user friendly but this is all |
| 289 | +that takes place behind the scenes. The sync only happens at fixed times |
| 290 | +though. Either the user hits sync inside the app, or once a day. Next |
| 291 | +up will be getting GCM up and running so we can get real time push |
| 292 | +updates going. |
| 293 | + |
| 294 | +## Adding GCM |
| 295 | +todo |
0 commit comments