Skip to content

Commit b9f8624

Browse files
committed
SyncAdapter and synchronization added to client.
Signed-off-by: Jonas Kalderstam <[email protected]>
1 parent 70f66d1 commit b9f8624

27 files changed

+1212
-111
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## Goals
2+
3+
1. Learn how to implement an Android app which connects, using __GCM__,
4+
to a server-side app.
5+
2. Learn how to make a server-side app with a __REST__ interface and
6+
connections to GCM.
7+
8+
This project is divided in two parts: the Android client and the server app.
9+
My own motivations for this project is to learn how to make a server side
10+
app. I already know how to make Android apps but know very little about
11+
web programming. So this will be outside of my comfort zone.
12+
13+
I have the following requirements for the project:
14+
15+
__Serverside:__
16+
* Have a __REST__ api
17+
* Use __GCM__
18+
* Require login but don't handle passwords
19+
20+
__Androidside:__
21+
* No passwords
22+
* Get push updates through __GCM__
23+
* Initial full sync on install using __REST__
24+
25+
## Server app
26+
Step 1 will be to construct an app we can deploy on a webserver somewhere.
27+
Go into the _server-app_ folder and see how that's done.
28+
29+
## Android app
30+
Step 2 will be the Android app. This will be fairly straightforward. Have
31+
a look in _android-client_ for that.
32+
33+
## Result
34+
No result so far.
35+
36+
When finished, will link to apk and have the server running.

android-client/AndroidManifest.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
android:minSdkVersion="14"
99
android:targetSdkVersion="18" />
1010

11+
<!-- For connection -->
12+
<uses-permission android:name="android.permission.INTERNET" />
13+
<!-- For getting Google account -->
14+
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
15+
<!-- For getting Google account auth token -->
16+
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
17+
18+
<!-- For syncing -->
19+
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
20+
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
21+
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
22+
1123
<application
1224
android:allowBackup="true"
1325
android:icon="@drawable/ic_launcher"
@@ -40,10 +52,25 @@
4052
android:theme="@style/Invisible" >
4153
<intent-filter>
4254
<action android:name="android.intent.action.SEND" />
55+
4356
<category android:name="android.intent.category.DEFAULT" />
57+
4458
<data android:mimeType="text/*" />
4559
</intent-filter>
4660
</activity>
61+
62+
<service
63+
android:name="com.nononsenseapps.linksgcm.sync.SyncService"
64+
android:exported="true" >
65+
<intent-filter>
66+
<action android:name="android.content.SyncAdapter" />
67+
</intent-filter>
68+
69+
<meta-data
70+
android:name="android.content.SyncAdapter"
71+
android:resource="@xml/syncadapter" />
72+
</service>
73+
4774
</application>
4875

4976
</manifest>

android-client/README.md

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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

android-client/libs/gson-2.2.4.jar

186 KB
Binary file not shown.
220 KB
Binary file not shown.
95 KB
Binary file not shown.

android-client/project.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212

1313
# Project target.
1414
target=android-18
15+
android.library.reference.1=../../../android-sdk-linux/extras/google/google_play_services/libproject/google-play-services_lib

android-client/res/layout/fragment_link_grid.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
android:id="@android:id/list"
1010
android:layout_width="match_parent"
1111
android:layout_height="match_parent"
12-
android:numColumns="2" />
12+
android:numColumns="2"
13+
tools:listitem="@layout/list_item" />
1314

1415
<TextView
1516
android:id="@android:id/empty"

0 commit comments

Comments
 (0)