From 15ef1a8091c1557f175575671a5af62420088944 Mon Sep 17 00:00:00 2001 From: John Evans Date: Mon, 4 Apr 2011 13:38:01 -0700 Subject: [PATCH] New version of SampleSyncAdapter sample code that allows local editing The changes made were pretty sweeping. The biggest addition was to allow on-device contact creation/editing, and supporting 2-way sync to the sample server that runs in Google App Engine. The client-side sample code also includes examples of how to support the user of AuthTokens (instead of always sending username/password to the server), how to change a contact's picture, and how to set IM-style status messages for each contact. I also greatly simplified the server code so that instead of mimicking both an addressbook and an IM-style status update system for multiple users, it really just simulates an addressbook for a single user. The server code also includes a cron job that (once a week) blows away the contact database, so that it's relatively self-cleaning. Change-Id: I017f1d3f9320a02fe05a20f1613846963107145e --- samples/SampleSyncAdapter/Android.mk | 4 +- samples/SampleSyncAdapter/AndroidManifest.xml | 27 +- samples/SampleSyncAdapter/_index.html | 5 +- .../SampleSyncAdapter/res/drawable/border.xml | 6 + .../res/drawable/done_menu_icon.png | Bin 0 -> 658 bytes .../res/layout-xlarge/editor.xml | 43 ++ .../res/layout-xlarge/editor_header.xml | 58 ++ .../SampleSyncAdapter/res/layout/editor.xml | 36 + .../res/layout/editor_fields.xml | 127 ++++ samples/SampleSyncAdapter/res/menu/edit.xml | 30 + .../res/values-xlarge/dimens.xml | 24 + .../SampleSyncAdapter/res/values/dimens.xml | 25 + .../SampleSyncAdapter/res/values/strings.xml | 14 +- .../SampleSyncAdapter/res/values/styles.xml | 27 + .../res/xml-v11/contacts.xml | 33 + .../res/xml-v11/syncadapter.xml | 33 + .../SampleSyncAdapter/res/xml/contacts.xml | 6 +- .../SampleSyncAdapter/res/xml/syncadapter.xml | 14 +- .../samplesyncadapter_server/app.yaml | 65 +- .../samplesyncadapter_server/cron.yaml | 24 + .../samplesyncadapter_server/dashboard.py | 375 ++++------ .../samplesyncadapter_server/index.yaml | 27 +- .../samplesyncadapter_server/main.py | 173 ----- .../model/datastore.py | 49 +- .../static/css/main.css | 77 ++ .../static/img/default_avatar.gif | Bin 0 -> 2989 bytes .../templates/contacts.html | 53 ++ .../templates/edit_avatar.html | 42 ++ .../templates/simple_form.html | 38 + .../templates/users.html | 19 - .../templates/view_friends.html | 17 - .../samplesyncadapter_server/web_services.py | 400 +++++++++++ .../example/android/samplesync/Constants.java | 6 +- .../authenticator/AuthenticationService.java | 9 +- .../authenticator/Authenticator.java | 117 ++-- .../authenticator/AuthenticatorActivity.java | 170 +++-- .../samplesync/client/NetworkUtilities.java | 349 +++++----- .../android/samplesync/client/RawContact.java | 260 +++++++ .../android/samplesync/client/User.java | 155 ---- .../editor/ContactEditorActivity.java | 369 ++++++++++ .../samplesync/platform/BatchOperation.java | 19 +- .../samplesync/platform/ContactManager.java | 659 ++++++++++++++---- .../platform/ContactOperations.java | 285 +++++--- .../platform/SampleSyncAdapterColumns.java | 6 +- .../samplesync/syncadapter/SyncAdapter.java | 121 +++- 45 files changed, 3159 insertions(+), 1237 deletions(-) create mode 100644 samples/SampleSyncAdapter/res/drawable/border.xml create mode 100644 samples/SampleSyncAdapter/res/drawable/done_menu_icon.png create mode 100644 samples/SampleSyncAdapter/res/layout-xlarge/editor.xml create mode 100644 samples/SampleSyncAdapter/res/layout-xlarge/editor_header.xml create mode 100644 samples/SampleSyncAdapter/res/layout/editor.xml create mode 100644 samples/SampleSyncAdapter/res/layout/editor_fields.xml create mode 100644 samples/SampleSyncAdapter/res/menu/edit.xml create mode 100644 samples/SampleSyncAdapter/res/values-xlarge/dimens.xml create mode 100644 samples/SampleSyncAdapter/res/values/dimens.xml create mode 100644 samples/SampleSyncAdapter/res/values/styles.xml create mode 100644 samples/SampleSyncAdapter/res/xml-v11/contacts.xml create mode 100644 samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/cron.yaml delete mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/main.py create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/static/css/main.css create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/static/img/default_avatar.gif create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/templates/edit_avatar.html create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/templates/simple_form.html delete mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html delete mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/templates/view_friends.html create mode 100644 samples/SampleSyncAdapter/samplesyncadapter_server/web_services.py create mode 100644 samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java delete mode 100644 samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java create mode 100644 samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java diff --git a/samples/SampleSyncAdapter/Android.mk b/samples/SampleSyncAdapter/Android.mk index a27a68ffb..0f87c174d 100644 --- a/samples/SampleSyncAdapter/Android.mk +++ b/samples/SampleSyncAdapter/Android.mk @@ -6,10 +6,12 @@ LOCAL_MODULE_TAGS := samples tests # Only compile source java files in this apk. LOCAL_SRC_FILES := $(call all-java-files-under, src) -LOCAL_PACKAGE_NAME := Voiper +LOCAL_PACKAGE_NAME := SampleSyncAdapter LOCAL_SDK_VERSION := current +LOCAL_DX_FLAGS=--target-api=11 + include $(BUILD_PACKAGE) # Use the folloing include to make our test apk. diff --git a/samples/SampleSyncAdapter/AndroidManifest.xml b/samples/SampleSyncAdapter/AndroidManifest.xml index 202ed0e4b..fd53a1643 100644 --- a/samples/SampleSyncAdapter/AndroidManifest.xml +++ b/samples/SampleSyncAdapter/AndroidManifest.xml @@ -46,7 +46,7 @@ - + + + + + + + + + + + + + + diff --git a/samples/SampleSyncAdapter/_index.html b/samples/SampleSyncAdapter/_index.html index 4191ba507..603083a0a 100644 --- a/samples/SampleSyncAdapter/_index.html +++ b/samples/SampleSyncAdapter/_index.html @@ -2,7 +2,8 @@ cloud-based service and synchronize its data with data stored locally in a content provider. The sample uses two related parts of the Android framework — the account manager and the synchronization manager (through a sync -adapter).

+adapter). It also demonstrates how to provide users the ability to create +and edit synchronized contacts using a custom editor.

The account @@ -26,7 +27,7 @@ AbstractThreadedSyncAdapter abstract class and implementing the issues a sync operation for that sync adapter.

The cloud-based service for this sample application is running at:

-

http://samplesyncadapter.appspot.com/users

+

http://samplesyncadapter2.appspot.com/

When you install this sample application, a new syncable "SampleSyncAdapter" account will be added to your phone's account manager. You can go to "Settings | diff --git a/samples/SampleSyncAdapter/res/drawable/border.xml b/samples/SampleSyncAdapter/res/drawable/border.xml new file mode 100644 index 000000000..ab71f2c28 --- /dev/null +++ b/samples/SampleSyncAdapter/res/drawable/border.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/SampleSyncAdapter/res/drawable/done_menu_icon.png b/samples/SampleSyncAdapter/res/drawable/done_menu_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3468bbd5f8438efb3a8d17c4c1db1077b39e07b1 GIT binary patch literal 658 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE0wix1Z>k4UOiAAEE)4(M`_JqL@;D1TB8!2v z2N=7Z%(epwmK8Xr18D^?ZvQoBE&~JOEKe855Rc<;ubj=gk|1*Ihbd-DAF)me65Ob8YiHj_?m(A|-UW|C1lNX$urA#tyoX`+qx6mIW*u=&Gs^T` zZv1Lfx~Sj1|HbD%Kbx~b^uNs3gMH>jY90$ZU+90BWujkmqBt?ZX7k;^$mPxZSi4@C zil4RYU2$>0Lt}d0>@6u1_jLbixR*ck%e@umPR3_1H$O1FAZE z>#Uw`zH$0%#quN-!__bMJ+9-t^eA`2j-+Ga$#H)=%bzJ6p0mAgNAm0~bI*P}omL|n z{`hEm4$I2DQQxXF4s|IAtLRxio_%9;)}EToiAUsg*=>v-F5eX@KId!fo0o1CyPGE+ z$5URbRuPL + + + + + + + + + \ No newline at end of file diff --git a/samples/SampleSyncAdapter/res/layout-xlarge/editor_header.xml b/samples/SampleSyncAdapter/res/layout-xlarge/editor_header.xml new file mode 100644 index 000000000..648e6f98d --- /dev/null +++ b/samples/SampleSyncAdapter/res/layout-xlarge/editor_header.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + diff --git a/samples/SampleSyncAdapter/res/layout/editor.xml b/samples/SampleSyncAdapter/res/layout/editor.xml new file mode 100644 index 000000000..a0c36d2fa --- /dev/null +++ b/samples/SampleSyncAdapter/res/layout/editor.xml @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/samples/SampleSyncAdapter/res/layout/editor_fields.xml b/samples/SampleSyncAdapter/res/layout/editor_fields.xml new file mode 100644 index 000000000..31d0128c1 --- /dev/null +++ b/samples/SampleSyncAdapter/res/layout/editor_fields.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SampleSyncAdapter/res/menu/edit.xml b/samples/SampleSyncAdapter/res/menu/edit.xml new file mode 100644 index 000000000..1227584ea --- /dev/null +++ b/samples/SampleSyncAdapter/res/menu/edit.xml @@ -0,0 +1,30 @@ + + + +

+ + + + diff --git a/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml b/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml new file mode 100644 index 000000000..f9d74a784 --- /dev/null +++ b/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml @@ -0,0 +1,24 @@ + + + + + + 26sp + + diff --git a/samples/SampleSyncAdapter/res/values/dimens.xml b/samples/SampleSyncAdapter/res/values/dimens.xml new file mode 100644 index 000000000..7518c0764 --- /dev/null +++ b/samples/SampleSyncAdapter/res/values/dimens.xml @@ -0,0 +1,25 @@ + + + + + + + 18sp + + diff --git a/samples/SampleSyncAdapter/res/values/strings.xml b/samples/SampleSyncAdapter/res/values/strings.xml index 8139d65e4..3b5ac5835 100644 --- a/samples/SampleSyncAdapter/res/values/strings.xml +++ b/samples/SampleSyncAdapter/res/values/strings.xml @@ -20,7 +20,7 @@ xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> SamplesyncAdapter + name="label">Sample SyncAdapter Sample profile View Profile + + SampleSync contact + + Name + Home Phone + Mobile Phone + Work Phone + Email + Done + Cancel \ No newline at end of file diff --git a/samples/SampleSyncAdapter/res/values/styles.xml b/samples/SampleSyncAdapter/res/values/styles.xml new file mode 100644 index 000000000..074613e40 --- /dev/null +++ b/samples/SampleSyncAdapter/res/values/styles.xml @@ -0,0 +1,27 @@ + + + + + + #ffffff + #cccccc + + diff --git a/samples/SampleSyncAdapter/res/xml-v11/contacts.xml b/samples/SampleSyncAdapter/res/xml-v11/contacts.xml new file mode 100644 index 000000000..62dfa8009 --- /dev/null +++ b/samples/SampleSyncAdapter/res/xml-v11/contacts.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml b/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml new file mode 100644 index 000000000..dac8adea0 --- /dev/null +++ b/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml @@ -0,0 +1,33 @@ + + + + + + diff --git a/samples/SampleSyncAdapter/res/xml/contacts.xml b/samples/SampleSyncAdapter/res/xml/contacts.xml index 1ff9c05c1..b46257dcd 100644 --- a/samples/SampleSyncAdapter/res/xml/contacts.xml +++ b/samples/SampleSyncAdapter/res/xml/contacts.xml @@ -17,7 +17,11 @@ */ --> - + - - + diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml b/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml index 109eff36d..85261774f 100644 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml @@ -1,44 +1,59 @@ -application: samplesyncadapter +# Copyright (C) 2010 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +application: samplesyncadapter2 version: 1 runtime: python api_version: 1 handlers: + +# +# Define a handler for our static files (css, images, etc) +# +- url: /static + static_dir: static + +# +# Route all "web services" requests to the main.py file +# - url: /auth - script: main.py + script: web_services.py -- url: /login - script: main.py +- url: /sync + script: web_services.py -- url: /fetch_friend_updates - script: main.py - -- url: /fetch_status - script: main.py - -- url: /add_user +- url: /reset_database + script: web_services.py + +# +# Route all page requests to the dashboard.py file +# +- url: / script: dashboard.py -- url: /edit_user +- url: /add_contact script: dashboard.py -- url: /users +- url: /edit_contact script: dashboard.py -- url: /delete_friend +- url: /delete_contact script: dashboard.py -- url: /edit_user +- url: /avatar script: dashboard.py -- url: /add_credentials - script: dashboard.py - -- url: /user_credentials - script: dashboard.py - -- url: /add_friend - script: dashboard.py - -- url: /user_friends +- url: /edit_avatar script: dashboard.py diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/cron.yaml b/samples/SampleSyncAdapter/samplesyncadapter_server/cron.yaml new file mode 100644 index 000000000..1a0badb12 --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/cron.yaml @@ -0,0 +1,24 @@ +# Copyright (C) 2011 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +cron: +# +# Create a weekly cron job that cleans up the SampleSyncAdapter server database. +# We remove all existing contacts from the db, and create three initial +# contacts: Romeo, Juliet, and Tybalt. +# +- description: weekly cleanup job + url: /reset_database + schedule: every sunday 00:00 + timezone: America/Los_Angeles \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/dashboard.py b/samples/SampleSyncAdapter/samplesyncadapter_server/dashboard.py index c986f7e56..b9bac5e0c 100644 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/dashboard.py +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/dashboard.py @@ -1,21 +1,23 @@ #!/usr/bin/python2.5 # Copyright (C) 2010 The Android Open Source Project -# +# # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy of # the License at -# +# # http://www.apache.org/licenses/LICENSE-2.0 -# +# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. -"""Defines Django forms for inserting/updating/viewing data - to/from SampleSyncAdapter datastore.""" +""" +Defines Django forms for inserting/updating/viewing contact data +to/from SampleSyncAdapter datastore. +""" import cgi import datetime @@ -26,248 +28,181 @@ from google.appengine.ext import webapp from google.appengine.ext.webapp import template from google.appengine.ext.db import djangoforms from model import datastore +from google.appengine.api import images import wsgiref.handlers +class BaseRequestHandler(webapp.RequestHandler): + """ + Base class for our page-based request handlers that contains + some helper functions we use in most pages. + """ -class UserForm(djangoforms.ModelForm): - """Represents django form for entering user info.""" + """ + Return a form (potentially partially filled-in) to + the user. + """ + def send_form(self, title, action, contactId, handle, content_obj): + if (contactId >= 0): + idInfo = '' + else: + idInfo = '' - class Meta: - model = datastore.User + template_values = { + 'title': title, + 'header': title, + 'action': action, + 'contactId': contactId, + 'handle': handle, + 'has_contactId': (contactId >= 0), + 'has_handle': (handle != None), + 'form_data_rows': str(content_obj) + } + + path = os.path.join(os.path.dirname(__file__), 'templates', 'simple_form.html') + self.response.out.write(template.render(path, template_values)) + +class ContactForm(djangoforms.ModelForm): + """Represents django form for entering contact info.""" + + class Meta: + model = datastore.Contact -class UserInsertPage(webapp.RequestHandler): - """Inserts new users. GET presents a blank form. POST processes it.""" +class ContactInsertPage(BaseRequestHandler): + """ + Processes requests to add a new contact. GET presents an empty + contact form for the user to fill in. POST saves the new contact + with the POSTed information. + """ - def get(self): - self.response.out.write('' - '
' - '') - # This generates our shopping list form and writes it in the response - self.response.out.write(UserForm()) - self.response.out.write('
' - '' - '
') + def get(self): + self.send_form('Add Contact', '/add_contact', -1, None, ContactForm()) - def post(self): - data = UserForm(data=self.request.POST) - if data.is_valid(): - # Save the data, and redirect to the view page - entity = data.save(commit=False) - entity.put() - self.redirect('/users') - else: - # Reprint the form - self.response.out.write('' - '
' - '') - self.response.out.write(data) - self.response.out.write('
' - '' - '
') + def post(self): + data = ContactForm(data=self.request.POST) + if data.is_valid(): + # Save the data, and redirect to the view page + entity = data.save(commit=False) + entity.put() + self.redirect('/') + else: + # Reprint the form + self.send_form('Add Contact', '/add_contact', -1, None, data) -class UserEditPage(webapp.RequestHandler): - """Edits users. GET presents a form prefilled with user info - from datastore. POST processes it.""" +class ContactEditPage(BaseRequestHandler): + """ + Process requests to edit a contact's information. GET presents a form + with the current contact information filled in. POST saves new information + into the contact record. + """ - def get(self): - id = int(self.request.get('user')) - user = datastore.User.get(db.Key.from_path('User', id)) - self.response.out.write('' - '
' - '') - # This generates our shopping list form and writes it in the response - self.response.out.write(UserForm(instance=user)) - self.response.out.write('
' - '' - '' - '
' % id) + def get(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + self.send_form('Edit Contact', '/edit_contact', id, contact.handle, + ContactForm(instance=contact)) - def post(self): - id = int(self.request.get('_id')) - user = datastore.User.get(db.Key.from_path('User', id)) - data = UserForm(data=self.request.POST, instance=user) - if data.is_valid(): - # Save the data, and redirect to the view page - entity = data.save(commit=False) - entity.updated = datetime.datetime.utcnow() - entity.put() - self.redirect('/users') - else: - # Reprint the form - self.response.out.write('' - '
' - '') - self.response.out.write(data) - self.response.out.write('
' - '' - '' - '
' % id) + def post(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + data = ContactForm(data=self.request.POST, instance=contact) + if data.is_valid(): + # Save the data, and redirect to the view page + entity = data.save(commit=False) + entity.updated = datetime.datetime.utcnow() + entity.put() + self.redirect('/') + else: + # Reprint the form + self.send_form('Edit Contact', '/edit_contact', id, contact.handle, data) +class ContactDeletePage(BaseRequestHandler): + """Processes delete contact request.""" -class UsersListPage(webapp.RequestHandler): - """Lists all Users. In addition displays links for editing user info, - viewing user's friends and adding new users.""" + def get(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + contact.deleted = True + contact.updated = datetime.datetime.utcnow() + contact.put() - def get(self): - users = datastore.User.all() - template_values = { - 'users': users - } + self.redirect('/') - path = os.path.join(os.path.dirname(__file__), 'templates', 'users.html') - self.response.out.write(template.render(path, template_values)) +class AvatarEditPage(webapp.RequestHandler): + """ + Processes requests to edit contact's avatar. GET is used to fetch + a page that displays the contact's current avatar and allows the user + to specify a file containing a new avatar image. POST is used to + submit the form which will change the contact's avatar. + """ + def get(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + template_values = { + 'avatar': contact.avatar, + 'contactId': id + } + + path = os.path.join(os.path.dirname(__file__), 'templates', 'edit_avatar.html') + self.response.out.write(template.render(path, template_values)) -class UserCredentialsForm(djangoforms.ModelForm): - """Represents django form for entering user's credentials.""" + def post(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + #avatar = images.resize(self.request.get("avatar"), 128, 128) + avatar = self.request.get("avatar") + contact.avatar = db.Blob(avatar) + contact.updated = datetime.datetime.utcnow() + contact.put() + self.redirect('/') - class Meta: - model = datastore.UserCredentials +class AvatarViewPage(BaseRequestHandler): + """ + Processes request to view contact's avatar. This is different from + the GET AvatarEditPage request in that this doesn't return a page - + it just returns the raw image itself. + """ + def get(self): + id = int(self.request.get('id')) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + if (contact.avatar): + self.response.headers['Content-Type'] = "image/png" + self.response.out.write(contact.avatar) + else: + self.redirect(self.request.host_url + '/static/img/default_avatar.gif') -class UserCredentialsInsertPage(webapp.RequestHandler): - """Inserts user credentials. GET shows a blank form, POST processes it.""" +class ContactsListPage(webapp.RequestHandler): + """ + Display a page that lists all the contacts associated with + the specifies user account. + """ - def get(self): - self.response.out.write('' - '
' - '') - # This generates our shopping list form and writes it in the response - self.response.out.write(UserCredentialsForm()) - self.response.out.write('
' - '' - '
') + def get(self): + contacts = datastore.Contact.all() + template_values = { + 'contacts': contacts, + 'username': 'user' + } - def post(self): - data = UserCredentialsForm(data=self.request.POST) - if data.is_valid(): - # Save the data, and redirect to the view page - entity = data.save(commit=False) - entity.put() - self.redirect('/users') - else: - # Reprint the form - self.response.out.write('' - '
' - '') - self.response.out.write(data) - self.response.out.write('
' - '' - '
') - - -class UserFriendsForm(djangoforms.ModelForm): - """Represents django form for entering user's friends.""" - - class Meta: - model = datastore.UserFriends - exclude = ['deleted', 'username'] - - -class UserFriendsInsertPage(webapp.RequestHandler): - """Inserts user's new friends. GET shows a blank form, POST processes it.""" - - def get(self): - user = self.request.get('user') - self.response.out.write('' - '
' - '') - # This generates our shopping list form and writes it in the response - self.response.out.write(UserFriendsForm()) - self.response.out.write('
' - '' - '' - '
' % user) - - def post(self): - data = UserFriendsForm(data=self.request.POST) - if data.is_valid(): - user = self.request.get('user') - # Save the data, and redirect to the view page - entity = data.save(commit=False) - entity.username = user - query = datastore.UserFriends.all() - query.filter('username = ', user) - query.filter('friend_handle = ', entity.friend_handle) - result = query.get() - if result: - result.deleted = False - result.updated = datetime.datetime.utcnow() - result.put() - else: - entity.deleted = False - entity.put() - self.redirect('/user_friends?user=' + user) - else: - # Reprint the form - self.response.out.write('' - '
' - '') - self.response.out.write(data) - self.response.out.write('
' - '' - '
') - - -class UserFriendsListPage(webapp.RequestHandler): - """Lists all friends for a user. In addition displays links for removing - friends and adding new friends.""" - - def get(self): - user = self.request.get('user') - query = datastore.UserFriends.all() - query.filter('deleted = ', False) - query.filter('username = ', user) - friends = query.fetch(50) - template_values = { - 'friends': friends, - 'user': user - } - path = os.path.join(os.path.dirname(__file__), - 'templates', 'view_friends.html') - self.response.out.write(template.render(path, template_values)) - - -class DeleteFriendPage(webapp.RequestHandler): - """Processes delete friend request.""" - - def get(self): - user = self.request.get('user') - friend = self.request.get('friend') - query = datastore.UserFriends.all() - query.filter('username =', user) - query.filter('friend_handle =', friend) - result = query.get() - result.deleted = True - result.updated = datetime.datetime.utcnow() - result.put() - - self.redirect('/user_friends?user=' + user) + path = os.path.join(os.path.dirname(__file__), 'templates', 'contacts.html') + self.response.out.write(template.render(path, template_values)) def main(): - application = webapp.WSGIApplication( - [('/add_user', UserInsertPage), - ('/users', UsersListPage), - ('/add_credentials', UserCredentialsInsertPage), - ('/add_friend', UserFriendsInsertPage), - ('/user_friends', UserFriendsListPage), - ('/delete_friend', DeleteFriendPage), - ('/edit_user', UserEditPage) - ], - debug=True) - wsgiref.handlers.CGIHandler().run(application) + application = webapp.WSGIApplication( + [('/', ContactsListPage), + ('/add_contact', ContactInsertPage), + ('/edit_contact', ContactEditPage), + ('/delete_contact', ContactDeletePage), + ('/avatar', AvatarViewPage), + ('/edit_avatar', AvatarEditPage) + ], + debug=True) + wsgiref.handlers.CGIHandler().run(application) if __name__ == '__main__': main() \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/index.yaml b/samples/SampleSyncAdapter/samplesyncadapter_server/index.yaml index 83ceea006..6f02db528 100644 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/index.yaml +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/index.yaml @@ -1,14 +1,15 @@ -indexes: +# Copyright (C) 2010 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. -# This index.yaml is automatically updated whenever the dev_appserver -# detects that a new type of query is run. If you want to manage the -# index.yaml file manually, remove the above marker line (the line -# saying "# AUTOGENERATED"). If you want to manage some indexes -# manually, move them above the marker line. The index.yaml file is -# automatically uploaded to the admin console when you next deploy -# your application using appcfg.py. - -- kind: UserFriends - properties: - - name: username - - name: updated \ No newline at end of file +# AUTOGENERATED diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/main.py b/samples/SampleSyncAdapter/samplesyncadapter_server/main.py deleted file mode 100644 index 2d7c5c78e..000000000 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/main.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/python2.5 - -# Copyright (C) 2010 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not -# use this file except in compliance with the License. You may obtain a copy of -# the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations under -# the License. - -"""Handlers for Sample SyncAdapter services. - -Contains several RequestHandler subclasses used to handle post operations. -This script is designed to be run directly as a WSGI application. - - Authenticate: Handles user requests for authentication. - FetchFriends: Handles user requests for friend list. - FriendData: Stores information about user's friends. -""" - -import cgi -from datetime import datetime -from django.utils import simplejson -from google.appengine.api import users -from google.appengine.ext import db -from google.appengine.ext import webapp -from model import datastore -import wsgiref.handlers - - -class Authenticate(webapp.RequestHandler): - """Handles requests for login and authentication. - - UpdateHandler only accepts post events. It expects each - request to include username and password fields. It returns authtoken - after successful authentication and "invalid credentials" error otherwise. - """ - - def post(self): - self.username = self.request.get('username') - self.password = self.request.get('password') - password = datastore.UserCredentials.get(self.username) - if password == self.password: - self.response.set_status(200, 'OK') - # return the password as AuthToken - self.response.out.write(password) - else: - self.response.set_status(401, 'Invalid Credentials') - - -class FetchFriends(webapp.RequestHandler): - """Handles requests for fetching user's friendlist. - - UpdateHandler only accepts post events. It expects each - request to include username and authtoken. If the authtoken is valid - it returns user's friend info in JSON format.It uses helper - class FriendData to fetch user's friendlist. - """ - - def post(self): - self.username = self.request.get('username') - self.password = self.request.get('password') - self.timestamp = None - timestamp = self.request.get('timestamp') - if timestamp: - self.timestamp = datetime.strptime(timestamp, '%Y/%m/%d %H:%M') - password = datastore.UserCredentials.get(self.username) - if password == self.password: - self.friend_list = [] - friends = datastore.UserFriends.get_friends(self.username) - if friends: - for friend in friends: - friend_handle = getattr(friend, 'friend_handle') - - if self.timestamp is None or getattr(friend, 'updated') > self.timestamp: - if (getattr(friend, 'deleted')) == True: - friend = {} - friend['u'] = friend_handle - friend['d'] = 'true' - friend['i'] = str(datastore.User.get_user_id(friend_handle)) - self.friend_list.append(friend) - else: - FriendsData(self.friend_list, friend_handle) - else: - if datastore.User.get_user_last_updated(friend_handle) > self.timestamp: - FriendsData(self.friend_list, friend_handle) - self.response.set_status(200) - self.response.out.write(toJSON(self.friend_list)) - else: - self.response.set_status(401, 'Invalid Credentials') - -class FetchStatus(webapp.RequestHandler): - """Handles requests fetching friend statuses. - - UpdateHandler only accepts post events. It expects each - request to include username and authtoken. If the authtoken is valid - it returns status info in JSON format. - """ - - def post(self): - self.username = self.request.get('username') - self.password = self.request.get('password') - password = datastore.UserCredentials.get(self.username) - if password == self.password: - self.status_list = [] - friends = datastore.UserFriends.get_friends(self.username) - if friends: - for friend in friends: - friend_handle = getattr(friend, 'friend_handle') - status_text = datastore.User.get_user_status(friend_handle) - user_id = datastore.User.get_user_id(friend_handle) - status = {} - status['i'] = str(user_id) - status['s'] = status_text - self.status_list.append(status) - self.response.set_status(200) - self.response.out.write(toJSON(self.status_list)) - else: - self.response.set_status(401, 'Invalid Credentials') - - def toJSON(self): - """Dumps the data represented by the object to JSON for wire transfer.""" - return simplejson.dumps(self.friend_list) - - -def toJSON(object): - """Dumps the data represented by the object to JSON for wire transfer.""" - return simplejson.dumps(object) - -class FriendsData(object): - """Holds data for user's friends. - - This class knows how to serialize itself to JSON. - """ - __FIELD_MAP = { - 'handle': 'u', - 'firstname': 'f', - 'lastname': 'l', - 'status': 's', - 'phone_home': 'h', - 'phone_office': 'o', - 'phone_mobile': 'm', - 'email': 'e', - } - - def __init__(self, friend_list, username): - obj = datastore.User.get_user_info(username) - friend = {} - for obj_name, json_name in self.__FIELD_MAP.items(): - if hasattr(obj, obj_name): - friend[json_name] = str(getattr(obj, obj_name)) - friend['i'] = str(obj.key().id()) - friend_list.append(friend) - - -def main(): - application = webapp.WSGIApplication( - [('/auth', Authenticate), - ('/login', Authenticate), - ('/fetch_friend_updates', FetchFriends), - ('/fetch_status', FetchStatus), - ], - debug=True) - wsgiref.handlers.CGIHandler().run(application) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/model/datastore.py b/samples/SampleSyncAdapter/samplesyncadapter_server/model/datastore.py index 71bd18ab5..1f9163321 100644 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/model/datastore.py +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/model/datastore.py @@ -14,80 +14,51 @@ # License for the specific language governing permissions and limitations under # the License. -"""Represents user's contact information, friends and credentials.""" +"""Represents user's contact information""" from google.appengine.ext import db -class User(db.Model): +class Contact(db.Model): """Data model class to hold user objects.""" handle = db.StringProperty(required=True) - firstname = db.TextProperty() - lastname = db.TextProperty() - status = db.TextProperty() + firstname = db.StringProperty() + lastname = db.StringProperty() phone_home = db.PhoneNumberProperty() phone_office = db.PhoneNumberProperty() phone_mobile = db.PhoneNumberProperty() email = db.EmailProperty() + status = db.TextProperty() + avatar = db.BlobProperty() deleted = db.BooleanProperty() updated = db.DateTimeProperty(auto_now_add=True) @classmethod - def get_user_info(cls, username): + def get_contact_info(cls, username): if username not in (None, ''): query = cls.gql('WHERE handle = :1', username) return query.get() return None @classmethod - def get_user_last_updated(cls, username): + def get_contact_last_updated(cls, username): if username not in (None, ''): query = cls.gql('WHERE handle = :1', username) return query.get().updated return None @classmethod - def get_user_id(cls, username): + def get_contact_id(cls, username): if username not in (None, ''): query = cls.gql('WHERE handle = :1', username) return query.get().key().id() return None @classmethod - def get_user_status(cls, username): + def get_contact_status(cls, username): if username not in (None, ''): query = cls.gql('WHERE handle = :1', username) return query.get().status return None - -class UserCredentials(db.Model): - """Data model class to hold credentials for a Voiper user.""" - - username = db.StringProperty(required=True) - password = db.StringProperty() - - @classmethod - def get(cls, username): - if username not in (None, ''): - query = cls.gql('WHERE username = :1', username) - return query.get().password - return None - - -class UserFriends(db.Model): - """Data model class to hold user's friendlist info.""" - - username = db.StringProperty() - friend_handle = db.StringProperty(required=True) - updated = db.DateTimeProperty(auto_now_add=True) - deleted = db.BooleanProperty() - - @classmethod - def get_friends(cls, username): - if username not in (None, ''): - query = cls.gql('WHERE username = :1', username) - friends = query.fetch(50) - return friends - return None \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/static/css/main.css b/samples/SampleSyncAdapter/samplesyncadapter_server/static/css/main.css new file mode 100644 index 000000000..eab304d84 --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/static/css/main.css @@ -0,0 +1,77 @@ +/** + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. +*/ + +html,body { + height: 100%; +} + +body { + padding: 20px; + margin: 0; + background-color: #fff; + font-family: Verdana, Arial, Helvetica, sans-serif; + text-align: left; +} + +a { + color: #0033cc; +} + +h1 { + font-family: Arial; + font-weight: normal; + border-bottom: solid 1px #ccc; + margin: 0 0 20px 0; + padding: 0 0 5px 0; +} + +h3 { + font-family: Arial; + font-weight: bold; + line-height: 2em; +} + +table { + border-collapse: collapse; + margin-bottom: 20px; +} + +th,td { + padding: 5px 8px; + text-align: left; +} + +td.center { + text-align: center; +} + +.deleted td { + text-decoration: line-through; +} + +.data th { + font-weight: normal; + border-bottom: solid 1px #000; +} + +.data td { + border-bottom: solid 1px #eee; +} + +.form th { + font-weight: normal; + text-align: right; +} \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/static/img/default_avatar.gif b/samples/SampleSyncAdapter/samplesyncadapter_server/static/img/default_avatar.gif new file mode 100644 index 0000000000000000000000000000000000000000..5089e955946682a5ecd03a4dda8cf0768b673c22 GIT binary patch literal 2989 zcmb7F2~<xogn)trii$OWWu6fchXPemKomu(RB8Tw;J{z)`hWeq*1Os7oPEyO=O*{Wm*wL= zhm#4mfp!3PUI%-B2K$vD?G`xp3rMddAFtFsQDu}-J>$n}^Pg&Lvua&(>gJzm@Xl@U zlQ;Ow8`=5IE6z3t6}D_R-x_wYO>nt=b4f?+wT^`Acemf@OjO=WD!-pn`Rn28uJoGj zjQXC;rbmjF$NBBO7w+|yJ{&0P9<1yctnD3YefFw-VEDm{w>?86kKeo-`2GE>KmYs_ z{GZEo6eR(`0H&%BEHAGBUr%2rhLbY@Y7CA@Bp_e_L!{;L<*gR zqXk=F;qgUZG=j&76+jL+E=EW=HYVf=h8u9$NriuJqsMZ5| z1t0=iqv12&6x8~OZ$DXGxbUFyXAg$g;Hi@QM<%t&n^ejkgu;Y)+j#r25DVZNZ@l2A za=4T)rM?^=K2?vK%P>Q|zaCte`SOH3 zF`tX!Gk`7L6wVj$#vK#e|8P%Y{x^uvmI?p5EdUfq05EX*VjB(^pCgEV(-&LJQUJbr z0cS$<7n?P_5_%^AC~@U(5yyV4hae3A#PGJkdk=uuCQ+Q29}yXYIWrh;md0`Gf68;q&hHxPiPdF@oRjG#I9h`CqfU#7S>V39KHI@TK zJODr`z;8SN`2PY>S1FH*7SoLnRG$*p2%@7Kc$tqqN)E9l=!VTjo5Potpr!PbaTTABrz z8B8PEolMgu(wER}3+;}xvU246Gk)^2x55l{G*MzXQJt)7JllE^g?Qm|ivD3ob3+o5 zK&ZG^ahFJ$VdZ!LYf7UNwX`0h&!68X4wiKgb*E@ONi%u<;^kPy2cmw8(n@|yCIDkL z(|Y}ts2=(lj)g^^=N5idYH}7BcX;GHR2#7$@0G3nj!677-xT_ zxNStdyk6yEU(os3$H}T9&Ot5k-Cj$XG^>Ih>5jF^uV^VQ8)+yL`9@Qhb*1WLdnUGQ zSP&8;uVpLO6mtBF^wLLunR39Mq`}A{CpIFajIzPj4Z76R zC^fljG_FjR9+q5|rinPMp6*>2G~cdXS#~cXZR*@Ww%pAiif-=wdr-v6FxTWi7(>>_ z6%z;=S7}PZg^VBe?_~SfSjf!OX737SsneM2E$0*;a#gpsK*@Gv5xwb=x=b4Ci7u*l zog#X>g2XX$W4hZ(r-b;I6^#-!kc3X{t<>#XQ$fz-s}b>K^D%Fm=01yTAN^?^choZa zu0}2{bH}_nshUF`enyw;_>d4bk7+KlorInj0^Nycc*P?K|q-e{xJQ8nx_OilF zntL_dYCwLYQejY3w$F{8U)jH#e!8~tIR9d!ZhpSFG+<+Y@!i*>g7aDa(^Cu2IZszP za32ZZFT3(|RsM@>SWmw&s8@H_i*v)R|J-=Jr98i|wEGXTvGPgzANC6MSq0I`(cdmQ zUKw*upq3~31UKG#xk~UVMB6Ikd3mv`!10!*YTZzkK~l!%+P-u3&ehHPo}aCyGzoI+ z&P9EoR2{7?7+yaAW&cHcW?ldJMyvZX9BaH@1`pSnJqo^1wsggIN&Ot(rEhM3Q&C^o z>=iLHwmmpxu&~o7d8t#UA2(s-4tML|*XJ^$X11uc$A@mcU9v!jAu+a5Z@->u-TyY` z`=1Yb6f=D;l`pR?tElbbX?H&*rKwArmPz~F4t;m5YVax97?p<1b0O(mWzE9A*6umo z^gv}rrH#32$NBvz^&pa5GCeH3Za2Oj^ghf+h$MFkcBPWzS}3k_1dlUbRNlb1JV7nD3(#80GZh91_|t+g)t%GPv00ehtP@Lgq8%^s&f zr&CmF7ezxVtkQdNya-9-;aF&$z?S}5u7!^ynh3a-0&X& literal 0 HcmV?d00001 diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html new file mode 100644 index 000000000..b668d339c --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html @@ -0,0 +1,53 @@ + + + + + SampleSync: Contacts for '{{ username }}' + + + +

SampleSync: Contacts for '{{ username }}'

+ + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + + + + {% endfor %} +
 IdNameEmailHomeOfficeMobileStatus
+ + {{ contact.key.id }}{{ contact.firstname }} {{ contact.lastname }}{{ contact.email }}{{ contact.phone_home }}{{ contact.phone_office }}{{ contact.phone_mobile }}{{ contact.status }}
+ + Add Contact + + \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/edit_avatar.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/edit_avatar.html new file mode 100644 index 000000000..49cd3d3c9 --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/edit_avatar.html @@ -0,0 +1,42 @@ + + + + + SampleSync: Edit Picture + + + +

SampleSync: Edit Picture

+
+

Current Avatar:

+
+ {% if avatar %} + + {% else %} + You haven't added a picture for this friend... + {% endif %} +
+

New Avatar:

+

Please select a file containing the image you'd like to use for this friend

+ +

 

+ + + +
+ + \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/simple_form.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/simple_form.html new file mode 100644 index 000000000..1d728192e --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/simple_form.html @@ -0,0 +1,38 @@ + + + + + SampleSync: {{ title }} + + + +

SampleSync: {{ header }}

+
+ + {{ form_data_rows }} +
+ + + {% if has_contactId %} + + {% endif %} + {% if has_handle %} + + {% endif %} +
+ + diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html deleted file mode 100644 index 044c352f2..000000000 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html +++ /dev/null @@ -1,19 +0,0 @@ - - -

Sample Sync Adapter

- -

-

List of Users

- - -{% for user in users %} - - -{% endfor %} -
- {{ user.firstname }}  {{ user.lastname }} -   Friends
-

- - Insert More \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/view_friends.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/view_friends.html deleted file mode 100644 index d4ef89283..000000000 --- a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/view_friends.html +++ /dev/null @@ -1,17 +0,0 @@ - - -

Sample Sync Adapter

- -

- -{{user}}'s friends - -{% for friend in friends %} - -{% endfor %} -
- {{ friend.friend_handle }} Remove -
-

- - Add More \ No newline at end of file diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/web_services.py b/samples/SampleSyncAdapter/samplesyncadapter_server/web_services.py new file mode 100644 index 000000000..3028addaa --- /dev/null +++ b/samples/SampleSyncAdapter/samplesyncadapter_server/web_services.py @@ -0,0 +1,400 @@ +#!/usr/bin/python2.5 + +# Copyright (C) 2010 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +""" +Handlers for Sample SyncAdapter services. + +Contains several RequestHandler subclasses used to handle post operations. +This script is designed to be run directly as a WSGI application. + +""" + +import cgi +import logging +import time as _time +from datetime import datetime +from django.utils import simplejson +from google.appengine.api import users +from google.appengine.ext import db +from google.appengine.ext import webapp +from model import datastore +import wsgiref.handlers + + +class BaseWebServiceHandler(webapp.RequestHandler): + """ + Base class for our web services. We put some common helper + functions here. + """ + + """ + Since we're only simulating a single user account, declare our + hard-coded credentials here, so that they're easy to see/find. + We actually accept any and all usernames that start with this + hard-coded values. So if ACCT_USER_NAME is 'user', then we'll + accept 'user', 'user75', 'userbuddy', etc, all as legal account + usernames. + """ + ACCT_USER_NAME = 'user' + ACCT_PASSWORD = 'test' + ACCT_AUTH_TOKEN = 'xyzzy' + + DATE_TIME_FORMAT = '%Y/%m/%d %H:%M' + + """ + Process a request to authenticate a client. We assume that the username + and password will be included in the request. If successful, we'll return + an authtoken as the only content. If auth fails, we'll send an "invalid + credentials" error. + We return a boolean indicating whether we were successful (true) or not (false). + In the event that this call fails, we will setup the response, so callers just + need to RETURN in the error case. + """ + def authenticate(self): + self.username = self.request.get('username') + self.password = self.request.get('password') + + logging.info('Authenticatng username: ' + self.username) + + if ((self.username != None) and + (self.username.startswith(BaseWebServiceHandler.ACCT_USER_NAME)) and + (self.password == BaseWebServiceHandler.ACCT_PASSWORD)): + # Authentication was successful - return our hard-coded + # auth-token as the only response. + self.response.set_status(200, 'OK') + self.response.out.write(BaseWebServiceHandler.ACCT_AUTH_TOKEN) + return True + else: + # Authentication failed. Return the standard HTTP auth failure + # response to let the client know. + self.response.set_status(401, 'Invalid Credentials') + return False + + """ + Validate the credentials of the client for a web service request. + The request should include username/password parameters that correspond + to our hard-coded single account values. + We return a boolean indicating whether we were successful (true) or not (false). + In the event that this call fails, we will setup the response, so callers just + need to RETURN in the error case. + """ + def validate(self): + self.username = self.request.get('username') + self.authtoken = self.request.get('authtoken') + + logging.info('Validating username: ' + self.username) + + if ((self.username != None) and + (self.username.startswith(BaseWebServiceHandler.ACCT_USER_NAME)) and + (self.authtoken == BaseWebServiceHandler.ACCT_AUTH_TOKEN)): + return True + else: + self.response.set_status(401, 'Invalid Credentials') + return False + + +class Authenticate(BaseWebServiceHandler): + """ + Handles requests for login and authentication. + + UpdateHandler only accepts post events. It expects each + request to include username and password fields. It returns authtoken + after successful authentication and "invalid credentials" error otherwise. + """ + + def post(self): + self.authenticate() + + def get(self): + """Used for debugging in a browser...""" + self.post() + + +class SyncContacts(BaseWebServiceHandler): + """Handles requests for fetching user's contacts. + + UpdateHandler only accepts post events. It expects each + request to include username and authtoken. If the authtoken is valid + it returns user's contact info in JSON format. + """ + + def get(self): + """Used for debugging in a browser...""" + self.post() + + def post(self): + logging.info('*** Starting contact sync ***') + if (not self.validate()): + return + + updated_contacts = [] + + # Process any client-side changes sent up in the request. + # Any new contacts that were added are included in the + # updated_contacts list, so that we return them to the + # client. That way, the client can see the serverId of + # the newly added contact. + client_buffer = self.request.get('contacts') + if ((client_buffer != None) and (client_buffer != '')): + self.process_client_changes(client_buffer, updated_contacts) + + # Add any contacts that have been updated on the server-side + # since the last sync by this client. + client_state = self.request.get('syncstate') + self.get_updated_contacts(client_state, updated_contacts) + + logging.info('Returning ' + str(len(updated_contacts)) + ' contact records') + + # Return the list of updated contacts to the client + self.response.set_status(200) + self.response.out.write(toJSON(updated_contacts)) + + def get_updated_contacts(self, client_state, updated_contacts): + logging.info('* Processing server changes') + timestamp = None + + base_url = self.request.host_url + + # The client sends the last high-water-mark that they successfully + # sync'd to in the syncstate parameter. It's opaque to them, but + # its actually a seconds-in-unix-epoch timestamp that we use + # as a baseline. + if client_state: + logging.info('Client sync state: ' + client_state) + timestamp = datetime.utcfromtimestamp(float(client_state)) + + # Keep track of the update/delete counts, so we can log it + # below. Makes debugging easier... + update_count = 0 + delete_count = 0 + + contacts = datastore.Contact.all() + if contacts: + # Find the high-water mark for the most recently updated friend. + # We'll return this as the syncstate (x) value for all the friends + # we return from this function. + high_water_date = datetime.min + for contact in contacts: + if (contact.updated > high_water_date): + high_water_date = contact.updated + high_water_mark = str(long(_time.mktime(high_water_date.utctimetuple())) + 1) + logging.info('New sync state: ' + high_water_mark) + + # Now build the updated_contacts containing all the friends that have been + # changed since the last sync + for contact in contacts: + # If our list of contacts we're returning already contains this + # contact (for example, it's a contact just uploaded from the client) + # then don't bother processing it any further... + if (self.list_contains_contact(updated_contacts, contact)): + continue + + handle = contact.handle + + if timestamp is None or contact.updated > timestamp: + if contact.deleted == True: + delete_count = delete_count + 1 + DeletedContactData(updated_contacts, handle, high_water_mark) + else: + update_count = update_count + 1 + UpdatedContactData(updated_contacts, handle, None, base_url, high_water_mark) + + logging.info('Server-side updates: ' + str(update_count)) + logging.info('Server-side deletes: ' + str(delete_count)) + + def process_client_changes(self, contacts_buffer, updated_contacts): + logging.info('* Processing client changes: ' + self.username) + + base_url = self.request.host_url + + # Build an array of generic objects containing contact data, + # using the Django built-in JSON parser + logging.info('Uploaded contacts buffer: ' + contacts_buffer) + json_list = simplejson.loads(contacts_buffer) + logging.info('Client-side updates: ' + str(len(json_list))) + + # Keep track of the number of new contacts the client sent to us, + # so that we can log it below. + new_contact_count = 0 + + for jcontact in json_list: + new_contact = False + id = self.safe_attr(jcontact, 'i') + if (id != None): + logging.info('Updating contact: ' + str(id)) + contact = datastore.Contact.get(db.Key.from_path('Contact', id)) + else: + logging.info('Creating new contact record') + new_contact = True + contact = datastore.Contact(handle='temp') + + # If the 'change' for this contact is that they were deleted + # on the client-side, all we want to do is set the deleted + # flag here, and we're done. + if (self.safe_attr(jcontact, 'd') == True): + contact.deleted = True + contact.put() + logging.info('Deleted contact: ' + contact.handle) + continue + + contact.firstname = self.safe_attr(jcontact, 'f') + contact.lastname = self.safe_attr(jcontact, 'l') + contact.phone_home = self.safe_attr(jcontact, 'h') + contact.phone_office = self.safe_attr(jcontact, 'o') + contact.phone_mobile = self.safe_attr(jcontact, 'm') + contact.email = self.safe_attr(jcontact, 'e') + contact.deleted = (self.safe_attr(jcontact, 'd') == 'true') + if (new_contact): + # New record - add them to db... + new_contact_count = new_contact_count + 1 + contact.handle = contact.firstname + '_' + contact.lastname + logging.info('Created new contact handle: ' + contact.handle) + contact.put() + logging.info('Saved contact: ' + contact.handle) + + # We don't save off the client_id value (thus we add it after + # the "put"), but we want it to be in the JSON object we + # serialize out, so that the client can match this contact + # up with the client version. + client_id = self.safe_attr(jcontact, 'c') + + # Create a high-water-mark for sync-state from the 'updated' time + # for this contact, so we return the correct value to the client. + high_water = str(long(_time.mktime(contact.updated.utctimetuple())) + 1) + + # Add new contacts to our updated_contacts, so that we return them + # to the client (so the client gets the serverId for the + # added contact) + if (new_contact): + UpdatedContactData(updated_contacts, contact.handle, client_id, base_url, + high_water) + + logging.info('Client-side adds: ' + str(new_contact_count)) + + def list_contains_contact(self, contact_list, contact): + if (contact is None): + return False + contact_id = str(contact.key().id()) + for next in contact_list: + if ((next != None) and (next['i'] == contact_id)): + return True + return False + + def safe_attr(self, obj, attr_name): + if attr_name in obj: + return obj[attr_name] + return None + +class ResetDatabase(BaseWebServiceHandler): + """ + Handles cron request to reset the contact database. + + We have a weekly cron task that resets the database back to a + few contacts, so that it doesn't grow to an absurd size. + """ + + def get(self): + # Delete all the existing contacts from the database + contacts = datastore.Contact.all() + for contact in contacts: + contact.delete() + + # Now create three sample contacts + contact1 = datastore.Contact(handle = 'juliet', + firstname = 'Juliet', + lastname = 'Capulet', + phone_mobile = '(650) 555-1000', + phone_home = '(650) 555-1001', + status = 'Wherefore art thou Romeo?') + contact1.put() + + contact2 = datastore.Contact(handle = 'romeo', + firstname = 'Romeo', + lastname = 'Montague', + phone_mobile = '(650) 555-2000', + phone_home = '(650) 555-2001', + status = 'I dream\'d a dream to-night') + contact2.put() + + contact3 = datastore.Contact(handle = 'tybalt', + firstname = 'Tybalt', + lastname = 'Capulet', + phone_mobile = '(650) 555-3000', + phone_home = '(650) 555-3001', + status = 'Have at thee, coward') + contact3.put() + + + + +def toJSON(object): + """Dumps the data represented by the object to JSON for wire transfer.""" + return simplejson.dumps(object) + +class UpdatedContactData(object): + """Holds data for user's contacts. + + This class knows how to serialize itself to JSON. + """ + __FIELD_MAP = { + 'handle': 'u', + 'firstname': 'f', + 'lastname': 'l', + 'status': 's', + 'phone_home': 'h', + 'phone_office': 'o', + 'phone_mobile': 'm', + 'email': 'e', + 'client_id': 'c' + } + + def __init__(self, contact_list, username, client_id, host_url, high_water_mark): + obj = datastore.Contact.get_contact_info(username) + contact = {} + for obj_name, json_name in self.__FIELD_MAP.items(): + if hasattr(obj, obj_name): + v = getattr(obj, obj_name) + if (v != None): + contact[json_name] = str(v) + else: + contact[json_name] = None + contact['i'] = str(obj.key().id()) + contact['a'] = host_url + "/avatar?id=" + str(obj.key().id()) + contact['x'] = high_water_mark + if (client_id != None): + contact['c'] = str(client_id) + contact_list.append(contact) + +class DeletedContactData(object): + def __init__(self, contact_list, username, high_water_mark): + obj = datastore.Contact.get_contact_info(username) + contact = {} + contact['d'] = 'true' + contact['i'] = str(obj.key().id()) + contact['x'] = high_water_mark + contact_list.append(contact) + +def main(): + application = webapp.WSGIApplication( + [('/auth', Authenticate), + ('/sync', SyncContacts), + ('/reset_database', ResetDatabase), + ], + debug=True) + wsgiref.handlers.CGIHandler().run(application) + +if __name__ == "__main__": + main() diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java index 49f92bfff..bc09da253 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java index 2c163be66..9fc72eb4d 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java @@ -1,18 +1,19 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; import android.app.Service; @@ -49,7 +50,7 @@ public class AuthenticationService extends Service { public IBinder onBind(Intent intent) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "getBinder()... returning the AccountAuthenticator binder for intent " - + intent); + + intent); } return mAuthenticator.getIBinder(); } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java index 0c79c5e99..2eb6ecd7c 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java @@ -1,38 +1,57 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.client.NetworkUtilities; + import android.accounts.AbstractAccountAuthenticator; import android.accounts.Account; import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; +import android.accounts.NetworkErrorException; import android.content.Context; import android.content.Intent; import android.os.Bundle; - -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.NetworkUtilities; +import android.text.TextUtils; +import android.util.Log; /** * This class is an implementation of AbstractAccountAuthenticator for - * authenticating accounts in the com.example.android.samplesync domain. + * authenticating accounts in the com.example.android.samplesync domain. The + * interesting thing that this class demonstrates is the use of authTokens as + * part of the authentication process. In the account setup UI, the user enters + * their username and password. But for our subsequent calls off to the service + * for syncing, we want to use an authtoken instead - so we're not continually + * sending the password over the wire. getAuthToken() will be called when + * SyncAdapter calls AccountManager.blockingGetAuthToken(). When we get called, + * we need to return the appropriate authToken for the specified account. If we + * already have an authToken stored in the account, we return that authToken. If + * we don't, but we do have a username and password, then we'll attempt to talk + * to the sample service to fetch an authToken. If that fails (or we didn't have + * a username/password), then we need to prompt the user - so we create an + * AuthenticatorActivity intent and return that. That will display the dialog + * that prompts the user for their login information. */ class Authenticator extends AbstractAccountAuthenticator { + /** The tag used to log to adb console. **/ + private static final String TAG = "Authenticator"; + // Authentication Service context private final Context mContext; @@ -43,10 +62,9 @@ class Authenticator extends AbstractAccountAuthenticator { @Override public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, - String authTokenType, String[] requiredFeatures, Bundle options) { - + String authTokenType, String[] requiredFeatures, Bundle options) { + Log.v(TAG, "addAccount()"); final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); @@ -54,54 +72,49 @@ class Authenticator extends AbstractAccountAuthenticator { } @Override - public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, - Bundle options) { - - if (options != null && options.containsKey(AccountManager.KEY_PASSWORD)) { - final String password = options.getString(AccountManager.KEY_PASSWORD); - final boolean verified = onlineConfirmPassword(account.name, password); - final Bundle result = new Bundle(); - result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, verified); - return result; - } - // Launch AuthenticatorActivity to confirm credentials - final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); - intent.putExtra(AuthenticatorActivity.PARAM_CONFIRM_CREDENTIALS, true); - intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); - final Bundle bundle = new Bundle(); - bundle.putParcelable(AccountManager.KEY_INTENT, intent); - return bundle; + public Bundle confirmCredentials( + AccountAuthenticatorResponse response, Account account, Bundle options) { + Log.v(TAG, "confirmCredentials()"); + return null; } @Override public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + Log.v(TAG, "editProperties()"); throw new UnsupportedOperationException(); } @Override public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, - String authTokenType, Bundle loginOptions) { + String authTokenType, Bundle loginOptions) throws NetworkErrorException { + Log.v(TAG, "getAuthToken()"); + // If the caller requested an authToken type we don't support, then + // return an error if (!authTokenType.equals(Constants.AUTHTOKEN_TYPE)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType"); return result; } + + // Extract the username and password from the Account Manager, and ask + // the server for an appropriate AuthToken. final AccountManager am = AccountManager.get(mContext); final String password = am.getPassword(account); if (password != null) { - final boolean verified = onlineConfirmPassword(account.name, password); - if (verified) { + final String authToken = NetworkUtilities.authenticate(account.name, password); + if (!TextUtils.isEmpty(authToken)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); - result.putString(AccountManager.KEY_AUTHTOKEN, password); + result.putString(AccountManager.KEY_AUTHTOKEN, authToken); return result; } } - // the password was missing or incorrect, return an Intent to an - // Activity that will prompt the user for the password. + + // If we get here, then we couldn't access the user's password - so we + // need to re-prompt them for their credentials. We do that by creating + // an intent to display our AuthenticatorActivity panel. final Intent intent = new Intent(mContext, AuthenticatorActivity.class); intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); @@ -113,39 +126,27 @@ class Authenticator extends AbstractAccountAuthenticator { @Override public String getAuthTokenLabel(String authTokenType) { - if (Constants.AUTHTOKEN_TYPE.equals(authTokenType)) { - return mContext.getString(R.string.label); - } + // null means we don't support multiple authToken types + Log.v(TAG, "getAuthTokenLabel()"); return null; } @Override - public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, - String[] features) { - + public Bundle hasFeatures( + AccountAuthenticatorResponse response, Account account, String[] features) { + // This call is used to query whether the Authenticator supports + // specific features. We don't expect to get called, so we always + // return false (no) for any queries. + Log.v(TAG, "hasFeatures()"); final Bundle result = new Bundle(); result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false); return result; } - /** - * Validates user's password on the server - */ - private boolean onlineConfirmPassword(String username, String password) { - return NetworkUtilities - .authenticate(username, password, null/* Handler */, null/* Context */); - } - @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, - String authTokenType, Bundle loginOptions) { - - final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); - intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); - intent.putExtra(AuthenticatorActivity.PARAM_CONFIRM_CREDENTIALS, false); - final Bundle bundle = new Bundle(); - bundle.putParcelable(AccountManager.KEY_INTENT, intent); - return bundle; + String authTokenType, Bundle loginOptions) { + Log.v(TAG, "updateCredentials()"); + return null; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java index 4e1ee2a75..2a3c0fc4c 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java @@ -1,20 +1,25 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.NetworkUtilities; + import android.accounts.Account; import android.accounts.AccountAuthenticatorActivity; import android.accounts.AccountManager; @@ -23,6 +28,7 @@ import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.DialogInterface; import android.content.Intent; +import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.provider.ContactsContract; @@ -33,41 +39,36 @@ import android.view.Window; import android.widget.EditText; import android.widget.TextView; -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.NetworkUtilities; - /** * Activity which displays login screen to the user. */ public class AuthenticatorActivity extends AccountAuthenticatorActivity { - - /** The Intent flag to confirm credentials. **/ + /** The Intent flag to confirm credentials. */ public static final String PARAM_CONFIRM_CREDENTIALS = "confirmCredentials"; - /** The Intent extra to store password. **/ + /** The Intent extra to store password. */ public static final String PARAM_PASSWORD = "password"; - /** The Intent extra to store username. **/ + /** The Intent extra to store username. */ public static final String PARAM_USERNAME = "username"; - /** The Intent extra to store authtoken type. **/ + /** The Intent extra to store username. */ public static final String PARAM_AUTHTOKEN_TYPE = "authtokenType"; - /** The tag used to log to adb console. **/ + /** The tag used to log to adb console. */ private static final String TAG = "AuthenticatorActivity"; - private AccountManager mAccountManager; - private Thread mAuthThread; + /** Keep track of the login task so can cancel it if requested */ + private UserLoginTask mAuthTask = null; - private String mAuthtoken; - - private String mAuthtokenType; + /** Keep track of the progress dialog so we can dismiss it */ + private ProgressDialog mProgressDialog = null; /** * If set we are just checking that the user knows their credentials; this - * doesn't cause the user's password to be changed on the device. + * doesn't cause the user's password or authToken to be changed on the + * device. */ private Boolean mConfirmCredentials = false; @@ -99,14 +100,13 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { Log.i(TAG, "loading data from Intent"); final Intent intent = getIntent(); mUsername = intent.getStringExtra(PARAM_USERNAME); - mAuthtokenType = intent.getStringExtra(PARAM_AUTHTOKEN_TYPE); mRequestNewAccount = mUsername == null; mConfirmCredentials = intent.getBooleanExtra(PARAM_CONFIRM_CREDENTIALS, false); Log.i(TAG, " request new: " + mRequestNewAccount); requestWindowFeature(Window.FEATURE_LEFT_ICON); setContentView(R.layout.login_activity); - getWindow().setFeatureDrawableResource(Window.FEATURE_LEFT_ICON, - android.R.drawable.ic_dialog_alert); + getWindow().setFeatureDrawableResource( + Window.FEATURE_LEFT_ICON, android.R.drawable.ic_dialog_alert); mMessage = (TextView) findViewById(R.id.message); mUsernameEdit = (EditText) findViewById(R.id.username_edit); mPasswordEdit = (EditText) findViewById(R.id.password_edit); @@ -118,27 +118,31 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { * {@inheritDoc} */ @Override - protected Dialog onCreateDialog(int id) { + protected Dialog onCreateDialog(int id, Bundle args) { final ProgressDialog dialog = new ProgressDialog(this); dialog.setMessage(getText(R.string.ui_activity_authenticating)); dialog.setIndeterminate(true); dialog.setCancelable(true); dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { public void onCancel(DialogInterface dialog) { - Log.i(TAG, "dialog cancel has been invoked"); - if (mAuthThread != null) { - mAuthThread.interrupt(); - finish(); + Log.i(TAG, "user cancelling authentication"); + if (mAuthTask != null) { + mAuthTask.cancel(true); } } }); + // We save off the progress dialog in a field so that we can dismiss + // it later. We can't just call dismissDialog(0) because the system + // can lose track of our dialog if there's an orientation change. + mProgressDialog = dialog; return dialog; } /** * Handles onClick event on the Submit button. Sends username/password to - * the server for authentication. - * + * the server for authentication. The button is configured to call + * handleLogin() in the layout XML. + * * @param view The Submit button for which this method is invoked */ public void handleLogin(View view) { @@ -149,11 +153,11 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { if (TextUtils.isEmpty(mUsername) || TextUtils.isEmpty(mPassword)) { mMessage.setText(getMessage()); } else { + // Show a progress dialog, and kick off a background task to perform + // the user login attempt. showProgress(); - // Start authenticating... - mAuthThread = - NetworkUtilities.attemptAuth(mUsername, mPassword, mHandler, - AuthenticatorActivity.this); + mAuthTask = new UserLoginTask(); + mAuthTask.execute(); } } @@ -161,8 +165,8 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { * Called when response is received from the server for confirm credentials * request. See onAuthenticationResult(). Sets the * AccountAuthenticatorResult which is sent back to the caller. - * - * @param the confirmCredentials result. + * + * @param result the confirmCredentials result. */ private void finishConfirmCredentials(boolean result) { Log.i(TAG, "finishConfirmCredentials()"); @@ -178,12 +182,13 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { /** * Called when response is received from the server for authentication * request. See onAuthenticationResult(). Sets the - * AccountAuthenticatorResult which is sent back to the caller. Also sets - * the authToken in AccountManager for this account. - * - * @param the confirmCredentials result. + * AccountAuthenticatorResult which is sent back to the caller. We store the + * authToken that's returned from the server as the 'password' for this + * account - so we're never storing the user's actual password locally. + * + * @param result the confirmCredentials result. */ - private void finishLogin() { + private void finishLogin(String authToken) { Log.i(TAG, "finishLogin()"); final Account account = new Account(mUsername, Constants.ACCOUNT_TYPE); @@ -195,37 +200,35 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { mAccountManager.setPassword(account, mPassword); } final Intent intent = new Intent(); - mAuthtoken = mPassword; intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, mUsername); intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); - if (mAuthtokenType != null && mAuthtokenType.equals(Constants.AUTHTOKEN_TYPE)) { - intent.putExtra(AccountManager.KEY_AUTHTOKEN, mAuthtoken); - } setAccountAuthenticatorResult(intent.getExtras()); setResult(RESULT_OK, intent); finish(); } - /** - * Hides the progress UI for a lengthy operation. - */ - private void hideProgress() { - dismissDialog(0); - } - /** * Called when the authentication process completes (see attemptLogin()). + * + * @param authToken the authentication token returned by the server, or NULL if + * authentication failed. */ - public void onAuthenticationResult(boolean result) { + public void onAuthenticationResult(String authToken) { + + boolean success = ((authToken != null) && (authToken.length() > 0)); + Log.i(TAG, "onAuthenticationResult(" + success + ")"); + + // Our task is complete, so clear it out + mAuthTask = null; - Log.i(TAG, "onAuthenticationResult(" + result + ")"); // Hide the progress dialog hideProgress(); - if (result) { + + if (success) { if (!mConfirmCredentials) { - finishLogin(); + finishLogin(authToken); } else { - finishConfirmCredentials(true); + finishConfirmCredentials(success); } } else { Log.e(TAG, "onAuthenticationResult: failed to authenticate"); @@ -241,6 +244,16 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { } } + public void onAuthenticationCancel() { + Log.i(TAG, "onAuthenticationCancel()"); + + // Our task is complete, so clear it out + mAuthTask = null; + + // Hide the progress dialog + hideProgress(); + } + /** * Returns the message to be displayed at the top of the login dialog box. */ @@ -265,4 +278,49 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { private void showProgress() { showDialog(0); } + + /** + * Hides the progress UI for a lengthy operation. + */ + private void hideProgress() { + if (mProgressDialog != null) { + mProgressDialog.dismiss(); + mProgressDialog = null; + } + } + + /** + * Represents an asynchronous task used to authenticate a user against the + * SampleSync Service + */ + public class UserLoginTask extends AsyncTask { + + @Override + protected String doInBackground(Void... params) { + // We do the actual work of authenticating the user + // in the NetworkUtilities class. + try { + return NetworkUtilities.authenticate(mUsername, mPassword); + } catch (Exception ex) { + Log.e(TAG, "UserLoginTask.doInBackground: failed to authenticate"); + Log.i(TAG, ex.toString()); + return null; + } + } + + @Override + protected void onPostExecute(final String authToken) { + // On a successful authentication, call back into the Activity to + // communicate the authToken (or null for an error). + onAuthenticationResult(authToken); + } + + @Override + protected void onCancelled() { + // If the action was canceled (by the user clicking the cancel + // button in the progress dialog), then call back into the + // activity to let it know. + onAuthenticationCancel(); + } + } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java index 7824a4d20..bebcd729b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java @@ -1,23 +1,27 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.client; import android.accounts.Account; import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.os.Handler; +import android.text.TextUtils; import android.util.Log; import com.example.android.samplesync.authenticator.AuthenticatorActivity; @@ -37,11 +41,19 @@ import org.apache.http.message.BasicNameValuePair; import org.apache.http.params.HttpConnectionParams; import org.apache.http.params.HttpParams; import org.apache.http.util.EntityUtils; +import org.json.JSONObject; import org.json.JSONArray; import org.json.JSONException; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -52,31 +64,26 @@ import java.util.TimeZone; * Provides utility methods for communicating with the server. */ final public class NetworkUtilities { - - /** The tag used to log to adb console. **/ + /** The tag used to log to adb console. */ private static final String TAG = "NetworkUtilities"; - - /** The Intent extra to store password. **/ - public static final String PARAM_PASSWORD = "password"; - - /** The Intent extra to store username. **/ + /** POST parameter name for the user's account name */ public static final String PARAM_USERNAME = "username"; - - public static final String PARAM_UPDATED = "timestamp"; - - public static final String USER_AGENT = "AuthenticationService/1.0"; - - public static final int REGISTRATION_TIMEOUT_MS = 30 * 1000; // ms - - public static final String BASE_URL = "https://samplesyncadapter.appspot.com"; - + /** POST parameter name for the user's password */ + public static final String PARAM_PASSWORD = "password"; + /** POST parameter name for the user's authentication token */ + public static final String PARAM_AUTH_TOKEN = "authtoken"; + /** POST parameter name for the client's last-known sync state */ + public static final String PARAM_SYNC_STATE = "syncstate"; + /** POST parameter name for the sending client-edited contact info */ + public static final String PARAM_CONTACTS_DATA = "contacts"; + /** Timeout (in ms) we specify for each http request */ + public static final int HTTP_REQUEST_TIMEOUT_MS = 30 * 1000; + /** Base URL for the v2 Sample Sync Service */ + public static final String BASE_URL = "https://samplesyncadapter2.appspot.com"; + /** URI for authentication service */ public static final String AUTH_URI = BASE_URL + "/auth"; - - public static final String FETCH_FRIEND_UPDATES_URI = BASE_URL + "/fetch_friend_updates"; - - public static final String FETCH_STATUS_URI = BASE_URL + "/fetch_status"; - - private static HttpClient mHttpClient; + /** URI for sync service */ + public static final String SYNC_CONTACTS_URI = BASE_URL + "/sync"; private NetworkUtilities() { } @@ -84,224 +91,188 @@ final public class NetworkUtilities { /** * Configures the httpClient to connect to the URL provided. */ - public static void maybeCreateHttpClient() { - if (mHttpClient == null) { - mHttpClient = new DefaultHttpClient(); - final HttpParams params = mHttpClient.getParams(); - HttpConnectionParams.setConnectionTimeout(params, REGISTRATION_TIMEOUT_MS); - HttpConnectionParams.setSoTimeout(params, REGISTRATION_TIMEOUT_MS); - ConnManagerParams.setTimeout(params, REGISTRATION_TIMEOUT_MS); - } + public static HttpClient getHttpClient() { + HttpClient httpClient = new DefaultHttpClient(); + final HttpParams params = httpClient.getParams(); + HttpConnectionParams.setConnectionTimeout(params, HTTP_REQUEST_TIMEOUT_MS); + HttpConnectionParams.setSoTimeout(params, HTTP_REQUEST_TIMEOUT_MS); + ConnManagerParams.setTimeout(params, HTTP_REQUEST_TIMEOUT_MS); + return httpClient; } /** - * Executes the network requests on a separate thread. - * - * @param runnable The runnable instance containing network mOperations to - * be executed. + * Connects to the SampleSync test server, authenticates the provided + * username and password. + * + * @param username The server account username + * @param password The server account password + * @return String The authentication token returned by the server (or null) */ - public static Thread performOnBackgroundThread(final Runnable runnable) { - final Thread t = new Thread() { - @Override - public void run() { - try { - runnable.run(); - } finally { - } - } - }; - t.start(); - return t; - } - - /** - * Connects to the Voiper server, authenticates the provided username and - * password. - * - * @param username The user's username - * @param password The user's password - * @param handler The hander instance from the calling UI thread. - * @param context The context of the calling Activity. - * @return boolean The boolean result indicating whether the user was - * successfully authenticated. - */ - public static boolean authenticate(String username, String password, Handler handler, - final Context context) { + public static String authenticate(String username, String password) { final HttpResponse resp; final ArrayList params = new ArrayList(); params.add(new BasicNameValuePair(PARAM_USERNAME, username)); params.add(new BasicNameValuePair(PARAM_PASSWORD, password)); - HttpEntity entity = null; + final HttpEntity entity; try { entity = new UrlEncodedFormEntity(params); } catch (final UnsupportedEncodingException e) { // this should never happen. - throw new AssertionError(e); + throw new IllegalStateException(e); } + Log.i(TAG, "Authenticating to: " + AUTH_URI); final HttpPost post = new HttpPost(AUTH_URI); post.addHeader(entity.getContentType()); post.setEntity(entity); - maybeCreateHttpClient(); try { - resp = mHttpClient.execute(post); + resp = getHttpClient().execute(post); + String authToken = null; if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "Successful authentication"); + InputStream istream = (resp.getEntity() != null) ? resp.getEntity().getContent() + : null; + if (istream != null) { + BufferedReader ireader = new BufferedReader(new InputStreamReader(istream)); + authToken = ireader.readLine().trim(); } - sendResult(true, handler, context); - return true; + } + if ((authToken != null) && (authToken.length() > 0)) { + Log.v(TAG, "Successful authentication"); + return authToken; } else { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "Error authenticating" + resp.getStatusLine()); - } - sendResult(false, handler, context); - return false; + Log.e(TAG, "Error authenticating" + resp.getStatusLine()); + return null; } } catch (final IOException e) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "IOException when getting authtoken", e); - } - sendResult(false, handler, context); - return false; + Log.e(TAG, "IOException when getting authtoken", e); + return null; } finally { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "getAuthtoken completing"); - } + Log.v(TAG, "getAuthtoken completing"); } } /** - * Sends the authentication response from server back to the caller main UI - * thread through its handler. - * - * @param result The boolean holding authentication result - * @param handler The main UI thread's handler instance. - * @param context The caller Activity's context. + * Perform 2-way sync with the server-side contacts. We send a request that + * includes all the locally-dirty contacts so that the server can process + * those changes, and we receive (and return) a list of contacts that were + * updated on the server-side that need to be updated locally. + * + * @param account The account being synced + * @param authtoken The authtoken stored in the AccountManager for this + * account + * @param serverSyncState A token returned from the server on the last sync + * @param dirtyContacts A list of the contacts to send to the server + * @return A list of contacts that we need to update locally */ - private static void sendResult(final Boolean result, final Handler handler, - final Context context) { - if (handler == null || context == null) { - return; + public static List syncContacts( + Account account, String authtoken, long serverSyncState, List dirtyContacts) + throws JSONException, ParseException, IOException, AuthenticationException { + // Convert our list of User objects into a list of JSONObject + List jsonContacts = new ArrayList(); + for (RawContact rawContact : dirtyContacts) { + jsonContacts.add(rawContact.toJSONObject()); } - handler.post(new Runnable() { - public void run() { - ((AuthenticatorActivity) context).onAuthenticationResult(result); - } - }); - } - /** - * Attempts to authenticate the user credentials on the server. - * - * @param username The user's username - * @param password The user's password to be authenticated - * @param handler The main UI thread's handler instance. - * @param context The caller Activity's context - * @return Thread The thread on which the network mOperations are executed. - */ - public static Thread attemptAuth(final String username, final String password, - final Handler handler, final Context context) { + // Create a special JSONArray of our JSON contacts + JSONArray buffer = new JSONArray(jsonContacts); - final Runnable runnable = new Runnable() { - public void run() { - authenticate(username, password, handler, context); - } - }; - // run on background thread. - return NetworkUtilities.performOnBackgroundThread(runnable); - } + // Create an array that will hold the server-side contacts + // that have been changed (returned by the server). + final ArrayList serverDirtyList = new ArrayList(); - /** - * Fetches the list of friend data updates from the server - * - * @param account The account being synced. - * @param authtoken The authtoken stored in AccountManager for this account - * @param lastUpdated The last time that sync was performed - * @return list The list of updates received from the server. - */ - public static List fetchFriendUpdates(Account account, String authtoken, Date lastUpdated) - throws JSONException, ParseException, IOException, AuthenticationException { - - final ArrayList friendList = new ArrayList(); + // Prepare our POST data final ArrayList params = new ArrayList(); params.add(new BasicNameValuePair(PARAM_USERNAME, account.name)); - params.add(new BasicNameValuePair(PARAM_PASSWORD, authtoken)); - if (lastUpdated != null) { - final SimpleDateFormat formatter = new SimpleDateFormat("yyyy/MM/dd HH:mm"); - formatter.setTimeZone(TimeZone.getTimeZone("UTC")); - params.add(new BasicNameValuePair(PARAM_UPDATED, formatter.format(lastUpdated))); + params.add(new BasicNameValuePair(PARAM_AUTH_TOKEN, authtoken)); + params.add(new BasicNameValuePair(PARAM_CONTACTS_DATA, buffer.toString())); + if (serverSyncState > 0) { + params.add(new BasicNameValuePair(PARAM_SYNC_STATE, Long.toString(serverSyncState))); } Log.i(TAG, params.toString()); - HttpEntity entity = null; - entity = new UrlEncodedFormEntity(params); - final HttpPost post = new HttpPost(FETCH_FRIEND_UPDATES_URI); + HttpEntity entity = new UrlEncodedFormEntity(params); + + // Send the updated friends data to the server + Log.i(TAG, "Syncing to: " + SYNC_CONTACTS_URI); + final HttpPost post = new HttpPost(SYNC_CONTACTS_URI); post.addHeader(entity.getContentType()); post.setEntity(entity); - maybeCreateHttpClient(); - final HttpResponse resp = mHttpClient.execute(post); + final HttpResponse resp = getHttpClient().execute(post); final String response = EntityUtils.toString(resp.getEntity()); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { - // Succesfully connected to the samplesyncadapter server and - // authenticated. - // Extract friends data in json format. - final JSONArray friends = new JSONArray(response); + // Our request to the server was successful - so we assume + // that they accepted all the changes we sent up, and + // that the response includes the contacts that we need + // to update on our side... + final JSONArray serverContacts = new JSONArray(response); Log.d(TAG, response); - for (int i = 0; i < friends.length(); i++) { - friendList.add(User.valueOf(friends.getJSONObject(i))); + for (int i = 0; i < serverContacts.length(); i++) { + RawContact rawContact = RawContact.valueOf(serverContacts.getJSONObject(i)); + if (rawContact != null) { + serverDirtyList.add(rawContact); + } } } else { if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { - Log.e(TAG, "Authentication exception in fetching remote contacts"); + Log.e(TAG, "Authentication exception in sending dirty contacts"); throw new AuthenticationException(); } else { - Log.e(TAG, "Server error in fetching remote contacts: " + resp.getStatusLine()); + Log.e(TAG, "Server error in sending dirty contacts: " + resp.getStatusLine()); throw new IOException(); } } - return friendList; + + return serverDirtyList; } /** - * Fetches status messages for the user's friends from the server - * - * @param account The account being synced. - * @param authtoken The authtoken stored in the AccountManager for the - * account - * @return list The list of status messages received from the server. + * Download the avatar image from the server. + * + * @param avatarUrl the URL pointing to the avatar image + * @return a byte array with the raw JPEG avatar image */ - public static List fetchFriendStatuses(Account account, String authtoken) - throws JSONException, ParseException, IOException, AuthenticationException { - - final ArrayList statusList = new ArrayList(); - final ArrayList params = new ArrayList(); - params.add(new BasicNameValuePair(PARAM_USERNAME, account.name)); - params.add(new BasicNameValuePair(PARAM_PASSWORD, authtoken)); - HttpEntity entity = null; - entity = new UrlEncodedFormEntity(params); - final HttpPost post = new HttpPost(FETCH_STATUS_URI); - post.addHeader(entity.getContentType()); - post.setEntity(entity); - maybeCreateHttpClient(); - final HttpResponse resp = mHttpClient.execute(post); - final String response = EntityUtils.toString(resp.getEntity()); - if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { - // Succesfully connected to the samplesyncadapter server and - // authenticated. - // Extract friends data in json format. - final JSONArray statuses = new JSONArray(response); - for (int i = 0; i < statuses.length(); i++) { - statusList.add(User.Status.valueOf(statuses.getJSONObject(i))); - } - } else { - if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { - Log.e(TAG, "Authentication exception in fetching friend status list"); - throw new AuthenticationException(); - } else { - Log.e(TAG, "Server error in fetching friend status list"); - throw new IOException(); - } + public static byte[] downloadAvatar(final String avatarUrl) { + // If there is no avatar, we're done + if (TextUtils.isEmpty(avatarUrl)) { + return null; } - return statusList; + + try { + Log.i(TAG, "Downloading avatar: " + avatarUrl); + // Request the avatar image from the server, and create a bitmap + // object from the stream we get back. + URL url = new URL(avatarUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + try { + final BitmapFactory.Options options = new BitmapFactory.Options(); + final Bitmap avatar = BitmapFactory.decodeStream(connection.getInputStream(), + null, options); + + // Take the image we received from the server, whatever format it + // happens to be in, and convert it to a JPEG image. Note: we're + // not resizing the avatar - we assume that the image we get from + // the server is a reasonable size... + Log.i(TAG, "Converting avatar to JPEG"); + ByteArrayOutputStream convertStream = new ByteArrayOutputStream( + avatar.getWidth() * avatar.getHeight() * 4); + avatar.compress(Bitmap.CompressFormat.JPEG, 95, convertStream); + convertStream.flush(); + convertStream.close(); + // On pre-Honeycomb systems, it's important to call recycle on bitmaps + avatar.recycle(); + return convertStream.toByteArray(); + } finally { + connection.disconnect(); + } + } catch (MalformedURLException muex) { + // A bad URL - nothing we can really do about it here... + Log.e(TAG, "Malformed avatar URL: " + avatarUrl); + } catch (IOException ioex) { + // If we're unable to download the avatar, it's a bummer but not the + // end of the world. We'll try to get it next time we sync. + Log.e(TAG, "Failed to download user avatar: " + avatarUrl); + } + return null; } + } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java new file mode 100644 index 000000000..6aa73d9a2 --- /dev/null +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.android.samplesync.client; + +import android.text.TextUtils; +import android.util.Log; + +import org.json.JSONObject; +import org.json.JSONException; + +import java.lang.StringBuilder; + +/** + * Represents a low-level contacts RawContact - or at least + * the fields of the RawContact that we care about. + */ +final public class RawContact { + + /** The tag used to log to adb console. **/ + private static final String TAG = "RawContact"; + + private final String mUserName; + + private final String mFullName; + + private final String mFirstName; + + private final String mLastName; + + private final String mCellPhone; + + private final String mOfficePhone; + + private final String mHomePhone; + + private final String mEmail; + + private final String mStatus; + + private final String mAvatarUrl; + + private final boolean mDeleted; + + private final boolean mDirty; + + private final long mServerContactId; + + private final long mRawContactId; + + private final long mSyncState; + + public long getServerContactId() { + return mServerContactId; + } + + public long getRawContactId() { + return mRawContactId; + } + + public String getUserName() { + return mUserName; + } + + public String getFirstName() { + return mFirstName; + } + + public String getLastName() { + return mLastName; + } + + public String getFullName() { + return mFullName; + } + + public String getCellPhone() { + return mCellPhone; + } + + public String getOfficePhone() { + return mOfficePhone; + } + + public String getHomePhone() { + return mHomePhone; + } + + public String getEmail() { + return mEmail; + } + + public String getStatus() { + return mStatus; + } + + public String getAvatarUrl() { + return mAvatarUrl; + } + + public boolean isDeleted() { + return mDeleted; + } + + public boolean isDirty() { + return mDirty; + } + + public long getSyncState() { + return mSyncState; + } + + public String getBestName() { + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } else if (TextUtils.isEmpty(mFirstName)) { + return mLastName; + } else { + return mFirstName; + } + } + + /** + * Convert the RawContact object into a JSON string. From the + * JSONString interface. + * @return a JSON string representation of the object + */ + public JSONObject toJSONObject() { + JSONObject json = new JSONObject(); + + try { + if (!TextUtils.isEmpty(mFirstName)) { + json.put("f", mFirstName); + } + if (!TextUtils.isEmpty(mLastName)) { + json.put("l", mLastName); + } + if (!TextUtils.isEmpty(mCellPhone)) { + json.put("m", mCellPhone); + } + if (!TextUtils.isEmpty(mOfficePhone)) { + json.put("o", mOfficePhone); + } + if (!TextUtils.isEmpty(mHomePhone)) { + json.put("h", mHomePhone); + } + if (!TextUtils.isEmpty(mEmail)) { + json.put("e", mEmail); + } + if (mServerContactId > 0) { + json.put("i", mServerContactId); + } + if (mRawContactId > 0) { + json.put("c", mRawContactId); + } + if (mDeleted) { + json.put("d", mDeleted); + } + } catch (final Exception ex) { + Log.i(TAG, "Error converting RawContact to JSONObject" + ex.toString()); + } + + return json; + } + + public RawContact(String name, String fullName, String firstName, String lastName, + String cellPhone, String officePhone, String homePhone, String email, + String status, String avatarUrl, boolean deleted, long serverContactId, + long rawContactId, long syncState, boolean dirty) { + mUserName = name; + mFullName = fullName; + mFirstName = firstName; + mLastName = lastName; + mCellPhone = cellPhone; + mOfficePhone = officePhone; + mHomePhone = homePhone; + mEmail = email; + mStatus = status; + mAvatarUrl = avatarUrl; + mDeleted = deleted; + mServerContactId = serverContactId; + mRawContactId = rawContactId; + mSyncState = syncState; + mDirty = dirty; + } + + /** + * Creates and returns an instance of the RawContact from the provided JSON data. + * + * @param user The JSONObject containing user data + * @return user The new instance of Sample RawContact created from the JSON data. + */ + public static RawContact valueOf(JSONObject contact) { + + try { + final String userName = !contact.isNull("u") ? contact.getString("u") : null; + final int serverContactId = !contact.isNull("i") ? contact.getInt("i") : -1; + // If we didn't get either a username or serverId for the contact, then + // we can't do anything with it locally... + if ((userName == null) && (serverContactId <= 0)) { + throw new JSONException("JSON contact missing required 'u' or 'i' fields"); + } + + final int rawContactId = !contact.isNull("c") ? contact.getInt("c") : -1; + final String firstName = !contact.isNull("f") ? contact.getString("f") : null; + final String lastName = !contact.isNull("l") ? contact.getString("l") : null; + final String cellPhone = !contact.isNull("m") ? contact.getString("m") : null; + final String officePhone = !contact.isNull("o") ? contact.getString("o") : null; + final String homePhone = !contact.isNull("h") ? contact.getString("h") : null; + final String email = !contact.isNull("e") ? contact.getString("e") : null; + final String status = !contact.isNull("s") ? contact.getString("s") : null; + final String avatarUrl = !contact.isNull("a") ? contact.getString("a") : null; + final boolean deleted = !contact.isNull("d") ? contact.getBoolean("d") : false; + final long syncState = !contact.isNull("x") ? contact.getLong("x") : 0; + return new RawContact(userName, null, firstName, lastName, cellPhone, + officePhone, homePhone, email, status, avatarUrl, deleted, + serverContactId, rawContactId, syncState, false); + } catch (final Exception ex) { + Log.i(TAG, "Error parsing JSON contact object" + ex.toString()); + } + return null; + } + + /** + * Creates and returns RawContact instance from all the supplied parameters. + */ + public static RawContact create(String fullName, String firstName, String lastName, + String cellPhone, String officePhone, String homePhone, + String email, String status, boolean deleted, long rawContactId, + long serverContactId) { + return new RawContact(null, fullName, firstName, lastName, cellPhone, officePhone, + homePhone, email, status, null, deleted, serverContactId, rawContactId, + -1, true); + } + + /** + * Creates and returns a User instance that represents a deleted user. + * Since the user is deleted, all we need are the client/server IDs. + * @param clientUserId The client-side ID for the contact + * @param serverUserId The server-side ID for the contact + * @return a minimal User object representing the deleted contact. + */ + public static RawContact createDeletedContact(long rawContactId, long serverContactId) + { + return new RawContact(null, null, null, null, null, null, null, + null, null, null, true, serverContactId, rawContactId, -1, true); + } +} diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java deleted file mode 100644 index 217a383d8..000000000 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2010 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ -package com.example.android.samplesync.client; - -import android.util.Log; - -import org.json.JSONObject; - -/** - * Represents a sample SyncAdapter user - */ -final public class User { - - private final String mUserName; - - private final String mFirstName; - - private final String mLastName; - - private final String mCellPhone; - - private final String mOfficePhone; - - private final String mHomePhone; - - private final String mEmail; - - private final boolean mDeleted; - - private final int mUserId; - - public int getUserId() { - return mUserId; - } - - public String getUserName() { - return mUserName; - } - - public String getFirstName() { - return mFirstName; - } - - public String getLastName() { - return mLastName; - } - - public String getCellPhone() { - return mCellPhone; - } - - public String getOfficePhone() { - return mOfficePhone; - } - - public String getHomePhone() { - return mHomePhone; - } - - public String getEmail() { - return mEmail; - } - - public boolean isDeleted() { - return mDeleted; - } - - private User(String name, String firstName, String lastName, String cellPhone, - String officePhone, String homePhone, String email, Boolean deleted, Integer userId) { - - mUserName = name; - mFirstName = firstName; - mLastName = lastName; - mCellPhone = cellPhone; - mOfficePhone = officePhone; - mHomePhone = homePhone; - mEmail = email; - mDeleted = deleted; - mUserId = userId; - } - - /** - * Creates and returns an instance of the user from the provided JSON data. - * - * @param user The JSONObject containing user data - * @return user The new instance of Voiper user created from the JSON data. - */ - public static User valueOf(JSONObject user) { - - try { - final String userName = user.getString("u"); - final String firstName = user.has("f") ? user.getString("f") : null; - final String lastName = user.has("l") ? user.getString("l") : null; - final String cellPhone = user.has("m") ? user.getString("m") : null; - final String officePhone = user.has("o") ? user.getString("o") : null; - final String homePhone = user.has("h") ? user.getString("h") : null; - final String email = user.has("e") ? user.getString("e") : null; - final boolean deleted = user.has("d") ? user.getBoolean("d") : false; - final int userId = user.getInt("i"); - return new User(userName, firstName, lastName, cellPhone, officePhone, homePhone, - email, deleted, userId); - } catch (final Exception ex) { - Log.i("User", "Error parsing JSON user object" + ex.toString()); - } - return null; - } - - /** - * Represents the User's status messages - * - */ - final public static class Status { - - private final Integer mUserId; - - private final String mStatus; - - public int getUserId() { - return mUserId; - } - - public String getStatus() { - return mStatus; - } - - public Status(Integer userId, String status) { - mUserId = userId; - mStatus = status; - } - - public static User.Status valueOf(JSONObject userStatus) { - try { - final int userId = userStatus.getInt("i"); - final String status = userStatus.getString("s"); - return new User.Status(userId, status); - } catch (final Exception ex) { - Log.i("User.Status", "Error parsing JSON user object"); - } - return null; - } - } -} diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java new file mode 100644 index 000000000..2e30be7f2 --- /dev/null +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.android.samplesync.editor; + +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.RawContact; +import com.example.android.samplesync.platform.BatchOperation; +import com.example.android.samplesync.platform.ContactManager; +import com.example.android.samplesync.platform.ContactManager.EditorQuery; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.RawContacts; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.EditText; +import android.widget.TextView; + +/** + * Implements a sample editor for a contact that belongs to a remote contact service. + * The editor can be invoked for an existing SampleSyncAdapter contact, or it can + * be used to create a brand new SampleSyncAdapter contact. We look at the Intent + * object to figure out whether this is a "new" or "edit" operation. + */ +public class ContactEditorActivity extends Activity { + private static final String TAG = "SampleSyncAdapter"; + + // Keep track of whether we're inserting a new contact or editing an + // existing contact. + private boolean mIsInsert; + + // The name of the external account we're syncing this contact to. + private String mAccountName; + + // For existing contacts, this is the URI to the contact data. + private Uri mRawContactUri; + + // The raw clientId for this contact + private long mRawContactId; + + // Make sure we only attempt to save the contact once if the + // user presses the "done" button multiple times... + private boolean mSaveInProgress = false; + + // Keep track of the controls used to edit contact values, so we can get/set + // those values easily. + private EditText mNameEditText; + private EditText mHomePhoneEditText; + private EditText mMobilePhoneEditText; + private EditText mWorkPhoneEditText; + private EditText mEmailEditText; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.editor); + + mNameEditText = (EditText)findViewById(R.id.editor_name); + mHomePhoneEditText = (EditText)findViewById(R.id.editor_phone_home); + mMobilePhoneEditText = (EditText)findViewById(R.id.editor_phone_mobile); + mWorkPhoneEditText = (EditText)findViewById(R.id.editor_phone_work); + mEmailEditText = (EditText)findViewById(R.id.editor_email); + + // Figure out whether we're creating a new contact (ACTION_INSERT) or editing + // an existing contact. + Intent intent = getIntent(); + String action = intent.getAction(); + if (Intent.ACTION_INSERT.equals(action)) { + // We're inserting a new contact, so save off the external account name + // which should have been added to the intent we were passed. + mIsInsert = true; + String accountName = intent.getStringExtra(RawContacts.ACCOUNT_NAME); + if (accountName == null) { + Log.e(TAG, "Account name is required"); + finish(); + } + setAccountName(accountName); + } else { + // We're editing an existing contact. Load in the data from the contact + // so that the user can edit it. + mIsInsert = false; + mRawContactUri = intent.getData(); + if (mRawContactUri == null) { + Log.e(TAG, "Raw contact URI is required"); + finish(); + } + startLoadRawContactEntity(); + } + } + + @Override + public void onBackPressed() { + // This method will have been called if the user presses the "Back" button + // in the ActionBar. We treat that the same way as the "Done" button in + // the ActionBar. + save(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // This method gets called so that we can place items in the main Options menu - + // for example, the ActionBar items. We add our menus from the res/menu/edit.xml + // file. + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.edit, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + case R.id.menu_done: + // The user pressed the "Home" button or our "Done" button - both + // in the ActionBar. In both cases, we want to save the contact + // and exit. + save(); + return true; + case R.id.menu_cancel: + // The user pressed the Cancel menu item in the ActionBar. + // Close the editor without saving any changes. + finish(); + return true; + } + return false; + } + + /** + * Create an AsyncTask to load the contact from the Contacts data provider + */ + private void startLoadRawContactEntity() { + Uri uri = Uri.withAppendedPath(mRawContactUri, RawContacts.Entity.CONTENT_DIRECTORY); + new LoadRawContactTask().execute(uri); + } + + /** + * Called by the LoadRawContactTask when the contact information has been + * successfully loaded from the Contacts data provider. + */ + public void onRawContactEntityLoaded(Cursor cursor) { + while (cursor.moveToNext()) { + String mimetype = cursor.getString(EditorQuery.COLUMN_MIMETYPE); + if (StructuredName.CONTENT_ITEM_TYPE.equals(mimetype)) { + setAccountName(cursor.getString(EditorQuery.COLUMN_ACCOUNT_NAME)); + mRawContactId = cursor.getLong(EditorQuery.COLUMN_RAW_CONTACT_ID); + mNameEditText.setText(cursor.getString(EditorQuery.COLUMN_FULL_NAME)); + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimetype)) { + final int type = cursor.getInt(EditorQuery.COLUMN_PHONE_TYPE); + if (type == Phone.TYPE_HOME) { + mHomePhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } else if (type == Phone.TYPE_MOBILE) { + mMobilePhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } else if (type == Phone.TYPE_WORK) { + mWorkPhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } + } else if (Email.CONTENT_ITEM_TYPE.equals(mimetype)) { + mEmailEditText.setText(cursor.getString(EditorQuery.COLUMN_DATA1)); + } + } + } + + /** + * Save the updated contact data. We actually take two different actions + * depending on whether we are creating a new contact or editing an + * existing contact. + */ + public void save() { + // If we're already saving this contact, don't kick-off yet + // another save - the user probably just pressed the "Done" + // button multiple times... + if (mSaveInProgress) { + return; + } + + mSaveInProgress = true; + if (mIsInsert) { + saveNewContact(); + } else { + saveChanges(); + } + } + + /** + * Save off the external contacts provider account name. We show the account name + * in the header section of the edit panel, and we also need it later when we + * save off a brand new contact. + */ + private void setAccountName(String accountName) { + mAccountName = accountName; + if (accountName != null) { + TextView accountNameLabel = (TextView)findViewById(R.id.header_account_name); + if (accountNameLabel != null) { + accountNameLabel.setText(accountName); + } + } + } + + /** + * Save a new contact using the Contacts content provider. The actual insertion + * is performed in an AsyncTask. + */ + @SuppressWarnings("unchecked") + private void saveNewContact() { + new InsertContactTask().execute(buildRawContact()); + } + + /** + * Save changes to an existing contact. The actual update is performed in + * an AsyncTask. + */ + @SuppressWarnings("unchecked") + private void saveChanges() { + new UpdateContactTask().execute(buildRawContact()); + } + + /** + * Build a RawContact object from the data in the user-editable form + * @return a new RawContact object representing the edited user + */ + private RawContact buildRawContact() { + return RawContact.create(mNameEditText.getText().toString(), + null, + null, + mMobilePhoneEditText.getText().toString(), + mWorkPhoneEditText.getText().toString(), + mHomePhoneEditText.getText().toString(), + mEmailEditText.getText().toString(), + null, + false, + mRawContactId, + -1); + } + + /** + * Called after a contact is saved - both for edited contacts and new contacts. + * We set the final result of the activity to be "ok", and then close the activity + * by calling finish(). + */ + public void onContactSaved(Uri result) { + if (result != null) { + Intent intent = new Intent(); + intent.setData(result); + setResult(RESULT_OK, intent); + finish(); + } + mSaveInProgress = false; + } + + /** + * Represents an asynchronous task used to load a contact from + * the Contacts content provider. + * + */ + public class LoadRawContactTask extends AsyncTask { + + @Override + protected Cursor doInBackground(Uri... params) { + // Our background task is to load the contact from the Contacts provider + return getContentResolver().query(params[0], EditorQuery.PROJECTION, null, null, null); + } + + @Override + protected void onPostExecute(Cursor cursor) { + // After we've successfully loaded the contact, call back into + // the ContactEditorActivity so we can update the UI + try { + if (cursor != null) { + onRawContactEntityLoaded(cursor); + } + } finally { + cursor.close(); + } + } + } + + /** + * Represents an asynchronous task used to save a new contact + * into the contacts database. + */ + public class InsertContactTask extends AsyncTask { + + @Override + protected Uri doInBackground(RawContact... params) { + try { + final RawContact rawContact = params[0]; + final Context context = getApplicationContext(); + final ContentResolver resolver = getContentResolver(); + final BatchOperation batchOperation = new BatchOperation(context, resolver); + ContactManager.addContact(context, mAccountName, rawContact, false, batchOperation); + Uri rawContactUri = batchOperation.execute(); + + // Convert the raw contact URI to a contact URI + if (rawContactUri != null) { + return RawContacts.getContactLookupUri(resolver, rawContactUri); + } else { + Log.e(TAG, "Could not save new contact"); + return null; + } + } catch (Exception e) { + Log.e(TAG, "An error occurred while saving new contact", e); + } + return null; + } + + @Override + protected void onPostExecute(Uri result) { + // Tell the UI that the contact has been successfully saved + onContactSaved(result); + } + } + + + /** + * Represents an asynchronous task used to save an updated contact + * into the contacts database. + */ + public class UpdateContactTask extends AsyncTask { + + @Override + protected Uri doInBackground(RawContact... params) { + try { + final RawContact rawContact = params[0]; + final Context context = getApplicationContext(); + final ContentResolver resolver = getContentResolver(); + final BatchOperation batchOperation = new BatchOperation(context, resolver); + ContactManager.updateContact(context, resolver, rawContact, false, false, false, + false, rawContact.getRawContactId(), batchOperation); + batchOperation.execute(); + + // Convert the raw contact URI to a contact URI + return RawContacts.getContactLookupUri(resolver, mRawContactUri); + } catch (Exception e) { + Log.e(TAG, "Could not save changes", e); + } + return null; + } + + @Override + protected void onPostExecute(Uri result) { + // Tell the UI that the contact has been successfully saved + onContactSaved(result); + } + } +} diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java index 0be3daa8b..3a9c879f2 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -16,9 +16,11 @@ package com.example.android.samplesync.platform; import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.Context; import android.content.OperationApplicationException; +import android.net.Uri; import android.os.RemoteException; import android.provider.ContactsContract; import android.util.Log; @@ -50,19 +52,24 @@ final public class BatchOperation { mOperations.add(cpo); } - public void execute() { + public Uri execute() { + Uri result = null; if (mOperations.size() == 0) { - return; + return result; } // Apply the mOperations to the content provider try { - mResolver.applyBatch(ContactsContract.AUTHORITY, mOperations); + ContentProviderResult[] results = mResolver.applyBatch(ContactsContract.AUTHORITY, + mOperations); + if ((results != null) && (results.length > 0)) + result = results[0].uri; } catch (final OperationApplicationException e1) { Log.e(TAG, "storing contact data failed", e1); } catch (final RemoteException e2) { Log.e(TAG, "storing contact data failed", e2); } mOperations.clear(); + return result; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java index 218b165b6..2335d383b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -15,25 +15,30 @@ */ package com.example.android.samplesync.platform; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.RawContact; + +import android.accounts.Account; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.RawContacts; -import android.provider.ContactsContract.StatusUpdates; +import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Im; import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.Settings; +import android.provider.ContactsContract.StatusUpdates; import android.util.Log; -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.User; - +import java.util.ArrayList; import java.util.List; /** @@ -49,81 +54,172 @@ public class ContactManager { private static final String TAG = "ContactManager"; /** - * Synchronize raw contacts - * + * Take a list of updated contacts and apply those changes to the + * contacts database. Typically this list of contacts would have been + * returned from the server, and we want to apply those changes locally. + * * @param context The context of Authenticator Activity * @param account The username for the account - * @param users The list of users + * @param rawContacts The list of contacts to update + * @param lastSyncMarker The previous server sync-state + * @return the server syncState that should be used in our next + * sync request. */ - public static synchronized void syncContacts(Context context, String account, List users) { + public static synchronized long updateContacts(Context context, String account, + List rawContacts, long lastSyncMarker) { - long userId; - long rawContactId = 0; + long currentSyncMarker = lastSyncMarker; final ContentResolver resolver = context.getContentResolver(); final BatchOperation batchOperation = new BatchOperation(context, resolver); + final List newUsers = new ArrayList(); + Log.d(TAG, "In SyncContacts"); - for (final User user : users) { - userId = user.getUserId(); - // Check to see if the contact needs to be inserted or updated - rawContactId = lookupRawContact(resolver, userId); + for (final RawContact rawContact : rawContacts) { + // The server returns a syncState (x) value with each contact record. + // The syncState is sequential, so higher values represent more recent + // changes than lower values. We keep track of the highest value we + // see, and consider that a "high water mark" for the changes we've + // received from the server. That way, on our next sync, we can just + // ask for changes that have occurred since that most-recent change. + if (rawContact.getSyncState() > currentSyncMarker) { + currentSyncMarker = rawContact.getSyncState(); + } + + // If the server returned a clientId for this user, then it's likely + // that the user was added here, and was just pushed to the server + // for the first time. In that case, we need to update the main + // row for this contact so that the RawContacts.SOURCE_ID value + // contains the correct serverId. + final long rawContactId; + final boolean updateServerId; + if (rawContact.getRawContactId() > 0) { + rawContactId = rawContact.getRawContactId(); + updateServerId = true; + } else { + long serverContactId = rawContact.getServerContactId(); + rawContactId = lookupRawContact(resolver, serverContactId); + updateServerId = false; + } if (rawContactId != 0) { - if (!user.isDeleted()) { - // update contact - updateContact(context, resolver, account, user, rawContactId, batchOperation); + if (!rawContact.isDeleted()) { + updateContact(context, resolver, rawContact, updateServerId, + true, true, true, rawContactId, batchOperation); } else { - // delete contact deleteContact(context, rawContactId, batchOperation); } } else { - // add new contact Log.d(TAG, "In addContact"); - if (!user.isDeleted()) { - addContact(context, account, user, batchOperation); + if (!rawContact.isDeleted()) { + newUsers.add(rawContact); + addContact(context, account, rawContact, true, batchOperation); } } // A sync adapter should batch operations on multiple contacts, // because it will make a dramatic performance difference. + // (UI updates, etc) if (batchOperation.size() >= 50) { batchOperation.execute(); } } batchOperation.execute(); + + return currentSyncMarker; } /** - * Add a list of status messages to the contacts provider. - * - * @param context the context to use - * @param accountName the username of the logged in user - * @param statuses the list of statuses to store + * Return a list of the local contacts that have been marked as + * "dirty", and need syncing to the SampleSync server. + * + * @param context The context of Authenticator Activity + * @param account The account that we're interested in syncing + * @return a list of Users that are considered "dirty" */ - public static void insertStatuses(Context context, String username, List list) { + public static List getDirtyContacts(Context context, Account account) { + Log.i(TAG, "*** Looking for local dirty contacts"); + List dirtyContacts = new ArrayList(); - final ContentValues values = new ContentValues(); + final ContentResolver resolver = context.getContentResolver(); + final Cursor c = resolver.query(DirtyQuery.CONTENT_URI, + DirtyQuery.PROJECTION, + DirtyQuery.SELECTION, + new String[] {account.name}, + null); + try { + while (c.moveToNext()) { + final long rawContactId = c.getLong(DirtyQuery.COLUMN_RAW_CONTACT_ID); + final long serverContactId = c.getLong(DirtyQuery.COLUMN_SERVER_ID); + final boolean isDirty = "1".equals(c.getString(DirtyQuery.COLUMN_DIRTY)); + final boolean isDeleted = "1".equals(c.getString(DirtyQuery.COLUMN_DELETED)); + + // The system actually keeps track of a change version number for + // each contact. It may be something you're interested in for your + // client-server sync protocol. We're not using it in this example, + // other than to log it. + final long version = c.getLong(DirtyQuery.COLUMN_VERSION); + + Log.i(TAG, "Dirty Contact: " + Long.toString(rawContactId)); + Log.i(TAG, "Contact Version: " + Long.toString(version)); + + if (isDeleted) { + Log.i(TAG, "Contact is marked for deletion"); + RawContact rawContact = RawContact.createDeletedContact(rawContactId, + serverContactId); + dirtyContacts.add(rawContact); + } else if (isDirty) { + RawContact rawContact = getRawContact(context, rawContactId); + Log.i(TAG, "Contact Name: " + rawContact.getBestName()); + dirtyContacts.add(rawContact); + } + } + + } finally { + if (c != null) { + c.close(); + } + } + return dirtyContacts; + } + + /** + * Update the status messages for a list of users. This is typically called + * for contacts we've just added to the system, since we can't monkey with + * the contact's status until they have a profileId. + * + * @param context The context of Authenticator Activity + * @param rawContacts The list of users we want to update + */ + public static void updateStatusMessages(Context context, List rawContacts) { final ContentResolver resolver = context.getContentResolver(); final BatchOperation batchOperation = new BatchOperation(context, resolver); - for (final User.Status status : list) { - // Look up the user's sample SyncAdapter data row - final long userId = status.getUserId(); - final long profileId = lookupProfile(resolver, userId); - // Insert the activity into the stream - if (profileId > 0) { - values.put(StatusUpdates.DATA_ID, profileId); - values.put(StatusUpdates.STATUS, status.getStatus()); - values.put(StatusUpdates.PROTOCOL, Im.PROTOCOL_CUSTOM); - values.put(StatusUpdates.CUSTOM_PROTOCOL, CUSTOM_IM_PROTOCOL); - values.put(StatusUpdates.IM_ACCOUNT, username); - values.put(StatusUpdates.IM_HANDLE, status.getUserId()); - values.put(StatusUpdates.STATUS_RES_PACKAGE, context.getPackageName()); - values.put(StatusUpdates.STATUS_ICON, R.drawable.icon); - values.put(StatusUpdates.STATUS_LABEL, R.string.label); - batchOperation.add(ContactOperations.newInsertCpo(StatusUpdates.CONTENT_URI, true) - .withValues(values).build()); - // A sync adapter should batch operations on multiple contacts, - // because it will make a dramatic performance difference. - if (batchOperation.size() >= 50) { - batchOperation.execute(); - } + for (RawContact rawContact : rawContacts) { + updateContactStatus(context, rawContact, batchOperation); + } + batchOperation.execute(); + } + + /** + * After we've finished up a sync operation, we want to clean up the sync-state + * so that we're ready for the next time. This involves clearing out the 'dirty' + * flag on the synced contacts - but we also have to finish the DELETE operation + * on deleted contacts. When the user initially deletes them on the client, they're + * marked for deletion - but they're not actually deleted until we delete them + * again, and include the ContactsContract.CALLER_IS_SYNCADAPTER parameter to + * tell the contacts provider that we're really ready to let go of this contact. + * + * @param context The context of Authenticator Activity + * @param dirtyContacts The list of contacts that we're cleaning up + */ + public static void clearSyncFlags(Context context, List dirtyContacts) { + Log.i(TAG, "*** Clearing Sync-related Flags"); + final ContentResolver resolver = context.getContentResolver(); + final BatchOperation batchOperation = new BatchOperation(context, resolver); + for (RawContact rawContact : dirtyContacts) { + if (rawContact.isDeleted()) { + Log.i(TAG, "Deleting contact: " + Long.toString(rawContact.getRawContactId())); + deleteContact(context, rawContact.getRawContactId(), batchOperation); + } else if (rawContact.isDirty()) { + Log.i(TAG, "Clearing dirty flag for: " + rawContact.getBestName()); + clearDirtyFlag(context, rawContact.getRawContactId(), batchOperation); } } batchOperation.execute(); @@ -131,89 +227,312 @@ public class ContactManager { /** * Adds a single contact to the platform contacts provider. - * + * This can be used to respond to a new contact found as part + * of sync information returned from the server, or because a + * user added a new contact. + * * @param context the Authenticator Activity context * @param accountName the account the contact belongs to - * @param user the sample SyncAdapter User object + * @param rawContact the sample SyncAdapter User object + * @param inSync is the add part of a client-server sync? + * @param batchOperation allow us to batch together multiple operations + * into a single provider call */ - private static void addContact(Context context, String accountName, User user, - BatchOperation batchOperation) { + public static void addContact(Context context, String accountName, RawContact rawContact, + boolean inSync, BatchOperation batchOperation) { // Put the data in the contacts provider - final ContactOperations contactOp = - ContactOperations.createNewContact(context, user.getUserId(), accountName, - batchOperation); - contactOp.addName(user.getFirstName(), user.getLastName()).addEmail(user.getEmail()) - .addPhone(user.getCellPhone(), Phone.TYPE_MOBILE).addPhone(user.getHomePhone(), - Phone.TYPE_OTHER).addProfileAction(user.getUserId()); + final ContactOperations contactOp = ContactOperations.createNewContact( + context, rawContact.getServerContactId(), accountName, inSync, batchOperation); + + contactOp.addName(rawContact.getFullName(), rawContact.getFirstName(), + rawContact.getLastName()) + .addEmail(rawContact.getEmail()) + .addPhone(rawContact.getCellPhone(), Phone.TYPE_MOBILE) + .addPhone(rawContact.getHomePhone(), Phone.TYPE_HOME) + .addPhone(rawContact.getOfficePhone(), Phone.TYPE_WORK) + .addAvatar(rawContact.getAvatarUrl()); + + // If we have a serverId, then go ahead and create our status profile. + // Otherwise skip it - and we'll create it after we sync-up to the + // server later on. + if (rawContact.getServerContactId() > 0) { + contactOp.addProfileAction(rawContact.getServerContactId()); + } } /** * Updates a single contact to the platform contacts provider. - * + * This method can be used to update a contact from a sync + * operation or as a result of a user editing a contact + * record. + * + * This operation is actually relatively complex. We query + * the database to find all the rows of info that already + * exist for this Contact. For rows that exist (and thus we're + * modifying existing fields), we create an update operation + * to change that field. But for fields we're adding, we create + * "add" operations to create new rows for those fields. + * * @param context the Authenticator Activity context * @param resolver the ContentResolver to use - * @param accountName the account the contact belongs to - * @param user the sample SyncAdapter contact object. + * @param rawContact the sample SyncAdapter contact object + * @param updateStatus should we update this user's status + * @param updateAvatar should we update this user's avatar image + * @param inSync is the update part of a client-server sync? * @param rawContactId the unique Id for this rawContact in contacts * provider + * @param batchOperation allow us to batch together multiple operations + * into a single provider call */ - private static void updateContact(Context context, ContentResolver resolver, - String accountName, User user, long rawContactId, BatchOperation batchOperation) { + public static void updateContact(Context context, ContentResolver resolver, + RawContact rawContact, boolean updateServerId, boolean updateStatus, boolean updateAvatar, + boolean inSync, long rawContactId, BatchOperation batchOperation) { + + boolean existingCellPhone = false; + boolean existingHomePhone = false; + boolean existingWorkPhone = false; + boolean existingEmail = false; + boolean existingAvatar = false; - Uri uri; - String cellPhone = null; - String otherPhone = null; - String email = null; final Cursor c = - resolver.query(Data.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, + resolver.query(DataQuery.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, new String[] {String.valueOf(rawContactId)}, null); final ContactOperations contactOp = - ContactOperations.updateExistingContact(context, rawContactId, batchOperation); + ContactOperations.updateExistingContact(context, rawContactId, + inSync, batchOperation); try { + // Iterate over the existing rows of data, and update each one + // with the information we received from the server. while (c.moveToNext()) { final long id = c.getLong(DataQuery.COLUMN_ID); final String mimeType = c.getString(DataQuery.COLUMN_MIMETYPE); - uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); + final Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { - final String lastName = c.getString(DataQuery.COLUMN_FAMILY_NAME); - final String firstName = c.getString(DataQuery.COLUMN_GIVEN_NAME); - contactOp.updateName(uri, firstName, lastName, user.getFirstName(), user - .getLastName()); + contactOp.updateName(uri, + c.getString(DataQuery.COLUMN_GIVEN_NAME), + c.getString(DataQuery.COLUMN_FAMILY_NAME), + c.getString(DataQuery.COLUMN_FULL_NAME), + rawContact.getFirstName(), + rawContact.getLastName(), + rawContact.getFullName()); } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { final int type = c.getInt(DataQuery.COLUMN_PHONE_TYPE); if (type == Phone.TYPE_MOBILE) { - cellPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); - contactOp.updatePhone(cellPhone, user.getCellPhone(), uri); - } else if (type == Phone.TYPE_OTHER) { - otherPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); - contactOp.updatePhone(otherPhone, user.getHomePhone(), uri); + existingCellPhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getCellPhone(), uri); + } else if (type == Phone.TYPE_HOME) { + existingHomePhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getHomePhone(), uri); + } else if (type == Phone.TYPE_WORK) { + existingWorkPhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getOfficePhone(), uri); } - } else if (Data.MIMETYPE.equals(Email.CONTENT_ITEM_TYPE)) { - email = c.getString(DataQuery.COLUMN_EMAIL_ADDRESS); - contactOp.updateEmail(user.getEmail(), email, uri); + } else if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { + existingEmail = true; + contactOp.updateEmail(rawContact.getEmail(), + c.getString(DataQuery.COLUMN_EMAIL_ADDRESS), uri); + } else if (mimeType.equals(Photo.CONTENT_ITEM_TYPE)) { + existingAvatar = true; + contactOp.updateAvatar(rawContact.getAvatarUrl(), uri); } } // while } finally { c.close(); } + // Add the cell phone, if present and not updated above - if (cellPhone == null) { - contactOp.addPhone(user.getCellPhone(), Phone.TYPE_MOBILE); + if (!existingCellPhone) { + contactOp.addPhone(rawContact.getCellPhone(), Phone.TYPE_MOBILE); } - // Add the other phone, if present and not updated above - if (otherPhone == null) { - contactOp.addPhone(user.getHomePhone(), Phone.TYPE_OTHER); + // Add the home phone, if present and not updated above + if (!existingHomePhone) { + contactOp.addPhone(rawContact.getHomePhone(), Phone.TYPE_HOME); + } + + // Add the work phone, if present and not updated above + if (!existingWorkPhone) { + contactOp.addPhone(rawContact.getOfficePhone(), Phone.TYPE_WORK); } // Add the email address, if present and not updated above - if (email == null) { - contactOp.addEmail(user.getEmail()); + if (!existingEmail) { + contactOp.addEmail(rawContact.getEmail()); + } + // Add the avatar if we didn't update the existing avatar + if (!existingAvatar) { + contactOp.addAvatar(rawContact.getAvatarUrl()); + } + + // If we need to update the serverId of the contact record, take + // care of that. This will happen if the contact is created on the + // client, and then synced to the server. When we get the updated + // record back from the server, we can set the SOURCE_ID property + // on the contact, so we can (in the future) lookup contacts by + // the serverId. + if (updateServerId) { + Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + contactOp.updateServerId(rawContact.getServerContactId(), uri); + } + + // If we don't have a status profile, then create one. This could + // happen for contacts that were created on the client - we don't + // create the status profile until after the first sync... + final long serverId = rawContact.getServerContactId(); + final long profileId = lookupProfile(resolver, serverId); + if (profileId <= 0) { + contactOp.addProfileAction(serverId); } } /** - * Deletes a contact from the platform contacts provider. - * + * When we first add a sync adapter to the system, the contacts from that + * sync adapter will be hidden unless they're merged/grouped with an existing + * contact. But typically we want to actually show those contacts, so we + * need to mess with the Settings table to get them to show up. + * + * @param context the Authenticator Activity context + * @param account the Account who's visibility we're changing + * @param visible true if we want the contacts visible, false for hidden + */ + public static void setAccountContactsVisibility(Context context, Account account, + boolean visible) { + ContentValues values = new ContentValues(); + values.put(RawContacts.ACCOUNT_NAME, account.name); + values.put(RawContacts.ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); + values.put(Settings.UNGROUPED_VISIBLE, visible ? 1 : 0); + + context.getContentResolver().insert(Settings.CONTENT_URI, values); + } + + /** + * Return a User object with data extracted from a contact stored + * in the local contacts database. + * + * Because a contact is actually stored over several rows in the + * database, our query will return those multiple rows of information. + * We then iterate over the rows and build the User structure from + * what we find. + * + * @param context the Authenticator Activity context + * @param rawContactId the unique ID for the local contact + * @return a User object containing info on that contact + */ + private static RawContact getRawContact(Context context, long rawContactId) { + String firstName = null; + String lastName = null; + String fullName = null; + String cellPhone = null; + String homePhone = null; + String workPhone = null; + String email = null; + long serverId = -1; + + final ContentResolver resolver = context.getContentResolver(); + final Cursor c = + resolver.query(DataQuery.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, + new String[] {String.valueOf(rawContactId)}, null); + try { + while (c.moveToNext()) { + final long id = c.getLong(DataQuery.COLUMN_ID); + final String mimeType = c.getString(DataQuery.COLUMN_MIMETYPE); + final long tempServerId = c.getLong(DataQuery.COLUMN_SERVER_ID); + if (tempServerId > 0) { + serverId = tempServerId; + } + final Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); + if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { + lastName = c.getString(DataQuery.COLUMN_FAMILY_NAME); + firstName = c.getString(DataQuery.COLUMN_GIVEN_NAME); + fullName = c.getString(DataQuery.COLUMN_FULL_NAME); + } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { + final int type = c.getInt(DataQuery.COLUMN_PHONE_TYPE); + if (type == Phone.TYPE_MOBILE) { + cellPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } else if (type == Phone.TYPE_HOME) { + homePhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } else if (type == Phone.TYPE_WORK) { + workPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } + } else if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { + email = c.getString(DataQuery.COLUMN_EMAIL_ADDRESS); + } + } // while + } finally { + c.close(); + } + + // Now that we've extracted all the information we care about, + // create the actual User object. + RawContact rawContact = RawContact.create(fullName, firstName, lastName, cellPhone, + workPhone, homePhone, email, null, false, rawContactId, serverId); + + return rawContact; + } + + /** + * Update the status message associated with the specified user. The status + * message would be something that is likely to be used by IM or social + * networking sync providers, and less by a straightforward contact provider. + * But it's a useful demo to see how it's done. + * + * @param context the Authenticator Activity context + * @param rawContact the contact who's status we should update + * @param batchOperation allow us to batch together multiple operations + */ + private static void updateContactStatus(Context context, RawContact rawContact, + BatchOperation batchOperation) { + final ContentValues values = new ContentValues(); + final ContentResolver resolver = context.getContentResolver(); + + final long userId = rawContact.getServerContactId(); + final String username = rawContact.getUserName(); + final String status = rawContact.getStatus(); + + // Look up the user's sample SyncAdapter data row + final long profileId = lookupProfile(resolver, userId); + + // Insert the activity into the stream + if (profileId > 0) { + values.put(StatusUpdates.DATA_ID, profileId); + values.put(StatusUpdates.STATUS, status); + values.put(StatusUpdates.PROTOCOL, Im.PROTOCOL_CUSTOM); + values.put(StatusUpdates.CUSTOM_PROTOCOL, CUSTOM_IM_PROTOCOL); + values.put(StatusUpdates.IM_ACCOUNT, username); + values.put(StatusUpdates.IM_HANDLE, userId); + values.put(StatusUpdates.STATUS_RES_PACKAGE, context.getPackageName()); + values.put(StatusUpdates.STATUS_ICON, R.drawable.icon); + values.put(StatusUpdates.STATUS_LABEL, R.string.label); + batchOperation.add(ContactOperations.newInsertCpo(StatusUpdates.CONTENT_URI, + false, true).withValues(values).build()); + } + } + + /** + * Clear the local system 'dirty' flag for a contact. + * + * @param context the Authenticator Activity context + * @param rawContactId the id of the contact update + * @param batchOperation allow us to batch together multiple operations + */ + private static void clearDirtyFlag(Context context, long rawContactId, + BatchOperation batchOperation) { + final ContactOperations contactOp = + ContactOperations.updateExistingContact(context, rawContactId, true, + batchOperation); + + final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + contactOp.updateDirtyFlag(false, uri); + } + + /** + * Deletes a contact from the platform contacts provider. This method is used + * both for contacts that were deleted locally and then that deletion was synced + * to the server, and for contacts that were deleted on the server and the + * deletion was synced to the client. + * * @param context the Authenticator Activity context * @param rawContactId the unique Id for this rawContact in contacts * provider @@ -222,39 +541,43 @@ public class ContactManager { BatchOperation batchOperation) { batchOperation.add(ContactOperations.newDeleteCpo( - ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), true).build()); + ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), + true, true).build()); } /** * Returns the RawContact id for a sample SyncAdapter contact, or 0 if the * sample SyncAdapter user isn't found. - * - * @param context the Authenticator Activity context - * @param userId the sample SyncAdapter user ID to lookup + * + * @param resolver the content resolver to use + * @param serverContactId the sample SyncAdapter user ID to lookup * @return the RawContact id, or 0 if not found */ - private static long lookupRawContact(ContentResolver resolver, long userId) { + private static long lookupRawContact(ContentResolver resolver, long serverContactId) { - long authorId = 0; - final Cursor c = - resolver.query(RawContacts.CONTENT_URI, UserIdQuery.PROJECTION, UserIdQuery.SELECTION, - new String[] {String.valueOf(userId)}, null); + long rawContactId = 0; + final Cursor c = resolver.query( + UserIdQuery.CONTENT_URI, + UserIdQuery.PROJECTION, + UserIdQuery.SELECTION, + new String[] {String.valueOf(serverContactId)}, + null); try { - if (c.moveToFirst()) { - authorId = c.getLong(UserIdQuery.COLUMN_ID); + if ((c != null) && c.moveToFirst()) { + rawContactId = c.getLong(UserIdQuery.COLUMN_RAW_CONTACT_ID); } } finally { if (c != null) { c.close(); } } - return authorId; + return rawContactId; } /** * Returns the Data id for a sample SyncAdapter contact's profile row, or 0 * if the sample SyncAdapter user isn't found. - * + * * @param resolver a content resolver * @param userId the sample SyncAdapter user ID to lookup * @return the profile Data row id, or 0 if not found @@ -266,7 +589,7 @@ public class ContactManager { resolver.query(Data.CONTENT_URI, ProfileQuery.PROJECTION, ProfileQuery.SELECTION, new String[] {String.valueOf(userId)}, null); try { - if (c != null && c.moveToFirst()) { + if ((c != null) && c.moveToFirst()) { profileId = c.getLong(ProfileQuery.COLUMN_ID); } } finally { @@ -277,6 +600,46 @@ public class ContactManager { return profileId; } + final public static class EditorQuery { + + private EditorQuery() { + } + + public static final String[] PROJECTION = new String[] { + RawContacts.ACCOUNT_NAME, + Data._ID, + RawContacts.Entity.DATA_ID, + Data.MIMETYPE, + Data.DATA1, + Data.DATA2, + Data.DATA3, + Data.DATA15, + Data.SYNC1 + }; + + public static final int COLUMN_ACCOUNT_NAME = 0; + public static final int COLUMN_RAW_CONTACT_ID = 1; + public static final int COLUMN_DATA_ID = 2; + public static final int COLUMN_MIMETYPE = 3; + public static final int COLUMN_DATA1 = 4; + public static final int COLUMN_DATA2 = 5; + public static final int COLUMN_DATA3 = 6; + public static final int COLUMN_DATA15 = 7; + public static final int COLUMN_SYNC1 = 8; + + public static final int COLUMN_PHONE_NUMBER = COLUMN_DATA1; + public static final int COLUMN_PHONE_TYPE = COLUMN_DATA2; + public static final int COLUMN_EMAIL_ADDRESS = COLUMN_DATA1; + public static final int COLUMN_EMAIL_TYPE = COLUMN_DATA2; + public static final int COLUMN_FULL_NAME = COLUMN_DATA1; + public static final int COLUMN_GIVEN_NAME = COLUMN_DATA2; + public static final int COLUMN_FAMILY_NAME = COLUMN_DATA3; + public static final int COLUMN_AVATAR_IMAGE = COLUMN_DATA15; + public static final int COLUMN_SYNC_DIRTY = COLUMN_SYNC1; + + public static final String SELECTION = Data.RAW_CONTACT_ID + "=?"; + } + /** * Constants for a query to find a contact given a sample SyncAdapter user * ID. @@ -304,15 +667,55 @@ public class ContactManager { private UserIdQuery() { } - public final static String[] PROJECTION = new String[] {RawContacts._ID}; + public final static String[] PROJECTION = new String[] { + RawContacts._ID, + RawContacts.CONTACT_ID + }; - public final static int COLUMN_ID = 0; + public final static int COLUMN_RAW_CONTACT_ID = 0; + public final static int COLUMN_LINKED_CONTACT_ID = 1; + + public final static Uri CONTENT_URI = RawContacts.CONTENT_URI; public static final String SELECTION = RawContacts.ACCOUNT_TYPE + "='" + Constants.ACCOUNT_TYPE + "' AND " + RawContacts.SOURCE_ID + "=?"; } + /** + * Constants for a query to find SampleSyncAdapter contacts that are + * in need of syncing to the server. This should cover new, edited, + * and deleted contacts. + */ + final private static class DirtyQuery { + + private DirtyQuery() { + } + + public final static String[] PROJECTION = new String[] { + RawContacts._ID, + RawContacts.SOURCE_ID, + RawContacts.DIRTY, + RawContacts.DELETED, + RawContacts.VERSION + }; + + public final static int COLUMN_RAW_CONTACT_ID = 0; + public final static int COLUMN_SERVER_ID = 1; + public final static int COLUMN_DIRTY = 2; + public final static int COLUMN_DELETED = 3; + public final static int COLUMN_VERSION = 4; + + public static final Uri CONTENT_URI = RawContacts.CONTENT_URI.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + + public static final String SELECTION = + RawContacts.DIRTY + "=1 AND " + + RawContacts.ACCOUNT_TYPE + "='" + Constants.ACCOUNT_TYPE + "' AND " + + RawContacts.ACCOUNT_NAME + "=?"; + } + /** * Constants for a query to get contact data for a given rawContactId */ @@ -322,29 +725,29 @@ public class ContactManager { } public static final String[] PROJECTION = - new String[] {Data._ID, Data.MIMETYPE, Data.DATA1, Data.DATA2, Data.DATA3,}; + new String[] {Data._ID, RawContacts.SOURCE_ID, Data.MIMETYPE, Data.DATA1, + Data.DATA2, Data.DATA3, Data.DATA15, Data.SYNC1}; public static final int COLUMN_ID = 0; + public static final int COLUMN_SERVER_ID = 1; + public static final int COLUMN_MIMETYPE = 2; + public static final int COLUMN_DATA1 = 3; + public static final int COLUMN_DATA2 = 4; + public static final int COLUMN_DATA3 = 5; + public static final int COLUMN_DATA15 = 6; + public static final int COLUMN_SYNC1 = 7; - public static final int COLUMN_MIMETYPE = 1; - - public static final int COLUMN_DATA1 = 2; - - public static final int COLUMN_DATA2 = 3; - - public static final int COLUMN_DATA3 = 4; + public static final Uri CONTENT_URI = Data.CONTENT_URI; public static final int COLUMN_PHONE_NUMBER = COLUMN_DATA1; - public static final int COLUMN_PHONE_TYPE = COLUMN_DATA2; - public static final int COLUMN_EMAIL_ADDRESS = COLUMN_DATA1; - public static final int COLUMN_EMAIL_TYPE = COLUMN_DATA2; - + public static final int COLUMN_FULL_NAME = COLUMN_DATA1; public static final int COLUMN_GIVEN_NAME = COLUMN_DATA2; - public static final int COLUMN_FAMILY_NAME = COLUMN_DATA3; + public static final int COLUMN_AVATAR_IMAGE = COLUMN_DATA15; + public static final int COLUMN_SYNC_DIRTY = COLUMN_SYNC1; public static final String SELECTION = Data.RAW_CONTACT_ID + "=?"; } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java index db01f48ae..cb8e97b8e 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -15,115 +15,134 @@ */ package com.example.android.samplesync.platform; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.NetworkUtilities; + import android.content.ContentProviderOperation; import android.content.ContentValues; import android.content.Context; import android.net.Uri; import android.provider.ContactsContract; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; -import android.util.Log; - -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; /** * Helper class for storing data in the platform content providers. */ public class ContactOperations { - private final ContentValues mValues; - - private ContentProviderOperation.Builder mBuilder; - private final BatchOperation mBatchOperation; - private final Context mContext; - - private boolean mYield; - + private boolean mIsSyncOperation; private long mRawContactId; - private int mBackReference; - private boolean mIsNewContact; + /** + * Since we're sending a lot of contact provider operations in a single + * batched operation, we want to make sure that we "yield" periodically + * so that the Contact Provider can write changes to the DB, and can + * open a new transaction. This prevents ANR (application not responding) + * errors. The recommended time to specify that a yield is permitted is + * with the first operation on a particular contact. So if we're updating + * multiple fields for a single contact, we make sure that we call + * withYieldAllowed(true) on the first field that we update. We use + * mIsYieldAllowed to keep track of what value we should pass to + * withYieldAllowed(). + */ + private boolean mIsYieldAllowed; + /** * Returns an instance of ContactOperations instance for adding new contact * to the platform contacts provider. - * + * * @param context the Authenticator Activity context * @param userId the userId of the sample SyncAdapter user object - * @param accountName the username of the current login + * @param accountName the username for the SyncAdapter account + * @param isSyncOperation are we executing this as part of a sync operation? * @return instance of ContactOperations */ - public static ContactOperations createNewContact(Context context, int userId, - String accountName, BatchOperation batchOperation) { - - return new ContactOperations(context, userId, accountName, batchOperation); + public static ContactOperations createNewContact(Context context, long userId, + String accountName, boolean isSyncOperation, BatchOperation batchOperation) { + return new ContactOperations(context, userId, accountName, isSyncOperation, batchOperation); } /** * Returns an instance of ContactOperations for updating existing contact in * the platform contacts provider. - * + * * @param context the Authenticator Activity context * @param rawContactId the unique Id of the existing rawContact + * @param isSyncOperation are we executing this as part of a sync operation? * @return instance of ContactOperations */ public static ContactOperations updateExistingContact(Context context, long rawContactId, - BatchOperation batchOperation) { - - return new ContactOperations(context, rawContactId, batchOperation); + boolean isSyncOperation, BatchOperation batchOperation) { + return new ContactOperations(context, rawContactId, isSyncOperation, batchOperation); } - public ContactOperations(Context context, BatchOperation batchOperation) { + public ContactOperations(Context context, boolean isSyncOperation, + BatchOperation batchOperation) { mValues = new ContentValues(); - mYield = true; + mIsYieldAllowed = true; + mIsSyncOperation = isSyncOperation; mContext = context; mBatchOperation = batchOperation; } - public ContactOperations(Context context, int userId, String accountName, - BatchOperation batchOperation) { - - this(context, batchOperation); + public ContactOperations(Context context, long userId, String accountName, + boolean isSyncOperation, BatchOperation batchOperation) { + this(context, isSyncOperation, batchOperation); mBackReference = mBatchOperation.size(); mIsNewContact = true; mValues.put(RawContacts.SOURCE_ID, userId); mValues.put(RawContacts.ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); mValues.put(RawContacts.ACCOUNT_NAME, accountName); - mBuilder = newInsertCpo(RawContacts.CONTENT_URI, true).withValues(mValues); - mBatchOperation.add(mBuilder.build()); + ContentProviderOperation.Builder builder = + newInsertCpo(RawContacts.CONTENT_URI, mIsSyncOperation, true).withValues(mValues); + mBatchOperation.add(builder.build()); } - public ContactOperations(Context context, long rawContactId, BatchOperation batchOperation) { - this(context, batchOperation); + public ContactOperations(Context context, long rawContactId, boolean isSyncOperation, + BatchOperation batchOperation) { + this(context, isSyncOperation, batchOperation); mIsNewContact = false; mRawContactId = rawContactId; } /** - * Adds a contact name - * - * @param name Name of contact - * @param nameType type of name: family name, given name, etc. + * Adds a contact name. We can take either a full name ("Bob Smith") or separated + * first-name and last-name ("Bob" and "Smith"). + * + * @param fullName The full name of the contact - typically from an edit form + * Can be null if firstName/lastName are specified. + * @param firstName The first name of the contact - can be null if fullName + * is specified. + * @param lastName The last name of the contact - can be null if fullName + * is specified. * @return instance of ContactOperations */ - public ContactOperations addName(String firstName, String lastName) { - + public ContactOperations addName(String fullName, String firstName, String lastName) { mValues.clear(); - if (!TextUtils.isEmpty(firstName)) { - mValues.put(StructuredName.GIVEN_NAME, firstName); - mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); - } - if (!TextUtils.isEmpty(lastName)) { - mValues.put(StructuredName.FAMILY_NAME, lastName); + + if (!TextUtils.isEmpty(fullName)) { + mValues.put(StructuredName.DISPLAY_NAME, fullName); mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } else { + if (!TextUtils.isEmpty(firstName)) { + mValues.put(StructuredName.GIVEN_NAME, firstName); + mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } + if (!TextUtils.isEmpty(lastName)) { + mValues.put(StructuredName.FAMILY_NAME, lastName); + mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } } if (mValues.size() > 0) { addInsertOp(); @@ -133,8 +152,8 @@ public class ContactOperations { /** * Adds an email - * - * @param new email for user + * + * @param the email address we're adding * @return instance of ContactOperations */ public ContactOperations addEmail(String email) { @@ -150,7 +169,7 @@ public class ContactOperations { /** * Adds a phone number - * + * * @param phone new phone number for the contact * @param phoneType the type: cell, home, etc. * @return instance of ContactOperations @@ -166,9 +185,22 @@ public class ContactOperations { return this; } + public ContactOperations addAvatar(String avatarUrl) { + if (avatarUrl != null) { + byte[] avatarBuffer = NetworkUtilities.downloadAvatar(avatarUrl); + if (avatarBuffer != null) { + mValues.clear(); + mValues.put(Photo.PHOTO, avatarBuffer); + mValues.put(Photo.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + addInsertOp(); + } + } + return this; + } + /** * Adds a profile action - * + * * @param userId the userId of the sample SyncAdapter user object * @return instance of ContactOperations */ @@ -186,9 +218,23 @@ public class ContactOperations { return this; } + /** + * Updates contact's serverId + * + * @param serverId the serverId for this contact + * @param uri Uri for the existing raw contact to be updated + * @return instance of ContactOperations + */ + public ContactOperations updateServerId(long serverId, Uri uri) { + mValues.clear(); + mValues.put(RawContacts.SOURCE_ID, serverId); + addUpdateOp(uri); + return this; + } + /** * Updates contact's email - * + * * @param email email id of the sample SyncAdapter user * @param uri Uri for the existing raw contact to be updated * @return instance of ContactOperations @@ -203,25 +249,38 @@ public class ContactOperations { } /** - * Updates contact's name - * - * @param name Name of contact - * @param existingName Name of contact stored in provider - * @param nameType type of name: family name, given name, etc. + * Updates contact's name. The caller can either provide first-name + * and last-name fields or a full-name field. + * * @param uri Uri for the existing raw contact to be updated + * @param existingFirstName the first name stored in provider + * @param existingLastName the last name stored in provider + * @param existingFullName the full name stored in provider + * @param firstName the new first name to store + * @param lastName the new last name to store + * @param fullName the new full name to store * @return instance of ContactOperations */ - public ContactOperations updateName(Uri uri, String existingFirstName, String existingLastName, - String firstName, String lastName) { + public ContactOperations updateName(Uri uri, + String existingFirstName, + String existingLastName, + String existingFullName, + String firstName, + String lastName, + String fullName) { - Log.i("ContactOperations", "ef=" + existingFirstName + "el=" + existingLastName + "f=" - + firstName + "l=" + lastName); mValues.clear(); - if (!TextUtils.equals(existingFirstName, firstName)) { - mValues.put(StructuredName.GIVEN_NAME, firstName); - } - if (!TextUtils.equals(existingLastName, lastName)) { - mValues.put(StructuredName.FAMILY_NAME, lastName); + if (TextUtils.isEmpty(fullName)) { + if (!TextUtils.equals(existingFirstName, firstName)) { + mValues.put(StructuredName.GIVEN_NAME, firstName); + } + if (!TextUtils.equals(existingLastName, lastName)) { + mValues.put(StructuredName.FAMILY_NAME, lastName); + } + } else { + if (!TextUtils.equals(existingFullName, fullName)) { + mValues.put(StructuredName.DISPLAY_NAME, fullName); + } } if (mValues.size() > 0) { addUpdateOp(uri); @@ -229,9 +288,17 @@ public class ContactOperations { return this; } + public ContactOperations updateDirtyFlag(boolean isDirty, Uri uri) { + int isDirtyValue = isDirty ? 1 : 0; + mValues.clear(); + mValues.put(RawContacts.DIRTY, isDirtyValue); + addUpdateOp(uri); + return this; + } + /** * Updates contact's phone - * + * * @param existingNumber phone number stored in contacts provider * @param phone new phone number for the contact * @param uri Uri for the existing raw contact to be updated @@ -246,9 +313,22 @@ public class ContactOperations { return this; } + public ContactOperations updateAvatar(String avatarUrl, Uri uri) { + if (avatarUrl != null) { + byte[] avatarBuffer = NetworkUtilities.downloadAvatar(avatarUrl); + if (avatarBuffer != null) { + mValues.clear(); + mValues.put(Photo.PHOTO, avatarBuffer); + mValues.put(Photo.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + addUpdateOp(uri); + } + } + return this; + } + /** * Updates contact's profile action - * + * * @param userId sample SyncAdapter user id * @param uri Uri for the existing raw contact to be updated * @return instance of ContactOperations @@ -268,41 +348,62 @@ public class ContactOperations { if (!mIsNewContact) { mValues.put(Phone.RAW_CONTACT_ID, mRawContactId); } - mBuilder = newInsertCpo(addCallerIsSyncAdapterParameter(Data.CONTENT_URI), mYield); - mBuilder.withValues(mValues); + ContentProviderOperation.Builder builder = + newInsertCpo(Data.CONTENT_URI, mIsSyncOperation, mIsYieldAllowed); + builder.withValues(mValues); if (mIsNewContact) { - mBuilder.withValueBackReference(Data.RAW_CONTACT_ID, mBackReference); + builder.withValueBackReference(Data.RAW_CONTACT_ID, mBackReference); } - mYield = false; - mBatchOperation.add(mBuilder.build()); + mIsYieldAllowed = false; + mBatchOperation.add(builder.build()); } /** * Adds an update operation into the batch */ private void addUpdateOp(Uri uri) { - mBuilder = newUpdateCpo(uri, mYield).withValues(mValues); - mYield = false; - mBatchOperation.add(mBuilder.build()); + ContentProviderOperation.Builder builder = + newUpdateCpo(uri, mIsSyncOperation, mIsYieldAllowed).withValues(mValues); + mIsYieldAllowed = false; + mBatchOperation.add(builder.build()); } - public static ContentProviderOperation.Builder newInsertCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newInsert(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newInsertCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newInsert(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - public static ContentProviderOperation.Builder newUpdateCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newUpdate(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newUpdateCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newUpdate(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - public static ContentProviderOperation.Builder newDeleteCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newDelete(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newDeleteCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newDelete(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - private static Uri addCallerIsSyncAdapterParameter(Uri uri) { - return uri.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") - .build(); + private static Uri addCallerIsSyncAdapterParameter(Uri uri, boolean isSyncOperation) { + if (isSyncOperation) { + // If we're in the middle of a real sync-adapter operation, then go ahead + // and tell the Contacts provider that we're the sync adapter. That + // gives us some special permissions - like the ability to really + // delete a contact, and the ability to clear the dirty flag. + // + // If we're not in the middle of a sync operation (for example, we just + // locally created/edited a new contact), then we don't want to use + // the special permissions, and the system will automagically mark + // the contact as 'dirty' for us! + return uri.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + } + return uri; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java index 7b60d5bd7..e8a99a448 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java index 206189ad5..0ca8dee5b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -24,12 +24,12 @@ import android.content.ContentProviderClient; import android.content.Context; import android.content.SyncResult; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import com.example.android.samplesync.Constants; import com.example.android.samplesync.client.NetworkUtilities; -import com.example.android.samplesync.client.User; -import com.example.android.samplesync.client.User.Status; +import com.example.android.samplesync.client.RawContact; import com.example.android.samplesync.platform.ContactManager; import org.apache.http.ParseException; @@ -37,23 +37,27 @@ import org.apache.http.auth.AuthenticationException; import org.json.JSONException; import java.io.IOException; +import java.util.ArrayList; import java.util.Date; import java.util.List; /** * SyncAdapter implementation for syncing sample SyncAdapter contacts to the - * platform ContactOperations provider. + * platform ContactOperations provider. This sample shows a basic 2-way + * sync between the client and a sample server. It also contains an + * example of how to update the contacts' status messages, which + * would be useful for a messaging or social networking client. */ public class SyncAdapter extends AbstractThreadedSyncAdapter { private static final String TAG = "SyncAdapter"; + private static final String SYNC_MARKER_KEY = "com.example.android.samplesync.marker"; + private static final boolean NOTIFY_AUTH_FAILURE = true; private final AccountManager mAccountManager; private final Context mContext; - private Date mLastUpdated; - public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); mContext = context; @@ -64,42 +68,101 @@ public class SyncAdapter extends AbstractThreadedSyncAdapter { public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { - List users; - List statuses; - String authtoken = null; try { - // use the account manager to request the credentials - authtoken = - mAccountManager - .blockingGetAuthToken(account, Constants.AUTHTOKEN_TYPE, true /* notifyAuthFailure */); - // fetch updates from the sample service over the cloud - users = NetworkUtilities.fetchFriendUpdates(account, authtoken, mLastUpdated); - // update the last synced date. - mLastUpdated = new Date(); - // update platform contacts. + // see if we already have a sync-state attached to this account. By handing + // This value to the server, we can just get the contacts that have + // been updated on the server-side since our last sync-up + long lastSyncMarker = getServerSyncMarker(account); + + // By default, contacts from a 3rd party provider are hidden in the contacts + // list. So let's set the flag that causes them to be visible, so that users + // can actually see these contacts. + if (lastSyncMarker == 0) { + ContactManager.setAccountContactsVisibility(getContext(), account, true); + } + + List dirtyContacts; + List updatedContacts; + + // Use the account manager to request the AuthToken we'll need + // to talk to our sample server. If we don't have an AuthToken + // yet, this could involve a round-trip to the server to request + // and AuthToken. + final String authtoken = mAccountManager.blockingGetAuthToken(account, + Constants.AUTHTOKEN_TYPE, NOTIFY_AUTH_FAILURE); + + // Find the local 'dirty' contacts that we need to tell the server about... + // Find the local users that need to be sync'd to the server... + dirtyContacts = ContactManager.getDirtyContacts(mContext, account); + + // Send the dirty contacts to the server, and retrieve the server-side changes + updatedContacts = NetworkUtilities.syncContacts(account, authtoken, + lastSyncMarker, dirtyContacts); + + // Update the local contacts database with the changes. updateContacts() + // returns a syncState value that indicates the high-water-mark for + // the changes we received. Log.d(TAG, "Calling contactManager's sync contacts"); - ContactManager.syncContacts(mContext, account.name, users); - // fetch and update status messages for all the synced users. - statuses = NetworkUtilities.fetchFriendStatuses(account, authtoken); - ContactManager.insertStatuses(mContext, account.name, statuses); + long newSyncState = ContactManager.updateContacts(mContext, + account.name, + updatedContacts, + lastSyncMarker); + + // This is a demo of how you can update IM-style status messages + // for contacts on the client. This probably won't apply to + // 2-way contact sync providers - it's more likely that one-way + // sync providers (IM clients, social networking apps, etc) would + // use this feature. + ContactManager.updateStatusMessages(mContext, updatedContacts); + + // Save off the new sync marker. On our next sync, we only want to receive + // contacts that have changed since this sync... + setServerSyncMarker(account, newSyncState); + + if (dirtyContacts.size() > 0) { + ContactManager.clearSyncFlags(mContext, dirtyContacts); + } + } catch (final AuthenticatorException e) { - syncResult.stats.numParseExceptions++; Log.e(TAG, "AuthenticatorException", e); + syncResult.stats.numParseExceptions++; } catch (final OperationCanceledException e) { Log.e(TAG, "OperationCanceledExcetpion", e); } catch (final IOException e) { Log.e(TAG, "IOException", e); syncResult.stats.numIoExceptions++; } catch (final AuthenticationException e) { - mAccountManager.invalidateAuthToken(Constants.ACCOUNT_TYPE, authtoken); - syncResult.stats.numAuthExceptions++; Log.e(TAG, "AuthenticationException", e); + syncResult.stats.numAuthExceptions++; } catch (final ParseException e) { - syncResult.stats.numParseExceptions++; Log.e(TAG, "ParseException", e); - } catch (final JSONException e) { syncResult.stats.numParseExceptions++; + } catch (final JSONException e) { Log.e(TAG, "JSONException", e); + syncResult.stats.numParseExceptions++; } } + + /** + * This helper function fetches the last known high-water-mark + * we received from the server - or 0 if we've never synced. + * @param account the account we're syncing + * @return the change high-water-mark + */ + private long getServerSyncMarker(Account account) { + String markerString = mAccountManager.getUserData(account, SYNC_MARKER_KEY); + if (!TextUtils.isEmpty(markerString)) { + return Long.parseLong(markerString); + } + return 0; + } + + /** + * Save off the high-water-mark we receive back from the server. + * @param account The account we're syncing + * @param marker The high-water-mark we want to save. + */ + private void setServerSyncMarker(Account account, long marker) { + mAccountManager.setUserData(account, SYNC_MARKER_KEY, Long.toString(marker)); + } }