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
diff --git a/samples/SampleSyncAdapter/Android.mk b/samples/SampleSyncAdapter/Android.mk
index a27a68f..0f87c17 100644
--- a/samples/SampleSyncAdapter/Android.mk
+++ b/samples/SampleSyncAdapter/Android.mk
@@ -6,10 +6,12 @@
# 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 202ed0e..fd53a16 100644
--- a/samples/SampleSyncAdapter/AndroidManifest.xml
+++ b/samples/SampleSyncAdapter/AndroidManifest.xml
@@ -46,7 +46,7 @@
<uses-permission
android:name="android.permission.WRITE_SYNC_SETTINGS" />
- <uses-sdk android:minSdkVersion="5" />
+ <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="11"/>
<application
android:icon="@drawable/icon"
@@ -82,11 +82,36 @@
android:label="@string/ui_activity_title"
android:theme="@android:style/Theme.Dialog"
android:excludeFromRecents="true"
+ android:configChanges="orientation"
>
<!--
No intent-filter here! This activity is only ever launched by
someone who explicitly knows the class name
-->
</activity>
+
+ <activity
+ android:name=".editor.ContactEditorActivity"
+ android:theme="@style/ContactEditTheme"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action
+ android:name="android.intent.action.INSERT" />
+ <data
+ android:mimeType="vnd.android.cursor.item/contact" />
+ </intent-filter>
+
+ <!--
+ Note that the editor gets a raw contact URI, but is expected to call
+ setResult with the corresponding aggregate contact URI, not raw contact
+ URI.
+ -->
+ <intent-filter>
+ <action
+ android:name="android.intent.action.EDIT" />
+ <data
+ android:mimeType="vnd.android.cursor.item/raw_contact" />
+ </intent-filter>
+ </activity>
</application>
</manifest>
diff --git a/samples/SampleSyncAdapter/_index.html b/samples/SampleSyncAdapter/_index.html
index 4191ba5..603083a 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).</p>
+adapter). It also demonstrates how to provide users the ability to create
+and edit synchronized contacts using a custom editor.</p>
<p> The <a
href="../../../reference/android/accounts/AccountManager.html">account
@@ -26,7 +27,7 @@
issues a sync operation for that sync adapter. </p>
<p> The cloud-based service for this sample application is running at: </p>
-<p style="margin-left:2em;">http://samplesyncadapter.appspot.com/users</p>
+<p style="margin-left:2em;">http://samplesyncadapter2.appspot.com/</p>
<p>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 0000000..ab71f2c
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/drawable/border.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <solid android:color="@color/EditPanelBackgroundColor" />
+ <stroke android:width="2dip" android:color="@color/EditPanelBorderColor" />
+ <padding android:left="5dip" android:top="5dip" android:right="5dip" android:bottom="5dip" />
+</shape>
\ 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 0000000..3468bbd
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/drawable/done_menu_icon.png
Binary files differ
diff --git a/samples/SampleSyncAdapter/res/layout-xlarge/editor.xml b/samples/SampleSyncAdapter/res/layout-xlarge/editor.xml
new file mode 100644
index 0000000..3b7d97b
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/layout-xlarge/editor.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!--
+/**
+ * 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.
+ */
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:gravity="center_horizontal">
+ <LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="600dip"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:background="@drawable/border">
+ <include layout="@layout/editor_header" />
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:paddingTop="20dip"
+ android:paddingRight="20dip"
+ android:paddingBottom="20dip"
+ android:paddingLeft="20dip"
+ android:layout_weight="1">
+ <include layout="@layout/editor_fields" />
+ </ScrollView>
+ </LinearLayout>
+</LinearLayout>
\ 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 0000000..648e6f9
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/layout-xlarge/editor_header.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+
+<!-- Account info header -->
+<RelativeLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_height="64dip"
+ android:layout_width="match_parent"
+ android:background="?android:attr/selectableItemBackground">
+
+ <ImageView
+ android:id="@+id/header_account_icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="7dip"
+ android:layout_marginRight="7dip"
+ android:layout_centerVertical="true"
+ android:layout_alignParentRight="true"
+ android:src="@drawable/icon" />
+
+ <TextView
+ android:id="@+id/header_account_type"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_toLeftOf="@+id/header_account_icon"
+ android:layout_alignTop="@id/header_account_icon"
+ android:layout_marginTop="-4dip"
+ android:textSize="24sp"
+ android:textColor="?android:attr/textColorPrimary"
+ android:singleLine="true"
+ android:text="@string/header_account_type" />
+
+ <TextView
+ android:id="@+id/header_account_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_toLeftOf="@+id/header_account_icon"
+ android:layout_alignBottom="@+id/header_account_icon"
+ android:layout_marginBottom="2dip"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorPrimary"
+ android:singleLine="true" />
+
+</RelativeLayout>
diff --git a/samples/SampleSyncAdapter/res/layout/editor.xml b/samples/SampleSyncAdapter/res/layout/editor.xml
new file mode 100644
index 0000000..a0c36d2
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/layout/editor.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!--
+/**
+ * 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.
+ */
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:paddingTop="20dip"
+ android:paddingRight="20dip"
+ android:paddingBottom="20dip"
+ android:paddingLeft="20dip"
+ android:layout_weight="1">
+ <include layout="@layout/editor_fields" />
+ </ScrollView>
+
+</LinearLayout>
\ 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 0000000..31d0128
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/layout/editor_fields.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!--
+/**
+ * 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.
+ */
+-->
+
+<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="fill_parent"
+ android:layout_height="fill_parent"
+ android:stretchColumns="2">
+
+ <TableRow>
+ <TextView
+ android:layout_column="1"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_name"
+ android:padding="3dip" />
+ <EditText
+ android:layout_column="2"
+ android:id="@+id/editor_name"
+ android:singleLine="true"
+ android:inputType="textPersonName"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:minWidth="250dip"
+ android:scrollHorizontally="true"
+ android:capitalize="none"
+ android:textSize="@dimen/contact_name_text_size"
+ android:gravity="fill_horizontal"
+ android:autoText="false" />
+ </TableRow>
+ <TableRow>
+ <TextView
+ android:layout_column="1"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_phone_home"
+ android:padding="3dip" />
+ <EditText
+ android:id="@+id/editor_phone_home"
+ android:singleLine="true"
+ android:inputType="phone"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:minWidth="250dip"
+ android:scrollHorizontally="true"
+ android:capitalize="none"
+ android:gravity="fill_horizontal"
+ android:autoText="false" />
+ </TableRow>
+ <TableRow>
+ <TextView
+ android:layout_column="1"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_phone_mobile"
+ android:padding="3dip" />
+ <EditText
+ android:id="@+id/editor_phone_mobile"
+ android:singleLine="true"
+ android:inputType="phone"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:minWidth="250dip"
+ android:scrollHorizontally="true"
+ android:capitalize="none"
+ android:gravity="fill_horizontal"
+ android:autoText="false" />
+ </TableRow>
+ <TableRow>
+ <TextView
+ android:layout_column="1"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_phone_work"
+ android:padding="3dip" />
+ <EditText
+ android:id="@+id/editor_phone_work"
+ android:singleLine="true"
+ android:phoneNumber="true"
+ android:autoText="true"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:minWidth="250dip"
+ android:scrollHorizontally="true"
+ android:capitalize="none"
+ android:gravity="fill_horizontal" />
+ </TableRow>
+ <TableRow>
+ <TextView
+ android:layout_column="1"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_email"
+ android:padding="3dip" />
+ <EditText
+ android:id="@+id/editor_email"
+ android:singleLine="true"
+ android:inputType="textEmailAddress"
+ android:layout_width="fill_parent"
+ android:layout_height="wrap_content"
+ android:minWidth="250dip"
+ android:scrollHorizontally="true"
+ android:capitalize="none"
+ android:gravity="fill_horizontal"
+ android:autoText="false" />
+ </TableRow>
+</TableLayout>
diff --git a/samples/SampleSyncAdapter/res/menu/edit.xml b/samples/SampleSyncAdapter/res/menu/edit.xml
new file mode 100644
index 0000000..1227584
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/menu/edit.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+ <item
+ android:id="@+id/menu_done"
+ android:alphabeticShortcut="\n"
+ android:icon="@drawable/done_menu_icon"
+ android:title="@string/menu_done"
+ android:showAsAction="always|withText" />
+
+ <item
+ android:id="@+id/menu_cancel"
+ android:alphabeticShortcut="q"
+ android:title="@string/menu_cancel"
+ android:showAsAction="always|withText" />
+</menu>
diff --git a/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml b/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml
new file mode 100644
index 0000000..f9d74a7
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/values-xlarge/dimens.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * 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.
+ */
+-->
+<resources>
+
+ <!-- Font size used for the contact name in the editor -->
+ <dimen name="contact_name_text_size">26sp</dimen>
+
+</resources>
diff --git a/samples/SampleSyncAdapter/res/values/dimens.xml b/samples/SampleSyncAdapter/res/values/dimens.xml
new file mode 100644
index 0000000..7518c07
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/values/dimens.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * 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.
+ */
+-->
+
+<resources>
+
+ <!-- Font size used for the contact name in the editor -->
+ <dimen name="contact_name_text_size">18sp</dimen>
+
+</resources>
diff --git a/samples/SampleSyncAdapter/res/values/strings.xml b/samples/SampleSyncAdapter/res/values/strings.xml
index 8139d65..3b5ac58 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">
<!-- Label for this package -->
<string
- name="label">SamplesyncAdapter</string>
+ name="label">Sample SyncAdapter</string>
<!-- Permission label -->
<string
@@ -90,4 +90,16 @@
name="profile_action">Sample profile</string>
<string
name="view_profile">View Profile</string>
+
+ <string name="header_account_type">SampleSync contact</string>
+
+ <string name="label_name">Name</string>
+ <string name="label_phone_home">Home Phone</string>
+ <string name="label_phone_mobile">Mobile Phone</string>
+ <string name="label_phone_work">Work Phone</string>
+ <string name="label_email">Email</string>
+ <string
+ name="menu_done">Done</string>
+ <string
+ name="menu_cancel">Cancel</string>
</resources>
\ 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 0000000..074613e
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/values/styles.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<resources>
+ <!--
+ These styles will only be used in Honeycomb and later because
+ Android doesn't support third-party contact editing in pre-
+ Honeycomb versions.
+ -->
+ <color name="EditPanelBackgroundColor">#ffffff</color>
+ <color name="EditPanelBorderColor">#cccccc</color>
+ <style name="ContactEditTheme" parent="android:Theme.Holo.Light">
+ </style>
+</resources>
diff --git a/samples/SampleSyncAdapter/res/xml-v11/contacts.xml b/samples/SampleSyncAdapter/res/xml-v11/contacts.xml
new file mode 100644
index 0000000..62dfa80
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/xml-v11/contacts.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * 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.
+ */
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ editContactActivity="com.example.android.samplesync.editor.ContactEditorActivity"
+ createContactActivity="com.example.android.samplesync.editor.ContactEditorActivity"
+>
+
+ <ContactsDataKind
+ android:mimeType="vnd.android.cursor.item/vnd.samplesyncadapter.profile"
+ android:icon="@drawable/icon"
+ android:summaryColumn="data2"
+ android:detailColumn="data3"
+ android:detailSocialSummary="true" />
+
+</ContactsAccountType>
diff --git a/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml b/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml
new file mode 100644
index 0000000..dac8ade
--- /dev/null
+++ b/samples/SampleSyncAdapter/res/xml-v11/syncadapter.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * 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.
+ */
+-->
+
+<!--
+ The attributes in this XML file provide configuration information
+ for the SampleSyncAdapter.
+
+ See xml/syncadapter.xml for greater details, but this version of
+ the file specifies that uploading (and thus editing) is supported.
+-->
+
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+ android:contentAuthority="com.android.contacts"
+ android:accountType="com.example.android.samplesync"
+ android:supportsUploading="true"
+ android:userVisible="true"
+/>
diff --git a/samples/SampleSyncAdapter/res/xml/contacts.xml b/samples/SampleSyncAdapter/res/xml/contacts.xml
index 1ff9c05..b46257d 100644
--- a/samples/SampleSyncAdapter/res/xml/contacts.xml
+++ b/samples/SampleSyncAdapter/res/xml/contacts.xml
@@ -17,7 +17,11 @@
*/
-->
-<ContactsSource xmlns:android="http://schemas.android.com/apk/res/android">
+<ContactsSource
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ editContactActivity="com.example.android.samplesync.editor.ContactEditorActivity"
+ createContactActivity="com.example.android.samplesync.editor.ContactEditorActivity"
+>
<ContactsDataKind
android:mimeType="vnd.android.cursor.item/vnd.samplesyncadapter.profile"
diff --git a/samples/SampleSyncAdapter/res/xml/syncadapter.xml b/samples/SampleSyncAdapter/res/xml/syncadapter.xml
index 1f75947..3053a24 100644
--- a/samples/SampleSyncAdapter/res/xml/syncadapter.xml
+++ b/samples/SampleSyncAdapter/res/xml/syncadapter.xml
@@ -17,11 +17,21 @@
*/
-->
-<!-- The attributes in this XML file provide configuration information -->
-<!-- for the SyncAdapter. -->
+<!--
+ The attributes in this XML file provide configuration information
+ for the SampleSyncAdapter.
+
+ We have two versions of this file - one here, and one in the
+ xml-v11 directory (Honeycomb and beyond). This one specifies that
+ the syncadapter does not support uploading (and thus the contacts
+ associated with this syncadapter are not editable). The SDK 11
+ version of the file specifies that the adapter DOES support
+ uploading, so the contacts on SDK 11 and greater are editable.
+-->
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="com.android.contacts"
android:accountType="com.example.android.samplesync"
android:supportsUploading="false"
+ android:userVisible="true"
/>
diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml b/samples/SampleSyncAdapter/samplesyncadapter_server/app.yaml
index 109eff3..8526177 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 0000000..1a0badb
--- /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 c986f7e..b9bac5e 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.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 = '<input type="hidden" name="_id" value="%s">'
+ 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('<html><body>'
- '<form method="POST" '
- 'action="/add_user">'
- '<table>')
- # This generates our shopping list form and writes it in the response
- self.response.out.write(UserForm())
- self.response.out.write('</table>'
- '<input type="submit">'
- '</form></body></html>')
+ 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('<html><body>'
- '<form method="POST" '
- 'action="/">'
- '<table>')
- self.response.out.write(data)
- self.response.out.write('</table>'
- '<input type="submit">'
- '</form></body></html>')
+ 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('<html><body>'
- '<form method="POST" '
- 'action="/edit_user">'
- '<table>')
- # This generates our shopping list form and writes it in the response
- self.response.out.write(UserForm(instance=user))
- self.response.out.write('</table>'
- '<input type="hidden" name="_id" value="%s">'
- '<input type="submit">'
- '</form></body></html>' % 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('<html><body>'
- '<form method="POST" '
- 'action="/edit_user">'
- '<table>')
- self.response.out.write(data)
- self.response.out.write('</table>'
- '<input type="hidden" name="_id" value="%s">'
- '<input type="submit">'
- '</form></body></html>' % 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('<html><body>'
- '<form method="POST" '
- 'action="/add_credentials">'
- '<table>')
- # This generates our shopping list form and writes it in the response
- self.response.out.write(UserCredentialsForm())
- self.response.out.write('</table>'
- '<input type="submit">'
- '</form></body></html>')
+ 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('<html><body>'
- '<form method="POST" '
- 'action="/add_credentials">'
- '<table>')
- self.response.out.write(data)
- self.response.out.write('</table>'
- '<input type="submit">'
- '</form></body></html>')
-
-
-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('<html><body>'
- '<form method="POST" '
- 'action="/add_friend">'
- '<table>')
- # This generates our shopping list form and writes it in the response
- self.response.out.write(UserFriendsForm())
- self.response.out.write('</table>'
- '<input type = hidden name = "user" value = "%s">'
- '<input type="submit">'
- '</form></body></html>' % 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('<html><body>'
- '<form method="POST" '
- 'action="/add_friend">'
- '<table>')
- self.response.out.write(data)
- self.response.out.write('</table>'
- '<input type="submit">'
- '</form></body></html>')
-
-
-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 83ceea0..6f02db5 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 2d7c5c7..0000000
--- 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 71bd18a..1f91633 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 0000000..eab304d
--- /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 0000000..5089e95
--- /dev/null
+++ b/samples/SampleSyncAdapter/samplesyncadapter_server/static/img/default_avatar.gif
Binary files differ
diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html
new file mode 100644
index 0000000..b668d33
--- /dev/null
+++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/contacts.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<!--
+ * 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.
+-->
+ <head>
+ <title>SampleSync: Contacts for '{{ username }}'</title>
+ <link type="text/css" rel="stylesheet" href="/static/css/main.css" media="screen" />
+ </head>
+ <body>
+ <h1>SampleSync: Contacts for '{{ username }}'</h1>
+ <table class="data" cellpadding="0" cellspacing="0">
+ <tr>
+ <th> </th>
+ <th>Id</th>
+ <th>Name</th>
+ <th>Email</th>
+ <th>Home</th>
+ <th>Office</th>
+ <th>Mobile</th>
+ <th>Status</th>
+ </tr>
+ {% for contact in contacts %}
+ <tr {% if contact.deleted %} class="deleted" {% endif %}>
+ <td class="center">
+ <a href="/edit_avatar?id={{ contact.key.id }}"><img src="/avatar?id={{ contact.key.id }}" height="25" width="25" /></a>
+ </td>
+ <td><a href="/edit_contact?id={{ contact.key.id }}">{{ contact.key.id }}</a></td>
+ <td><a href="/edit_contact?id={{ contact.key.id }}">{{ contact.firstname }} {{ contact.lastname }}</a></td>
+ <td>{{ contact.email }}</td>
+ <td>{{ contact.phone_home }}</td>
+ <td>{{ contact.phone_office }}</td>
+ <td>{{ contact.phone_mobile }}</td>
+ <td><span style="whitespace: no-wrap;">{{ contact.status }}</span></td>
+ </tr>
+ {% endfor %}
+ </table>
+
+ <a href = "/add_contact">Add Contact</a>
+ </body>
+</html>
\ 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 0000000..49cd3d3
--- /dev/null
+++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/edit_avatar.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<!--
+ * 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.
+-->
+ <head>
+ <title>SampleSync: Edit Picture</title>
+ <link type="text/css" rel="stylesheet" href="/static/css/main.css" media="screen" />
+ </head>
+ <body>
+ <h1>SampleSync: Edit Picture</h1>
+ <form method="POST" action="/edit_avatar" enctype="multipart/form-data">
+ <h3>Current Avatar:</h3>
+ <blockquote>
+ {% if avatar %}
+ <img src="/avatar?id={{ contactId }}" />
+ {% else %}
+ <i>You haven't added a picture for this friend...</i>
+ {% endif %}
+ </blockquote>
+ <h3>New Avatar:</h3>
+ <p>Please select a file containing the image you'd like to use for this friend</p>
+ <input type="file" name="avatar" />
+ <p> </p>
+ <input type="submit" name="Save" value="Save Changes" />
+ <input type="button" name="Cancel" value="Cancel" onclick="document.location='/';return false;" />
+ <input type="hidden" name="id" value="{{ contactId }}" />
+ </form>
+ </body>
+</html>
\ 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 0000000..1d72819
--- /dev/null
+++ b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/simple_form.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
+<!--
+ * 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.
+-->
+ <head>
+ <title>SampleSync: {{ title }}</title>
+ <link type="text/css" rel="stylesheet" href="/static/css/main.css" media="screen" />
+ </head>
+ <body>
+ <h1>SampleSync: {{ header }}</h1>
+ <form method="POST" action="{{ action }}">
+ <table class="form" cellpadding="0" cellspacing="0">
+ {{ form_data_rows }}
+ </table>
+ <input type="submit" name="Save" value="Save Changes" />
+ <input type="button" name="Cancel" value="Cancel" onclick="document.location='/';return false;" />
+ {% if has_contactId %}
+ <input type="hidden" name="id" value="{{ contactId }}" />
+ {% endif %}
+ {% if has_handle %}
+ <input type="hidden" name="username" value="{{ handle }}" />
+ {% endif %}
+ </form>
+ </body>
+</html>
diff --git a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html b/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html
deleted file mode 100644
index 044c352..0000000
--- a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/users.html
+++ /dev/null
@@ -1,19 +0,0 @@
-<html>
-<body>
-<h1> Sample Sync Adapter </h1>
-
-<p>
-<h3> List of Users </h3>
-<table>
-
-{% for user in users %}
- <tr><td>
- <a
- href="/edit_user?user={{ user.key.id}}">{{ user.firstname }} {{ user.lastname }} </a>
- </td><td> <a href="/user_friends?user={{ user.handle }}">Friends</a> </td>
- </tr>
-{% endfor %}
-</table>
-</p>
-
-<a href = "/add_user"> Insert More </a>
\ 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 d4ef892..0000000
--- a/samples/SampleSyncAdapter/samplesyncadapter_server/templates/view_friends.html
+++ /dev/null
@@ -1,17 +0,0 @@
-<html>
-<body>
-<h1> Sample Sync Adapter </h1>
-
-<p>
-
-{{user}}'s friends
-<table>
-{% for friend in friends %}
- <tr><td>
- {{ friend.friend_handle }} </td><td> <a href="/delete_friend?user={{ user }}&friend={{friend.friend_handle}}">Remove</a>
- </td></tr>
-{% endfor %}
-</table>
-</p>
-
-<a href = "/add_friend?user={{user}}"> Add More </a>
\ 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 0000000..3028add
--- /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 49f92bf..bc09da2 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 2c163be..9fc72eb 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 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 0c79c5e..2eb6ecd 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 @@
@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 @@
}
@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 @@
@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 4e1ee2a..2a3c0fc 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.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.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 @@
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 @@
* {@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 @@
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 @@
* 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 @@
/**
* 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 @@
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) {
- Log.i(TAG, "onAuthenticationResult(" + result + ")");
+ boolean success = ((authToken != null) && (authToken.length() > 0));
+ Log.i(TAG, "onAuthenticationResult(" + success + ")");
+
+ // Our task is complete, so clear it out
+ mAuthTask = null;
+
// 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 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 @@
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<Void, Void, String> {
+
+ @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 7824a4d..bebcd72 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.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 @@
* 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 @@
/**
* 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<NameValuePair> params = new ArrayList<NameValuePair>();
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<RawContact> syncContacts(
+ Account account, String authtoken, long serverSyncState, List<RawContact> dirtyContacts)
+ throws JSONException, ParseException, IOException, AuthenticationException {
+ // Convert our list of User objects into a list of JSONObject
+ List<JSONObject> jsonContacts = new ArrayList<JSONObject>();
+ 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<RawContact> serverDirtyList = new ArrayList<RawContact>();
- /**
- * 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<User> fetchFriendUpdates(Account account, String authtoken, Date lastUpdated)
- throws JSONException, ParseException, IOException, AuthenticationException {
-
- final ArrayList<User> friendList = new ArrayList<User>();
+ // Prepare our POST data
final ArrayList<NameValuePair> params = new ArrayList<NameValuePair>();
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<User.Status> fetchFriendStatuses(Account account, String authtoken)
- throws JSONException, ParseException, IOException, AuthenticationException {
-
- final ArrayList<User.Status> statusList = new ArrayList<User.Status>();
- final ArrayList<NameValuePair> params = new ArrayList<NameValuePair>();
- 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 0000000..6aa73d9
--- /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 217a383..0000000
--- 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 0000000..2e30be7
--- /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<Uri, Void, Cursor> {
+
+ @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<RawContact, Void, Uri> {
+
+ @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<RawContact, Void, Uri> {
+
+ @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 0be3daa..3a9c879 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 @@
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 218b165..2335d38 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 @@
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<User> users) {
+ public static synchronized long updateContacts(Context context, String account,
+ List<RawContact> 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<RawContact> newUsers = new ArrayList<RawContact>();
+
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<User.Status> list) {
+ public static List<RawContact> getDirtyContacts(Context context, Account account) {
+ Log.i(TAG, "*** Looking for local dirty contacts");
+ List<RawContact> dirtyContacts = new ArrayList<RawContact>();
- 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<RawContact> 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<RawContact> 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 @@
/**
* 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) {
- Uri uri;
- String cellPhone = null;
- String otherPhone = null;
- String email = null;
+ boolean existingCellPhone = false;
+ boolean existingHomePhone = false;
+ boolean existingWorkPhone = false;
+ boolean existingEmail = false;
+ boolean existingAvatar = false;
+
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 @@
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 @@
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 @@
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,9 +667,15 @@
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 "
@@ -314,6 +683,40 @@
}
/**
+ * 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
*/
final private static class DataQuery {
@@ -322,29 +725,29 @@
}
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 db01f48..cb8e97b 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);
+
+ if (!TextUtils.isEmpty(fullName)) {
+ mValues.put(StructuredName.DISPLAY_NAME, fullName);
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);
+ } 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 @@
/**
* 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 @@
/**
* 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 @@
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
*/
@@ -187,8 +219,22 @@
}
/**
+ * 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 @@
}
/**
- * 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 @@
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 @@
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 @@
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 7b60d5b..e8a99a4 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 206189a..0ca8dee 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.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.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 void onPerformSync(Account account, Bundle extras, String authority,
ContentProviderClient provider, SyncResult syncResult) {
- List<User> users;
- List<Status> 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<RawContact> dirtyContacts;
+ List<RawContact> 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));
+ }
}