diff options
| author | John Evans <jsevans@google.com> | 2011-04-04 13:38:01 -0700 |
|---|---|---|
| committer | John Evans <jsevans@google.com> | 2011-04-28 10:31:38 -0700 |
| commit | 15ef1a8091c1557f175575671a5af62420088944 (patch) | |
| tree | 7a0cbf489ee714162450c9db51c536e952be2525 /samples/SampleSyncAdapter/src | |
| parent | 3f2b06f3c5b63c716d305eeedc8655114666015a (diff) | |
| download | android_development-15ef1a8091c1557f175575671a5af62420088944.tar.gz android_development-15ef1a8091c1557f175575671a5af62420088944.tar.bz2 android_development-15ef1a8091c1557f175575671a5af62420088944.zip | |
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
Diffstat (limited to 'samples/SampleSyncAdapter/src')
13 files changed, 1800 insertions, 721 deletions
diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java index 49f92bfff..bc09da253 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/Constants.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java index 2c163be66..9fc72eb4d 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticationService.java @@ -1,18 +1,19 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; import android.app.Service; @@ -49,7 +50,7 @@ public class AuthenticationService extends Service { public IBinder onBind(Intent intent) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "getBinder()... returning the AccountAuthenticator binder for intent " - + intent); + + intent); } return mAuthenticator.getIBinder(); } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java index 0c79c5e99..2eb6ecd7c 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java @@ -1,38 +1,57 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.client.NetworkUtilities; + import android.accounts.AbstractAccountAuthenticator; import android.accounts.Account; import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; +import android.accounts.NetworkErrorException; import android.content.Context; import android.content.Intent; import android.os.Bundle; - -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.NetworkUtilities; +import android.text.TextUtils; +import android.util.Log; /** * This class is an implementation of AbstractAccountAuthenticator for - * authenticating accounts in the com.example.android.samplesync domain. + * authenticating accounts in the com.example.android.samplesync domain. The + * interesting thing that this class demonstrates is the use of authTokens as + * part of the authentication process. In the account setup UI, the user enters + * their username and password. But for our subsequent calls off to the service + * for syncing, we want to use an authtoken instead - so we're not continually + * sending the password over the wire. getAuthToken() will be called when + * SyncAdapter calls AccountManager.blockingGetAuthToken(). When we get called, + * we need to return the appropriate authToken for the specified account. If we + * already have an authToken stored in the account, we return that authToken. If + * we don't, but we do have a username and password, then we'll attempt to talk + * to the sample service to fetch an authToken. If that fails (or we didn't have + * a username/password), then we need to prompt the user - so we create an + * AuthenticatorActivity intent and return that. That will display the dialog + * that prompts the user for their login information. */ class Authenticator extends AbstractAccountAuthenticator { + /** The tag used to log to adb console. **/ + private static final String TAG = "Authenticator"; + // Authentication Service context private final Context mContext; @@ -43,10 +62,9 @@ class Authenticator extends AbstractAccountAuthenticator { @Override public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, - String authTokenType, String[] requiredFeatures, Bundle options) { - + String authTokenType, String[] requiredFeatures, Bundle options) { + Log.v(TAG, "addAccount()"); final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); final Bundle bundle = new Bundle(); bundle.putParcelable(AccountManager.KEY_INTENT, intent); @@ -54,54 +72,49 @@ class Authenticator extends AbstractAccountAuthenticator { } @Override - public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, - Bundle options) { - - if (options != null && options.containsKey(AccountManager.KEY_PASSWORD)) { - final String password = options.getString(AccountManager.KEY_PASSWORD); - final boolean verified = onlineConfirmPassword(account.name, password); - final Bundle result = new Bundle(); - result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, verified); - return result; - } - // Launch AuthenticatorActivity to confirm credentials - final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); - intent.putExtra(AuthenticatorActivity.PARAM_CONFIRM_CREDENTIALS, true); - intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); - final Bundle bundle = new Bundle(); - bundle.putParcelable(AccountManager.KEY_INTENT, intent); - return bundle; + public Bundle confirmCredentials( + AccountAuthenticatorResponse response, Account account, Bundle options) { + Log.v(TAG, "confirmCredentials()"); + return null; } @Override public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + Log.v(TAG, "editProperties()"); throw new UnsupportedOperationException(); } @Override public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, - String authTokenType, Bundle loginOptions) { + String authTokenType, Bundle loginOptions) throws NetworkErrorException { + Log.v(TAG, "getAuthToken()"); + // If the caller requested an authToken type we don't support, then + // return an error if (!authTokenType.equals(Constants.AUTHTOKEN_TYPE)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType"); return result; } + + // Extract the username and password from the Account Manager, and ask + // the server for an appropriate AuthToken. final AccountManager am = AccountManager.get(mContext); final String password = am.getPassword(account); if (password != null) { - final boolean verified = onlineConfirmPassword(account.name, password); - if (verified) { + final String authToken = NetworkUtilities.authenticate(account.name, password); + if (!TextUtils.isEmpty(authToken)) { final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); - result.putString(AccountManager.KEY_AUTHTOKEN, password); + result.putString(AccountManager.KEY_AUTHTOKEN, authToken); return result; } } - // the password was missing or incorrect, return an Intent to an - // Activity that will prompt the user for the password. + + // If we get here, then we couldn't access the user's password - so we + // need to re-prompt them for their credentials. We do that by creating + // an intent to display our AuthenticatorActivity panel. final Intent intent = new Intent(mContext, AuthenticatorActivity.class); intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); @@ -113,39 +126,27 @@ class Authenticator extends AbstractAccountAuthenticator { @Override public String getAuthTokenLabel(String authTokenType) { - if (Constants.AUTHTOKEN_TYPE.equals(authTokenType)) { - return mContext.getString(R.string.label); - } + // null means we don't support multiple authToken types + Log.v(TAG, "getAuthTokenLabel()"); return null; } @Override - public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, - String[] features) { - + public Bundle hasFeatures( + AccountAuthenticatorResponse response, Account account, String[] features) { + // This call is used to query whether the Authenticator supports + // specific features. We don't expect to get called, so we always + // return false (no) for any queries. + Log.v(TAG, "hasFeatures()"); final Bundle result = new Bundle(); result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false); return result; } - /** - * Validates user's password on the server - */ - private boolean onlineConfirmPassword(String username, String password) { - return NetworkUtilities - .authenticate(username, password, null/* Handler */, null/* Context */); - } - @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, - String authTokenType, Bundle loginOptions) { - - final Intent intent = new Intent(mContext, AuthenticatorActivity.class); - intent.putExtra(AuthenticatorActivity.PARAM_USERNAME, account.name); - intent.putExtra(AuthenticatorActivity.PARAM_AUTHTOKEN_TYPE, authTokenType); - intent.putExtra(AuthenticatorActivity.PARAM_CONFIRM_CREDENTIALS, false); - final Bundle bundle = new Bundle(); - bundle.putParcelable(AccountManager.KEY_INTENT, intent); - return bundle; + String authTokenType, Bundle loginOptions) { + Log.v(TAG, "updateCredentials()"); + return null; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java index 4e1ee2a75..2a3c0fc4c 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/AuthenticatorActivity.java @@ -1,20 +1,25 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.authenticator; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.NetworkUtilities; + import android.accounts.Account; import android.accounts.AccountAuthenticatorActivity; import android.accounts.AccountManager; @@ -23,6 +28,7 @@ import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.DialogInterface; import android.content.Intent; +import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.provider.ContactsContract; @@ -33,41 +39,36 @@ import android.view.Window; import android.widget.EditText; import android.widget.TextView; -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.NetworkUtilities; - /** * Activity which displays login screen to the user. */ public class AuthenticatorActivity extends AccountAuthenticatorActivity { - - /** The Intent flag to confirm credentials. **/ + /** The Intent flag to confirm credentials. */ public static final String PARAM_CONFIRM_CREDENTIALS = "confirmCredentials"; - /** The Intent extra to store password. **/ + /** The Intent extra to store password. */ public static final String PARAM_PASSWORD = "password"; - /** The Intent extra to store username. **/ + /** The Intent extra to store username. */ public static final String PARAM_USERNAME = "username"; - /** The Intent extra to store authtoken type. **/ + /** The Intent extra to store username. */ public static final String PARAM_AUTHTOKEN_TYPE = "authtokenType"; - /** The tag used to log to adb console. **/ + /** The tag used to log to adb console. */ private static final String TAG = "AuthenticatorActivity"; - private AccountManager mAccountManager; - private Thread mAuthThread; - - private String mAuthtoken; + /** Keep track of the login task so can cancel it if requested */ + private UserLoginTask mAuthTask = null; - private String mAuthtokenType; + /** Keep track of the progress dialog so we can dismiss it */ + private ProgressDialog mProgressDialog = null; /** * If set we are just checking that the user knows their credentials; this - * doesn't cause the user's password to be changed on the device. + * doesn't cause the user's password or authToken to be changed on the + * device. */ private Boolean mConfirmCredentials = false; @@ -99,14 +100,13 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { Log.i(TAG, "loading data from Intent"); final Intent intent = getIntent(); mUsername = intent.getStringExtra(PARAM_USERNAME); - mAuthtokenType = intent.getStringExtra(PARAM_AUTHTOKEN_TYPE); mRequestNewAccount = mUsername == null; mConfirmCredentials = intent.getBooleanExtra(PARAM_CONFIRM_CREDENTIALS, false); Log.i(TAG, " request new: " + mRequestNewAccount); requestWindowFeature(Window.FEATURE_LEFT_ICON); setContentView(R.layout.login_activity); - getWindow().setFeatureDrawableResource(Window.FEATURE_LEFT_ICON, - android.R.drawable.ic_dialog_alert); + getWindow().setFeatureDrawableResource( + Window.FEATURE_LEFT_ICON, android.R.drawable.ic_dialog_alert); mMessage = (TextView) findViewById(R.id.message); mUsernameEdit = (EditText) findViewById(R.id.username_edit); mPasswordEdit = (EditText) findViewById(R.id.password_edit); @@ -118,27 +118,31 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { * {@inheritDoc} */ @Override - protected Dialog onCreateDialog(int id) { + protected Dialog onCreateDialog(int id, Bundle args) { final ProgressDialog dialog = new ProgressDialog(this); dialog.setMessage(getText(R.string.ui_activity_authenticating)); dialog.setIndeterminate(true); dialog.setCancelable(true); dialog.setOnCancelListener(new DialogInterface.OnCancelListener() { public void onCancel(DialogInterface dialog) { - Log.i(TAG, "dialog cancel has been invoked"); - if (mAuthThread != null) { - mAuthThread.interrupt(); - finish(); + Log.i(TAG, "user cancelling authentication"); + if (mAuthTask != null) { + mAuthTask.cancel(true); } } }); + // We save off the progress dialog in a field so that we can dismiss + // it later. We can't just call dismissDialog(0) because the system + // can lose track of our dialog if there's an orientation change. + mProgressDialog = dialog; return dialog; } /** * Handles onClick event on the Submit button. Sends username/password to - * the server for authentication. - * + * the server for authentication. The button is configured to call + * handleLogin() in the layout XML. + * * @param view The Submit button for which this method is invoked */ public void handleLogin(View view) { @@ -149,11 +153,11 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { if (TextUtils.isEmpty(mUsername) || TextUtils.isEmpty(mPassword)) { mMessage.setText(getMessage()); } else { + // Show a progress dialog, and kick off a background task to perform + // the user login attempt. showProgress(); - // Start authenticating... - mAuthThread = - NetworkUtilities.attemptAuth(mUsername, mPassword, mHandler, - AuthenticatorActivity.this); + mAuthTask = new UserLoginTask(); + mAuthTask.execute(); } } @@ -161,8 +165,8 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { * Called when response is received from the server for confirm credentials * request. See onAuthenticationResult(). Sets the * AccountAuthenticatorResult which is sent back to the caller. - * - * @param the confirmCredentials result. + * + * @param result the confirmCredentials result. */ private void finishConfirmCredentials(boolean result) { Log.i(TAG, "finishConfirmCredentials()"); @@ -178,12 +182,13 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { /** * Called when response is received from the server for authentication * request. See onAuthenticationResult(). Sets the - * AccountAuthenticatorResult which is sent back to the caller. Also sets - * the authToken in AccountManager for this account. - * - * @param the confirmCredentials result. + * AccountAuthenticatorResult which is sent back to the caller. We store the + * authToken that's returned from the server as the 'password' for this + * account - so we're never storing the user's actual password locally. + * + * @param result the confirmCredentials result. */ - private void finishLogin() { + private void finishLogin(String authToken) { Log.i(TAG, "finishLogin()"); final Account account = new Account(mUsername, Constants.ACCOUNT_TYPE); @@ -195,37 +200,35 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { mAccountManager.setPassword(account, mPassword); } final Intent intent = new Intent(); - mAuthtoken = mPassword; intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, mUsername); intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); - if (mAuthtokenType != null && mAuthtokenType.equals(Constants.AUTHTOKEN_TYPE)) { - intent.putExtra(AccountManager.KEY_AUTHTOKEN, mAuthtoken); - } setAccountAuthenticatorResult(intent.getExtras()); setResult(RESULT_OK, intent); finish(); } /** - * Hides the progress UI for a lengthy operation. - */ - private void hideProgress() { - dismissDialog(0); - } - - /** * Called when the authentication process completes (see attemptLogin()). + * + * @param authToken the authentication token returned by the server, or NULL if + * authentication failed. */ - public void onAuthenticationResult(boolean result) { + public void onAuthenticationResult(String authToken) { + + boolean success = ((authToken != null) && (authToken.length() > 0)); + Log.i(TAG, "onAuthenticationResult(" + success + ")"); + + // Our task is complete, so clear it out + mAuthTask = null; - Log.i(TAG, "onAuthenticationResult(" + result + ")"); // Hide the progress dialog hideProgress(); - if (result) { + + if (success) { if (!mConfirmCredentials) { - finishLogin(); + finishLogin(authToken); } else { - finishConfirmCredentials(true); + finishConfirmCredentials(success); } } else { Log.e(TAG, "onAuthenticationResult: failed to authenticate"); @@ -241,6 +244,16 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { } } + public void onAuthenticationCancel() { + Log.i(TAG, "onAuthenticationCancel()"); + + // Our task is complete, so clear it out + mAuthTask = null; + + // Hide the progress dialog + hideProgress(); + } + /** * Returns the message to be displayed at the top of the login dialog box. */ @@ -265,4 +278,49 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity { private void showProgress() { showDialog(0); } + + /** + * Hides the progress UI for a lengthy operation. + */ + private void hideProgress() { + if (mProgressDialog != null) { + mProgressDialog.dismiss(); + mProgressDialog = null; + } + } + + /** + * Represents an asynchronous task used to authenticate a user against the + * SampleSync Service + */ + public class UserLoginTask extends AsyncTask<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 7824a4d20..bebcd729b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/NetworkUtilities.java @@ -1,23 +1,27 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ + package com.example.android.samplesync.client; import android.accounts.Account; import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.os.Handler; +import android.text.TextUtils; import android.util.Log; import com.example.android.samplesync.authenticator.AuthenticatorActivity; @@ -37,11 +41,19 @@ import org.apache.http.message.BasicNameValuePair; import org.apache.http.params.HttpConnectionParams; import org.apache.http.params.HttpParams; import org.apache.http.util.EntityUtils; +import org.json.JSONObject; import org.json.JSONArray; import org.json.JSONException; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -52,31 +64,26 @@ import java.util.TimeZone; * Provides utility methods for communicating with the server. */ final public class NetworkUtilities { - - /** The tag used to log to adb console. **/ + /** The tag used to log to adb console. */ private static final String TAG = "NetworkUtilities"; - - /** The Intent extra to store password. **/ - public static final String PARAM_PASSWORD = "password"; - - /** The Intent extra to store username. **/ + /** POST parameter name for the user's account name */ public static final String PARAM_USERNAME = "username"; - - public static final String PARAM_UPDATED = "timestamp"; - - public static final String USER_AGENT = "AuthenticationService/1.0"; - - public static final int REGISTRATION_TIMEOUT_MS = 30 * 1000; // ms - - public static final String BASE_URL = "https://samplesyncadapter.appspot.com"; - + /** POST parameter name for the user's password */ + public static final String PARAM_PASSWORD = "password"; + /** POST parameter name for the user's authentication token */ + public static final String PARAM_AUTH_TOKEN = "authtoken"; + /** POST parameter name for the client's last-known sync state */ + public static final String PARAM_SYNC_STATE = "syncstate"; + /** POST parameter name for the sending client-edited contact info */ + public static final String PARAM_CONTACTS_DATA = "contacts"; + /** Timeout (in ms) we specify for each http request */ + public static final int HTTP_REQUEST_TIMEOUT_MS = 30 * 1000; + /** Base URL for the v2 Sample Sync Service */ + public static final String BASE_URL = "https://samplesyncadapter2.appspot.com"; + /** URI for authentication service */ public static final String AUTH_URI = BASE_URL + "/auth"; - - public static final String FETCH_FRIEND_UPDATES_URI = BASE_URL + "/fetch_friend_updates"; - - public static final String FETCH_STATUS_URI = BASE_URL + "/fetch_status"; - - private static HttpClient mHttpClient; + /** URI for sync service */ + public static final String SYNC_CONTACTS_URI = BASE_URL + "/sync"; private NetworkUtilities() { } @@ -84,224 +91,188 @@ final public class NetworkUtilities { /** * Configures the httpClient to connect to the URL provided. */ - public static void maybeCreateHttpClient() { - if (mHttpClient == null) { - mHttpClient = new DefaultHttpClient(); - final HttpParams params = mHttpClient.getParams(); - HttpConnectionParams.setConnectionTimeout(params, REGISTRATION_TIMEOUT_MS); - HttpConnectionParams.setSoTimeout(params, REGISTRATION_TIMEOUT_MS); - ConnManagerParams.setTimeout(params, REGISTRATION_TIMEOUT_MS); - } - } - - /** - * Executes the network requests on a separate thread. - * - * @param runnable The runnable instance containing network mOperations to - * be executed. - */ - public static Thread performOnBackgroundThread(final Runnable runnable) { - final Thread t = new Thread() { - @Override - public void run() { - try { - runnable.run(); - } finally { - } - } - }; - t.start(); - return t; + 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; } /** - * 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. + * 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 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); - } - - /** - * 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 { + // 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>(); - 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 { + public static byte[] downloadAvatar(final String avatarUrl) { + // If there is no avatar, we're done + if (TextUtils.isEmpty(avatarUrl)) { + return null; + } - 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(); + 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 statusList; + return null; } + } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java new file mode 100644 index 000000000..6aa73d9a2 --- /dev/null +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/RawContact.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.android.samplesync.client; + +import android.text.TextUtils; +import android.util.Log; + +import org.json.JSONObject; +import org.json.JSONException; + +import java.lang.StringBuilder; + +/** + * Represents a low-level contacts RawContact - or at least + * the fields of the RawContact that we care about. + */ +final public class RawContact { + + /** The tag used to log to adb console. **/ + private static final String TAG = "RawContact"; + + private final String mUserName; + + private final String mFullName; + + private final String mFirstName; + + private final String mLastName; + + private final String mCellPhone; + + private final String mOfficePhone; + + private final String mHomePhone; + + private final String mEmail; + + private final String mStatus; + + private final String mAvatarUrl; + + private final boolean mDeleted; + + private final boolean mDirty; + + private final long mServerContactId; + + private final long mRawContactId; + + private final long mSyncState; + + public long getServerContactId() { + return mServerContactId; + } + + public long getRawContactId() { + return mRawContactId; + } + + public String getUserName() { + return mUserName; + } + + public String getFirstName() { + return mFirstName; + } + + public String getLastName() { + return mLastName; + } + + public String getFullName() { + return mFullName; + } + + public String getCellPhone() { + return mCellPhone; + } + + public String getOfficePhone() { + return mOfficePhone; + } + + public String getHomePhone() { + return mHomePhone; + } + + public String getEmail() { + return mEmail; + } + + public String getStatus() { + return mStatus; + } + + public String getAvatarUrl() { + return mAvatarUrl; + } + + public boolean isDeleted() { + return mDeleted; + } + + public boolean isDirty() { + return mDirty; + } + + public long getSyncState() { + return mSyncState; + } + + public String getBestName() { + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } else if (TextUtils.isEmpty(mFirstName)) { + return mLastName; + } else { + return mFirstName; + } + } + + /** + * Convert the RawContact object into a JSON string. From the + * JSONString interface. + * @return a JSON string representation of the object + */ + public JSONObject toJSONObject() { + JSONObject json = new JSONObject(); + + try { + if (!TextUtils.isEmpty(mFirstName)) { + json.put("f", mFirstName); + } + if (!TextUtils.isEmpty(mLastName)) { + json.put("l", mLastName); + } + if (!TextUtils.isEmpty(mCellPhone)) { + json.put("m", mCellPhone); + } + if (!TextUtils.isEmpty(mOfficePhone)) { + json.put("o", mOfficePhone); + } + if (!TextUtils.isEmpty(mHomePhone)) { + json.put("h", mHomePhone); + } + if (!TextUtils.isEmpty(mEmail)) { + json.put("e", mEmail); + } + if (mServerContactId > 0) { + json.put("i", mServerContactId); + } + if (mRawContactId > 0) { + json.put("c", mRawContactId); + } + if (mDeleted) { + json.put("d", mDeleted); + } + } catch (final Exception ex) { + Log.i(TAG, "Error converting RawContact to JSONObject" + ex.toString()); + } + + return json; + } + + public RawContact(String name, String fullName, String firstName, String lastName, + String cellPhone, String officePhone, String homePhone, String email, + String status, String avatarUrl, boolean deleted, long serverContactId, + long rawContactId, long syncState, boolean dirty) { + mUserName = name; + mFullName = fullName; + mFirstName = firstName; + mLastName = lastName; + mCellPhone = cellPhone; + mOfficePhone = officePhone; + mHomePhone = homePhone; + mEmail = email; + mStatus = status; + mAvatarUrl = avatarUrl; + mDeleted = deleted; + mServerContactId = serverContactId; + mRawContactId = rawContactId; + mSyncState = syncState; + mDirty = dirty; + } + + /** + * Creates and returns an instance of the RawContact from the provided JSON data. + * + * @param user The JSONObject containing user data + * @return user The new instance of Sample RawContact created from the JSON data. + */ + public static RawContact valueOf(JSONObject contact) { + + try { + final String userName = !contact.isNull("u") ? contact.getString("u") : null; + final int serverContactId = !contact.isNull("i") ? contact.getInt("i") : -1; + // If we didn't get either a username or serverId for the contact, then + // we can't do anything with it locally... + if ((userName == null) && (serverContactId <= 0)) { + throw new JSONException("JSON contact missing required 'u' or 'i' fields"); + } + + final int rawContactId = !contact.isNull("c") ? contact.getInt("c") : -1; + final String firstName = !contact.isNull("f") ? contact.getString("f") : null; + final String lastName = !contact.isNull("l") ? contact.getString("l") : null; + final String cellPhone = !contact.isNull("m") ? contact.getString("m") : null; + final String officePhone = !contact.isNull("o") ? contact.getString("o") : null; + final String homePhone = !contact.isNull("h") ? contact.getString("h") : null; + final String email = !contact.isNull("e") ? contact.getString("e") : null; + final String status = !contact.isNull("s") ? contact.getString("s") : null; + final String avatarUrl = !contact.isNull("a") ? contact.getString("a") : null; + final boolean deleted = !contact.isNull("d") ? contact.getBoolean("d") : false; + final long syncState = !contact.isNull("x") ? contact.getLong("x") : 0; + return new RawContact(userName, null, firstName, lastName, cellPhone, + officePhone, homePhone, email, status, avatarUrl, deleted, + serverContactId, rawContactId, syncState, false); + } catch (final Exception ex) { + Log.i(TAG, "Error parsing JSON contact object" + ex.toString()); + } + return null; + } + + /** + * Creates and returns RawContact instance from all the supplied parameters. + */ + public static RawContact create(String fullName, String firstName, String lastName, + String cellPhone, String officePhone, String homePhone, + String email, String status, boolean deleted, long rawContactId, + long serverContactId) { + return new RawContact(null, fullName, firstName, lastName, cellPhone, officePhone, + homePhone, email, status, null, deleted, serverContactId, rawContactId, + -1, true); + } + + /** + * Creates and returns a User instance that represents a deleted user. + * Since the user is deleted, all we need are the client/server IDs. + * @param clientUserId The client-side ID for the contact + * @param serverUserId The server-side ID for the contact + * @return a minimal User object representing the deleted contact. + */ + public static RawContact createDeletedContact(long rawContactId, long serverContactId) + { + return new RawContact(null, null, null, null, null, null, null, + null, null, null, true, serverContactId, rawContactId, -1, true); + } +} diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java deleted file mode 100644 index 217a383d8..000000000 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/client/User.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2010 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ -package com.example.android.samplesync.client; - -import android.util.Log; - -import org.json.JSONObject; - -/** - * Represents a sample SyncAdapter user - */ -final public class User { - - private final String mUserName; - - private final String mFirstName; - - private final String mLastName; - - private final String mCellPhone; - - private final String mOfficePhone; - - private final String mHomePhone; - - private final String mEmail; - - private final boolean mDeleted; - - private final int mUserId; - - public int getUserId() { - return mUserId; - } - - public String getUserName() { - return mUserName; - } - - public String getFirstName() { - return mFirstName; - } - - public String getLastName() { - return mLastName; - } - - public String getCellPhone() { - return mCellPhone; - } - - public String getOfficePhone() { - return mOfficePhone; - } - - public String getHomePhone() { - return mHomePhone; - } - - public String getEmail() { - return mEmail; - } - - public boolean isDeleted() { - return mDeleted; - } - - private User(String name, String firstName, String lastName, String cellPhone, - String officePhone, String homePhone, String email, Boolean deleted, Integer userId) { - - mUserName = name; - mFirstName = firstName; - mLastName = lastName; - mCellPhone = cellPhone; - mOfficePhone = officePhone; - mHomePhone = homePhone; - mEmail = email; - mDeleted = deleted; - mUserId = userId; - } - - /** - * Creates and returns an instance of the user from the provided JSON data. - * - * @param user The JSONObject containing user data - * @return user The new instance of Voiper user created from the JSON data. - */ - public static User valueOf(JSONObject user) { - - try { - final String userName = user.getString("u"); - final String firstName = user.has("f") ? user.getString("f") : null; - final String lastName = user.has("l") ? user.getString("l") : null; - final String cellPhone = user.has("m") ? user.getString("m") : null; - final String officePhone = user.has("o") ? user.getString("o") : null; - final String homePhone = user.has("h") ? user.getString("h") : null; - final String email = user.has("e") ? user.getString("e") : null; - final boolean deleted = user.has("d") ? user.getBoolean("d") : false; - final int userId = user.getInt("i"); - return new User(userName, firstName, lastName, cellPhone, officePhone, homePhone, - email, deleted, userId); - } catch (final Exception ex) { - Log.i("User", "Error parsing JSON user object" + ex.toString()); - } - return null; - } - - /** - * Represents the User's status messages - * - */ - final public static class Status { - - private final Integer mUserId; - - private final String mStatus; - - public int getUserId() { - return mUserId; - } - - public String getStatus() { - return mStatus; - } - - public Status(Integer userId, String status) { - mUserId = userId; - mStatus = status; - } - - public static User.Status valueOf(JSONObject userStatus) { - try { - final int userId = userStatus.getInt("i"); - final String status = userStatus.getString("s"); - return new User.Status(userId, status); - } catch (final Exception ex) { - Log.i("User.Status", "Error parsing JSON user object"); - } - return null; - } - } -} diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java new file mode 100644 index 000000000..2e30be7f2 --- /dev/null +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/editor/ContactEditorActivity.java @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.android.samplesync.editor; + +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.RawContact; +import com.example.android.samplesync.platform.BatchOperation; +import com.example.android.samplesync.platform.ContactManager; +import com.example.android.samplesync.platform.ContactManager.EditorQuery; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.RawContacts; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.EditText; +import android.widget.TextView; + +/** + * Implements a sample editor for a contact that belongs to a remote contact service. + * The editor can be invoked for an existing SampleSyncAdapter contact, or it can + * be used to create a brand new SampleSyncAdapter contact. We look at the Intent + * object to figure out whether this is a "new" or "edit" operation. + */ +public class ContactEditorActivity extends Activity { + private static final String TAG = "SampleSyncAdapter"; + + // Keep track of whether we're inserting a new contact or editing an + // existing contact. + private boolean mIsInsert; + + // The name of the external account we're syncing this contact to. + private String mAccountName; + + // For existing contacts, this is the URI to the contact data. + private Uri mRawContactUri; + + // The raw clientId for this contact + private long mRawContactId; + + // Make sure we only attempt to save the contact once if the + // user presses the "done" button multiple times... + private boolean mSaveInProgress = false; + + // Keep track of the controls used to edit contact values, so we can get/set + // those values easily. + private EditText mNameEditText; + private EditText mHomePhoneEditText; + private EditText mMobilePhoneEditText; + private EditText mWorkPhoneEditText; + private EditText mEmailEditText; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.editor); + + mNameEditText = (EditText)findViewById(R.id.editor_name); + mHomePhoneEditText = (EditText)findViewById(R.id.editor_phone_home); + mMobilePhoneEditText = (EditText)findViewById(R.id.editor_phone_mobile); + mWorkPhoneEditText = (EditText)findViewById(R.id.editor_phone_work); + mEmailEditText = (EditText)findViewById(R.id.editor_email); + + // Figure out whether we're creating a new contact (ACTION_INSERT) or editing + // an existing contact. + Intent intent = getIntent(); + String action = intent.getAction(); + if (Intent.ACTION_INSERT.equals(action)) { + // We're inserting a new contact, so save off the external account name + // which should have been added to the intent we were passed. + mIsInsert = true; + String accountName = intent.getStringExtra(RawContacts.ACCOUNT_NAME); + if (accountName == null) { + Log.e(TAG, "Account name is required"); + finish(); + } + setAccountName(accountName); + } else { + // We're editing an existing contact. Load in the data from the contact + // so that the user can edit it. + mIsInsert = false; + mRawContactUri = intent.getData(); + if (mRawContactUri == null) { + Log.e(TAG, "Raw contact URI is required"); + finish(); + } + startLoadRawContactEntity(); + } + } + + @Override + public void onBackPressed() { + // This method will have been called if the user presses the "Back" button + // in the ActionBar. We treat that the same way as the "Done" button in + // the ActionBar. + save(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // This method gets called so that we can place items in the main Options menu - + // for example, the ActionBar items. We add our menus from the res/menu/edit.xml + // file. + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.edit, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + case R.id.menu_done: + // The user pressed the "Home" button or our "Done" button - both + // in the ActionBar. In both cases, we want to save the contact + // and exit. + save(); + return true; + case R.id.menu_cancel: + // The user pressed the Cancel menu item in the ActionBar. + // Close the editor without saving any changes. + finish(); + return true; + } + return false; + } + + /** + * Create an AsyncTask to load the contact from the Contacts data provider + */ + private void startLoadRawContactEntity() { + Uri uri = Uri.withAppendedPath(mRawContactUri, RawContacts.Entity.CONTENT_DIRECTORY); + new LoadRawContactTask().execute(uri); + } + + /** + * Called by the LoadRawContactTask when the contact information has been + * successfully loaded from the Contacts data provider. + */ + public void onRawContactEntityLoaded(Cursor cursor) { + while (cursor.moveToNext()) { + String mimetype = cursor.getString(EditorQuery.COLUMN_MIMETYPE); + if (StructuredName.CONTENT_ITEM_TYPE.equals(mimetype)) { + setAccountName(cursor.getString(EditorQuery.COLUMN_ACCOUNT_NAME)); + mRawContactId = cursor.getLong(EditorQuery.COLUMN_RAW_CONTACT_ID); + mNameEditText.setText(cursor.getString(EditorQuery.COLUMN_FULL_NAME)); + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimetype)) { + final int type = cursor.getInt(EditorQuery.COLUMN_PHONE_TYPE); + if (type == Phone.TYPE_HOME) { + mHomePhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } else if (type == Phone.TYPE_MOBILE) { + mMobilePhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } else if (type == Phone.TYPE_WORK) { + mWorkPhoneEditText.setText(cursor.getString(EditorQuery.COLUMN_PHONE_NUMBER)); + } + } else if (Email.CONTENT_ITEM_TYPE.equals(mimetype)) { + mEmailEditText.setText(cursor.getString(EditorQuery.COLUMN_DATA1)); + } + } + } + + /** + * Save the updated contact data. We actually take two different actions + * depending on whether we are creating a new contact or editing an + * existing contact. + */ + public void save() { + // If we're already saving this contact, don't kick-off yet + // another save - the user probably just pressed the "Done" + // button multiple times... + if (mSaveInProgress) { + return; + } + + mSaveInProgress = true; + if (mIsInsert) { + saveNewContact(); + } else { + saveChanges(); + } + } + + /** + * Save off the external contacts provider account name. We show the account name + * in the header section of the edit panel, and we also need it later when we + * save off a brand new contact. + */ + private void setAccountName(String accountName) { + mAccountName = accountName; + if (accountName != null) { + TextView accountNameLabel = (TextView)findViewById(R.id.header_account_name); + if (accountNameLabel != null) { + accountNameLabel.setText(accountName); + } + } + } + + /** + * Save a new contact using the Contacts content provider. The actual insertion + * is performed in an AsyncTask. + */ + @SuppressWarnings("unchecked") + private void saveNewContact() { + new InsertContactTask().execute(buildRawContact()); + } + + /** + * Save changes to an existing contact. The actual update is performed in + * an AsyncTask. + */ + @SuppressWarnings("unchecked") + private void saveChanges() { + new UpdateContactTask().execute(buildRawContact()); + } + + /** + * Build a RawContact object from the data in the user-editable form + * @return a new RawContact object representing the edited user + */ + private RawContact buildRawContact() { + return RawContact.create(mNameEditText.getText().toString(), + null, + null, + mMobilePhoneEditText.getText().toString(), + mWorkPhoneEditText.getText().toString(), + mHomePhoneEditText.getText().toString(), + mEmailEditText.getText().toString(), + null, + false, + mRawContactId, + -1); + } + + /** + * Called after a contact is saved - both for edited contacts and new contacts. + * We set the final result of the activity to be "ok", and then close the activity + * by calling finish(). + */ + public void onContactSaved(Uri result) { + if (result != null) { + Intent intent = new Intent(); + intent.setData(result); + setResult(RESULT_OK, intent); + finish(); + } + mSaveInProgress = false; + } + + /** + * Represents an asynchronous task used to load a contact from + * the Contacts content provider. + * + */ + public class LoadRawContactTask extends AsyncTask<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 0be3daa8b..3a9c879f2 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/BatchOperation.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -16,9 +16,11 @@ package com.example.android.samplesync.platform; import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.Context; import android.content.OperationApplicationException; +import android.net.Uri; import android.os.RemoteException; import android.provider.ContactsContract; import android.util.Log; @@ -50,19 +52,24 @@ final public class BatchOperation { mOperations.add(cpo); } - public void execute() { + public Uri execute() { + Uri result = null; if (mOperations.size() == 0) { - return; + return result; } // Apply the mOperations to the content provider try { - mResolver.applyBatch(ContactsContract.AUTHORITY, mOperations); + ContentProviderResult[] results = mResolver.applyBatch(ContactsContract.AUTHORITY, + mOperations); + if ((results != null) && (results.length > 0)) + result = results[0].uri; } catch (final OperationApplicationException e1) { Log.e(TAG, "storing contact data failed", e1); } catch (final RemoteException e2) { Log.e(TAG, "storing contact data failed", e2); } mOperations.clear(); + return result; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java index 218b165b6..2335d383b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactManager.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -15,25 +15,30 @@ */ package com.example.android.samplesync.platform; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.RawContact; + +import android.accounts.Account; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.RawContacts; -import android.provider.ContactsContract.StatusUpdates; +import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Im; import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.Settings; +import android.provider.ContactsContract.StatusUpdates; import android.util.Log; -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; -import com.example.android.samplesync.client.User; - +import java.util.ArrayList; import java.util.List; /** @@ -49,171 +54,485 @@ public class ContactManager { private static final String TAG = "ContactManager"; /** - * Synchronize raw contacts - * + * Take a list of updated contacts and apply those changes to the + * contacts database. Typically this list of contacts would have been + * returned from the server, and we want to apply those changes locally. + * * @param context The context of Authenticator Activity * @param account The username for the account - * @param users The list of users + * @param rawContacts The list of contacts to update + * @param lastSyncMarker The previous server sync-state + * @return the server syncState that should be used in our next + * sync request. */ - public static synchronized void syncContacts(Context context, String account, List<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 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(); + 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 (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(); } /** * Adds a single contact to the platform contacts provider. - * + * This can be used to respond to a new contact found as part + * of sync information returned from the server, or because a + * user added a new contact. + * * @param context the Authenticator Activity context * @param accountName the account the contact belongs to - * @param user the sample SyncAdapter User object + * @param rawContact the sample SyncAdapter User object + * @param inSync is the add part of a client-server sync? + * @param batchOperation allow us to batch together multiple operations + * into a single provider call */ - private static void addContact(Context context, String accountName, User user, - BatchOperation batchOperation) { + public static void addContact(Context context, String accountName, RawContact rawContact, + boolean inSync, BatchOperation batchOperation) { // Put the data in the contacts provider - final ContactOperations contactOp = - ContactOperations.createNewContact(context, user.getUserId(), accountName, - batchOperation); - contactOp.addName(user.getFirstName(), user.getLastName()).addEmail(user.getEmail()) - .addPhone(user.getCellPhone(), Phone.TYPE_MOBILE).addPhone(user.getHomePhone(), - Phone.TYPE_OTHER).addProfileAction(user.getUserId()); + final ContactOperations contactOp = ContactOperations.createNewContact( + context, rawContact.getServerContactId(), accountName, inSync, batchOperation); + + contactOp.addName(rawContact.getFullName(), rawContact.getFirstName(), + rawContact.getLastName()) + .addEmail(rawContact.getEmail()) + .addPhone(rawContact.getCellPhone(), Phone.TYPE_MOBILE) + .addPhone(rawContact.getHomePhone(), Phone.TYPE_HOME) + .addPhone(rawContact.getOfficePhone(), Phone.TYPE_WORK) + .addAvatar(rawContact.getAvatarUrl()); + + // If we have a serverId, then go ahead and create our status profile. + // Otherwise skip it - and we'll create it after we sync-up to the + // server later on. + if (rawContact.getServerContactId() > 0) { + contactOp.addProfileAction(rawContact.getServerContactId()); + } } /** * Updates a single contact to the platform contacts provider. - * + * This method can be used to update a contact from a sync + * operation or as a result of a user editing a contact + * record. + * + * This operation is actually relatively complex. We query + * the database to find all the rows of info that already + * exist for this Contact. For rows that exist (and thus we're + * modifying existing fields), we create an update operation + * to change that field. But for fields we're adding, we create + * "add" operations to create new rows for those fields. + * * @param context the Authenticator Activity context * @param resolver the ContentResolver to use - * @param accountName the account the contact belongs to - * @param user the sample SyncAdapter contact object. + * @param rawContact the sample SyncAdapter contact object + * @param updateStatus should we update this user's status + * @param updateAvatar should we update this user's avatar image + * @param inSync is the update part of a client-server sync? * @param rawContactId the unique Id for this rawContact in contacts * provider + * @param batchOperation allow us to batch together multiple operations + * into a single provider call */ - private static void updateContact(Context context, ContentResolver resolver, - String accountName, User user, long rawContactId, BatchOperation batchOperation) { + public static void updateContact(Context context, ContentResolver resolver, + RawContact rawContact, boolean updateServerId, boolean updateStatus, boolean updateAvatar, + boolean inSync, long rawContactId, BatchOperation batchOperation) { + + boolean existingCellPhone = false; + boolean existingHomePhone = false; + boolean existingWorkPhone = false; + boolean existingEmail = false; + boolean existingAvatar = false; - Uri uri; - String cellPhone = null; - String otherPhone = null; - String email = null; final Cursor c = - resolver.query(Data.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, + resolver.query(DataQuery.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, new String[] {String.valueOf(rawContactId)}, null); final ContactOperations contactOp = - ContactOperations.updateExistingContact(context, rawContactId, batchOperation); + ContactOperations.updateExistingContact(context, rawContactId, + inSync, batchOperation); try { + // Iterate over the existing rows of data, and update each one + // with the information we received from the server. while (c.moveToNext()) { final long id = c.getLong(DataQuery.COLUMN_ID); final String mimeType = c.getString(DataQuery.COLUMN_MIMETYPE); - uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); + final Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { - final String lastName = c.getString(DataQuery.COLUMN_FAMILY_NAME); - final String firstName = c.getString(DataQuery.COLUMN_GIVEN_NAME); - contactOp.updateName(uri, firstName, lastName, user.getFirstName(), user - .getLastName()); + contactOp.updateName(uri, + c.getString(DataQuery.COLUMN_GIVEN_NAME), + c.getString(DataQuery.COLUMN_FAMILY_NAME), + c.getString(DataQuery.COLUMN_FULL_NAME), + rawContact.getFirstName(), + rawContact.getLastName(), + rawContact.getFullName()); } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { final int type = c.getInt(DataQuery.COLUMN_PHONE_TYPE); if (type == Phone.TYPE_MOBILE) { - cellPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); - contactOp.updatePhone(cellPhone, user.getCellPhone(), uri); - } else if (type == Phone.TYPE_OTHER) { - otherPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); - contactOp.updatePhone(otherPhone, user.getHomePhone(), uri); + existingCellPhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getCellPhone(), uri); + } else if (type == Phone.TYPE_HOME) { + existingHomePhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getHomePhone(), uri); + } else if (type == Phone.TYPE_WORK) { + existingWorkPhone = true; + contactOp.updatePhone(c.getString(DataQuery.COLUMN_PHONE_NUMBER), + rawContact.getOfficePhone(), uri); } - } else if (Data.MIMETYPE.equals(Email.CONTENT_ITEM_TYPE)) { - email = c.getString(DataQuery.COLUMN_EMAIL_ADDRESS); - contactOp.updateEmail(user.getEmail(), email, uri); + } else if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { + existingEmail = true; + contactOp.updateEmail(rawContact.getEmail(), + c.getString(DataQuery.COLUMN_EMAIL_ADDRESS), uri); + } else if (mimeType.equals(Photo.CONTENT_ITEM_TYPE)) { + existingAvatar = true; + contactOp.updateAvatar(rawContact.getAvatarUrl(), uri); } } // while } finally { c.close(); } + // Add the cell phone, if present and not updated above - if (cellPhone == null) { - contactOp.addPhone(user.getCellPhone(), Phone.TYPE_MOBILE); + if (!existingCellPhone) { + contactOp.addPhone(rawContact.getCellPhone(), Phone.TYPE_MOBILE); } - // Add the other phone, if present and not updated above - if (otherPhone == null) { - contactOp.addPhone(user.getHomePhone(), Phone.TYPE_OTHER); + // Add the home phone, if present and not updated above + if (!existingHomePhone) { + contactOp.addPhone(rawContact.getHomePhone(), Phone.TYPE_HOME); + } + + // Add the work phone, if present and not updated above + if (!existingWorkPhone) { + contactOp.addPhone(rawContact.getOfficePhone(), Phone.TYPE_WORK); } // Add the email address, if present and not updated above - if (email == null) { - contactOp.addEmail(user.getEmail()); + if (!existingEmail) { + contactOp.addEmail(rawContact.getEmail()); } + // Add the avatar if we didn't update the existing avatar + if (!existingAvatar) { + contactOp.addAvatar(rawContact.getAvatarUrl()); + } + + // If we need to update the serverId of the contact record, take + // care of that. This will happen if the contact is created on the + // client, and then synced to the server. When we get the updated + // record back from the server, we can set the SOURCE_ID property + // on the contact, so we can (in the future) lookup contacts by + // the serverId. + if (updateServerId) { + Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + contactOp.updateServerId(rawContact.getServerContactId(), uri); + } + + // If we don't have a status profile, then create one. This could + // happen for contacts that were created on the client - we don't + // create the status profile until after the first sync... + final long serverId = rawContact.getServerContactId(); + final long profileId = lookupProfile(resolver, serverId); + if (profileId <= 0) { + contactOp.addProfileAction(serverId); + } + } + + /** + * 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); } /** - * Deletes a contact from the platform contacts provider. - * + * Return a User object with data extracted from a contact stored + * in the local contacts database. + * + * Because a contact is actually stored over several rows in the + * database, our query will return those multiple rows of information. + * We then iterate over the rows and build the User structure from + * what we find. + * + * @param context the Authenticator Activity context + * @param rawContactId the unique ID for the local contact + * @return a User object containing info on that contact + */ + private static RawContact getRawContact(Context context, long rawContactId) { + String firstName = null; + String lastName = null; + String fullName = null; + String cellPhone = null; + String homePhone = null; + String workPhone = null; + String email = null; + long serverId = -1; + + final ContentResolver resolver = context.getContentResolver(); + final Cursor c = + resolver.query(DataQuery.CONTENT_URI, DataQuery.PROJECTION, DataQuery.SELECTION, + new String[] {String.valueOf(rawContactId)}, null); + try { + while (c.moveToNext()) { + final long id = c.getLong(DataQuery.COLUMN_ID); + final String mimeType = c.getString(DataQuery.COLUMN_MIMETYPE); + final long tempServerId = c.getLong(DataQuery.COLUMN_SERVER_ID); + if (tempServerId > 0) { + serverId = tempServerId; + } + final Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, id); + if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { + lastName = c.getString(DataQuery.COLUMN_FAMILY_NAME); + firstName = c.getString(DataQuery.COLUMN_GIVEN_NAME); + fullName = c.getString(DataQuery.COLUMN_FULL_NAME); + } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { + final int type = c.getInt(DataQuery.COLUMN_PHONE_TYPE); + if (type == Phone.TYPE_MOBILE) { + cellPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } else if (type == Phone.TYPE_HOME) { + homePhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } else if (type == Phone.TYPE_WORK) { + workPhone = c.getString(DataQuery.COLUMN_PHONE_NUMBER); + } + } else if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { + email = c.getString(DataQuery.COLUMN_EMAIL_ADDRESS); + } + } // while + } finally { + c.close(); + } + + // Now that we've extracted all the information we care about, + // create the actual User object. + RawContact rawContact = RawContact.create(fullName, firstName, lastName, cellPhone, + workPhone, homePhone, email, null, false, rawContactId, serverId); + + return rawContact; + } + + /** + * Update the status message associated with the specified user. The status + * message would be something that is likely to be used by IM or social + * networking sync providers, and less by a straightforward contact provider. + * But it's a useful demo to see how it's done. + * + * @param context the Authenticator Activity context + * @param rawContact the contact who's status we should update + * @param batchOperation allow us to batch together multiple operations + */ + private static void updateContactStatus(Context context, RawContact rawContact, + BatchOperation batchOperation) { + final ContentValues values = new ContentValues(); + final ContentResolver resolver = context.getContentResolver(); + + final long userId = rawContact.getServerContactId(); + final String username = rawContact.getUserName(); + final String status = rawContact.getStatus(); + + // Look up the user's sample SyncAdapter data row + final long profileId = lookupProfile(resolver, userId); + + // Insert the activity into the stream + if (profileId > 0) { + values.put(StatusUpdates.DATA_ID, profileId); + values.put(StatusUpdates.STATUS, status); + values.put(StatusUpdates.PROTOCOL, Im.PROTOCOL_CUSTOM); + values.put(StatusUpdates.CUSTOM_PROTOCOL, CUSTOM_IM_PROTOCOL); + values.put(StatusUpdates.IM_ACCOUNT, username); + values.put(StatusUpdates.IM_HANDLE, userId); + values.put(StatusUpdates.STATUS_RES_PACKAGE, context.getPackageName()); + values.put(StatusUpdates.STATUS_ICON, R.drawable.icon); + values.put(StatusUpdates.STATUS_LABEL, R.string.label); + batchOperation.add(ContactOperations.newInsertCpo(StatusUpdates.CONTENT_URI, + false, true).withValues(values).build()); + } + } + + /** + * Clear the local system 'dirty' flag for a contact. + * + * @param context the Authenticator Activity context + * @param rawContactId the id of the contact update + * @param batchOperation allow us to batch together multiple operations + */ + private static void clearDirtyFlag(Context context, long rawContactId, + BatchOperation batchOperation) { + final ContactOperations contactOp = + ContactOperations.updateExistingContact(context, rawContactId, true, + batchOperation); + + final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + contactOp.updateDirtyFlag(false, uri); + } + + /** + * Deletes a contact from the platform contacts provider. This method is used + * both for contacts that were deleted locally and then that deletion was synced + * to the server, and for contacts that were deleted on the server and the + * deletion was synced to the client. + * * @param context the Authenticator Activity context * @param rawContactId the unique Id for this rawContact in contacts * provider @@ -222,39 +541,43 @@ public class ContactManager { BatchOperation batchOperation) { batchOperation.add(ContactOperations.newDeleteCpo( - ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), true).build()); + ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), + true, true).build()); } /** * Returns the RawContact id for a sample SyncAdapter contact, or 0 if the * sample SyncAdapter user isn't found. - * - * @param context the Authenticator Activity context - * @param userId the sample SyncAdapter user ID to lookup + * + * @param resolver the content resolver to use + * @param serverContactId the sample SyncAdapter user ID to lookup * @return the RawContact id, or 0 if not found */ - private static long lookupRawContact(ContentResolver resolver, long userId) { + private static long lookupRawContact(ContentResolver resolver, long serverContactId) { - long authorId = 0; - final Cursor c = - resolver.query(RawContacts.CONTENT_URI, UserIdQuery.PROJECTION, UserIdQuery.SELECTION, - new String[] {String.valueOf(userId)}, null); + long rawContactId = 0; + final Cursor c = resolver.query( + UserIdQuery.CONTENT_URI, + UserIdQuery.PROJECTION, + UserIdQuery.SELECTION, + new String[] {String.valueOf(serverContactId)}, + null); try { - if (c.moveToFirst()) { - authorId = c.getLong(UserIdQuery.COLUMN_ID); + if ((c != null) && c.moveToFirst()) { + rawContactId = c.getLong(UserIdQuery.COLUMN_RAW_CONTACT_ID); } } finally { if (c != null) { c.close(); } } - return authorId; + return rawContactId; } /** * Returns the Data id for a sample SyncAdapter contact's profile row, or 0 * if the sample SyncAdapter user isn't found. - * + * * @param resolver a content resolver * @param userId the sample SyncAdapter user ID to lookup * @return the profile Data row id, or 0 if not found @@ -266,7 +589,7 @@ public class ContactManager { resolver.query(Data.CONTENT_URI, ProfileQuery.PROJECTION, ProfileQuery.SELECTION, new String[] {String.valueOf(userId)}, null); try { - if (c != null && c.moveToFirst()) { + if ((c != null) && c.moveToFirst()) { profileId = c.getLong(ProfileQuery.COLUMN_ID); } } finally { @@ -277,6 +600,46 @@ public class ContactManager { return profileId; } + final public static class EditorQuery { + + private EditorQuery() { + } + + public static final String[] PROJECTION = new String[] { + RawContacts.ACCOUNT_NAME, + Data._ID, + RawContacts.Entity.DATA_ID, + Data.MIMETYPE, + Data.DATA1, + Data.DATA2, + Data.DATA3, + Data.DATA15, + Data.SYNC1 + }; + + public static final int COLUMN_ACCOUNT_NAME = 0; + public static final int COLUMN_RAW_CONTACT_ID = 1; + public static final int COLUMN_DATA_ID = 2; + public static final int COLUMN_MIMETYPE = 3; + public static final int COLUMN_DATA1 = 4; + public static final int COLUMN_DATA2 = 5; + public static final int COLUMN_DATA3 = 6; + public static final int COLUMN_DATA15 = 7; + public static final int COLUMN_SYNC1 = 8; + + public static final int COLUMN_PHONE_NUMBER = COLUMN_DATA1; + public static final int COLUMN_PHONE_TYPE = COLUMN_DATA2; + public static final int COLUMN_EMAIL_ADDRESS = COLUMN_DATA1; + public static final int COLUMN_EMAIL_TYPE = COLUMN_DATA2; + public static final int COLUMN_FULL_NAME = COLUMN_DATA1; + public static final int COLUMN_GIVEN_NAME = COLUMN_DATA2; + public static final int COLUMN_FAMILY_NAME = COLUMN_DATA3; + public static final int COLUMN_AVATAR_IMAGE = COLUMN_DATA15; + public static final int COLUMN_SYNC_DIRTY = COLUMN_SYNC1; + + public static final String SELECTION = Data.RAW_CONTACT_ID + "=?"; + } + /** * Constants for a query to find a contact given a sample SyncAdapter user * ID. @@ -304,9 +667,15 @@ public class ContactManager { private UserIdQuery() { } - public final static String[] PROJECTION = new String[] {RawContacts._ID}; + public final static String[] PROJECTION = new String[] { + RawContacts._ID, + RawContacts.CONTACT_ID + }; - public final static int COLUMN_ID = 0; + public final static int COLUMN_RAW_CONTACT_ID = 0; + public final static int COLUMN_LINKED_CONTACT_ID = 1; + + public final static Uri CONTENT_URI = RawContacts.CONTENT_URI; public static final String SELECTION = RawContacts.ACCOUNT_TYPE + "='" + Constants.ACCOUNT_TYPE + "' AND " @@ -314,6 +683,40 @@ public class ContactManager { } /** + * 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 class ContactManager { } public static final String[] PROJECTION = - new String[] {Data._ID, Data.MIMETYPE, Data.DATA1, Data.DATA2, Data.DATA3,}; + new String[] {Data._ID, RawContacts.SOURCE_ID, Data.MIMETYPE, Data.DATA1, + Data.DATA2, Data.DATA3, Data.DATA15, Data.SYNC1}; public static final int COLUMN_ID = 0; + public static final int COLUMN_SERVER_ID = 1; + public static final int COLUMN_MIMETYPE = 2; + public static final int COLUMN_DATA1 = 3; + public static final int COLUMN_DATA2 = 4; + public static final int COLUMN_DATA3 = 5; + public static final int COLUMN_DATA15 = 6; + public static final int COLUMN_SYNC1 = 7; - public static final int COLUMN_MIMETYPE = 1; - - public static final int COLUMN_DATA1 = 2; - - public static final int COLUMN_DATA2 = 3; - - public static final int COLUMN_DATA3 = 4; + public static final Uri CONTENT_URI = Data.CONTENT_URI; public static final int COLUMN_PHONE_NUMBER = COLUMN_DATA1; - public static final int COLUMN_PHONE_TYPE = COLUMN_DATA2; - public static final int COLUMN_EMAIL_ADDRESS = COLUMN_DATA1; - public static final int COLUMN_EMAIL_TYPE = COLUMN_DATA2; - + public static final int COLUMN_FULL_NAME = COLUMN_DATA1; public static final int COLUMN_GIVEN_NAME = COLUMN_DATA2; - public static final int COLUMN_FAMILY_NAME = COLUMN_DATA3; + public static final int COLUMN_AVATAR_IMAGE = COLUMN_DATA15; + public static final int COLUMN_SYNC_DIRTY = COLUMN_SYNC1; public static final String SELECTION = Data.RAW_CONTACT_ID + "=?"; } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java index db01f48ae..cb8e97b8e 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/ContactOperations.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -15,115 +15,134 @@ */ package com.example.android.samplesync.platform; +import com.example.android.samplesync.Constants; +import com.example.android.samplesync.R; +import com.example.android.samplesync.client.NetworkUtilities; + import android.content.ContentProviderOperation; import android.content.ContentValues; import android.content.Context; import android.net.Uri; import android.provider.ContactsContract; -import android.provider.ContactsContract.Data; -import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; -import android.util.Log; - -import com.example.android.samplesync.Constants; -import com.example.android.samplesync.R; /** * Helper class for storing data in the platform content providers. */ public class ContactOperations { - private final ContentValues mValues; - - private ContentProviderOperation.Builder mBuilder; - private final BatchOperation mBatchOperation; - private final Context mContext; - - private boolean mYield; - + private boolean mIsSyncOperation; private long mRawContactId; - private int mBackReference; - private boolean mIsNewContact; /** + * Since we're sending a lot of contact provider operations in a single + * batched operation, we want to make sure that we "yield" periodically + * so that the Contact Provider can write changes to the DB, and can + * open a new transaction. This prevents ANR (application not responding) + * errors. The recommended time to specify that a yield is permitted is + * with the first operation on a particular contact. So if we're updating + * multiple fields for a single contact, we make sure that we call + * withYieldAllowed(true) on the first field that we update. We use + * mIsYieldAllowed to keep track of what value we should pass to + * withYieldAllowed(). + */ + private boolean mIsYieldAllowed; + + /** * Returns an instance of ContactOperations instance for adding new contact * to the platform contacts provider. - * + * * @param context the Authenticator Activity context * @param userId the userId of the sample SyncAdapter user object - * @param accountName the username of the current login + * @param accountName the username for the SyncAdapter account + * @param isSyncOperation are we executing this as part of a sync operation? * @return instance of ContactOperations */ - public static ContactOperations createNewContact(Context context, int userId, - String accountName, BatchOperation batchOperation) { - - return new ContactOperations(context, userId, accountName, batchOperation); + public static ContactOperations createNewContact(Context context, long userId, + String accountName, boolean isSyncOperation, BatchOperation batchOperation) { + return new ContactOperations(context, userId, accountName, isSyncOperation, batchOperation); } /** * Returns an instance of ContactOperations for updating existing contact in * the platform contacts provider. - * + * * @param context the Authenticator Activity context * @param rawContactId the unique Id of the existing rawContact + * @param isSyncOperation are we executing this as part of a sync operation? * @return instance of ContactOperations */ public static ContactOperations updateExistingContact(Context context, long rawContactId, - BatchOperation batchOperation) { - - return new ContactOperations(context, rawContactId, batchOperation); + boolean isSyncOperation, BatchOperation batchOperation) { + return new ContactOperations(context, rawContactId, isSyncOperation, batchOperation); } - public ContactOperations(Context context, BatchOperation batchOperation) { + public ContactOperations(Context context, boolean isSyncOperation, + BatchOperation batchOperation) { mValues = new ContentValues(); - mYield = true; + mIsYieldAllowed = true; + mIsSyncOperation = isSyncOperation; mContext = context; mBatchOperation = batchOperation; } - public ContactOperations(Context context, int userId, String accountName, - BatchOperation batchOperation) { - - this(context, batchOperation); + public ContactOperations(Context context, long userId, String accountName, + boolean isSyncOperation, BatchOperation batchOperation) { + this(context, isSyncOperation, batchOperation); mBackReference = mBatchOperation.size(); mIsNewContact = true; mValues.put(RawContacts.SOURCE_ID, userId); mValues.put(RawContacts.ACCOUNT_TYPE, Constants.ACCOUNT_TYPE); mValues.put(RawContacts.ACCOUNT_NAME, accountName); - mBuilder = newInsertCpo(RawContacts.CONTENT_URI, true).withValues(mValues); - mBatchOperation.add(mBuilder.build()); + ContentProviderOperation.Builder builder = + newInsertCpo(RawContacts.CONTENT_URI, mIsSyncOperation, true).withValues(mValues); + mBatchOperation.add(builder.build()); } - public ContactOperations(Context context, long rawContactId, BatchOperation batchOperation) { - this(context, batchOperation); + public ContactOperations(Context context, long rawContactId, boolean isSyncOperation, + BatchOperation batchOperation) { + this(context, isSyncOperation, batchOperation); mIsNewContact = false; mRawContactId = rawContactId; } /** - * Adds a contact name - * - * @param name Name of contact - * @param nameType type of name: family name, given name, etc. + * Adds a contact name. We can take either a full name ("Bob Smith") or separated + * first-name and last-name ("Bob" and "Smith"). + * + * @param fullName The full name of the contact - typically from an edit form + * Can be null if firstName/lastName are specified. + * @param firstName The first name of the contact - can be null if fullName + * is specified. + * @param lastName The last name of the contact - can be null if fullName + * is specified. * @return instance of ContactOperations */ - public ContactOperations addName(String firstName, String lastName) { - + public ContactOperations addName(String fullName, String firstName, String lastName) { mValues.clear(); - if (!TextUtils.isEmpty(firstName)) { - mValues.put(StructuredName.GIVEN_NAME, firstName); - mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); - } - if (!TextUtils.isEmpty(lastName)) { - mValues.put(StructuredName.FAMILY_NAME, lastName); + + if (!TextUtils.isEmpty(fullName)) { + mValues.put(StructuredName.DISPLAY_NAME, fullName); mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } else { + if (!TextUtils.isEmpty(firstName)) { + mValues.put(StructuredName.GIVEN_NAME, firstName); + mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } + if (!TextUtils.isEmpty(lastName)) { + mValues.put(StructuredName.FAMILY_NAME, lastName); + mValues.put(StructuredName.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } } if (mValues.size() > 0) { addInsertOp(); @@ -133,8 +152,8 @@ public class ContactOperations { /** * Adds an email - * - * @param new email for user + * + * @param the email address we're adding * @return instance of ContactOperations */ public ContactOperations addEmail(String email) { @@ -150,7 +169,7 @@ public class ContactOperations { /** * Adds a phone number - * + * * @param phone new phone number for the contact * @param phoneType the type: cell, home, etc. * @return instance of ContactOperations @@ -166,9 +185,22 @@ public class ContactOperations { return this; } + public ContactOperations addAvatar(String avatarUrl) { + if (avatarUrl != null) { + byte[] avatarBuffer = NetworkUtilities.downloadAvatar(avatarUrl); + if (avatarBuffer != null) { + mValues.clear(); + mValues.put(Photo.PHOTO, avatarBuffer); + mValues.put(Photo.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + addInsertOp(); + } + } + return this; + } + /** * Adds a profile action - * + * * @param userId the userId of the sample SyncAdapter user object * @return instance of ContactOperations */ @@ -187,8 +219,22 @@ public class ContactOperations { } /** + * Updates contact's serverId + * + * @param serverId the serverId for this contact + * @param uri Uri for the existing raw contact to be updated + * @return instance of ContactOperations + */ + public ContactOperations updateServerId(long serverId, Uri uri) { + mValues.clear(); + mValues.put(RawContacts.SOURCE_ID, serverId); + addUpdateOp(uri); + return this; + } + + /** * Updates contact's email - * + * * @param email email id of the sample SyncAdapter user * @param uri Uri for the existing raw contact to be updated * @return instance of ContactOperations @@ -203,25 +249,38 @@ public class ContactOperations { } /** - * Updates contact's name - * - * @param name Name of contact - * @param existingName Name of contact stored in provider - * @param nameType type of name: family name, given name, etc. + * Updates contact's name. The caller can either provide first-name + * and last-name fields or a full-name field. + * * @param uri Uri for the existing raw contact to be updated + * @param existingFirstName the first name stored in provider + * @param existingLastName the last name stored in provider + * @param existingFullName the full name stored in provider + * @param firstName the new first name to store + * @param lastName the new last name to store + * @param fullName the new full name to store * @return instance of ContactOperations */ - public ContactOperations updateName(Uri uri, String existingFirstName, String existingLastName, - String firstName, String lastName) { + public ContactOperations updateName(Uri uri, + String existingFirstName, + String existingLastName, + String existingFullName, + String firstName, + String lastName, + String fullName) { - Log.i("ContactOperations", "ef=" + existingFirstName + "el=" + existingLastName + "f=" - + firstName + "l=" + lastName); mValues.clear(); - if (!TextUtils.equals(existingFirstName, firstName)) { - mValues.put(StructuredName.GIVEN_NAME, firstName); - } - if (!TextUtils.equals(existingLastName, lastName)) { - mValues.put(StructuredName.FAMILY_NAME, lastName); + if (TextUtils.isEmpty(fullName)) { + if (!TextUtils.equals(existingFirstName, firstName)) { + mValues.put(StructuredName.GIVEN_NAME, firstName); + } + if (!TextUtils.equals(existingLastName, lastName)) { + mValues.put(StructuredName.FAMILY_NAME, lastName); + } + } else { + if (!TextUtils.equals(existingFullName, fullName)) { + mValues.put(StructuredName.DISPLAY_NAME, fullName); + } } if (mValues.size() > 0) { addUpdateOp(uri); @@ -229,9 +288,17 @@ public class ContactOperations { return this; } + public ContactOperations updateDirtyFlag(boolean isDirty, Uri uri) { + int isDirtyValue = isDirty ? 1 : 0; + mValues.clear(); + mValues.put(RawContacts.DIRTY, isDirtyValue); + addUpdateOp(uri); + return this; + } + /** * Updates contact's phone - * + * * @param existingNumber phone number stored in contacts provider * @param phone new phone number for the contact * @param uri Uri for the existing raw contact to be updated @@ -246,9 +313,22 @@ public class ContactOperations { return this; } + public ContactOperations updateAvatar(String avatarUrl, Uri uri) { + if (avatarUrl != null) { + byte[] avatarBuffer = NetworkUtilities.downloadAvatar(avatarUrl); + if (avatarBuffer != null) { + mValues.clear(); + mValues.put(Photo.PHOTO, avatarBuffer); + mValues.put(Photo.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + addUpdateOp(uri); + } + } + return this; + } + /** * Updates contact's profile action - * + * * @param userId sample SyncAdapter user id * @param uri Uri for the existing raw contact to be updated * @return instance of ContactOperations @@ -268,41 +348,62 @@ public class ContactOperations { if (!mIsNewContact) { mValues.put(Phone.RAW_CONTACT_ID, mRawContactId); } - mBuilder = newInsertCpo(addCallerIsSyncAdapterParameter(Data.CONTENT_URI), mYield); - mBuilder.withValues(mValues); + ContentProviderOperation.Builder builder = + newInsertCpo(Data.CONTENT_URI, mIsSyncOperation, mIsYieldAllowed); + builder.withValues(mValues); if (mIsNewContact) { - mBuilder.withValueBackReference(Data.RAW_CONTACT_ID, mBackReference); + builder.withValueBackReference(Data.RAW_CONTACT_ID, mBackReference); } - mYield = false; - mBatchOperation.add(mBuilder.build()); + mIsYieldAllowed = false; + mBatchOperation.add(builder.build()); } /** * Adds an update operation into the batch */ private void addUpdateOp(Uri uri) { - mBuilder = newUpdateCpo(uri, mYield).withValues(mValues); - mYield = false; - mBatchOperation.add(mBuilder.build()); + ContentProviderOperation.Builder builder = + newUpdateCpo(uri, mIsSyncOperation, mIsYieldAllowed).withValues(mValues); + mIsYieldAllowed = false; + mBatchOperation.add(builder.build()); } - public static ContentProviderOperation.Builder newInsertCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newInsert(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newInsertCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newInsert(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - public static ContentProviderOperation.Builder newUpdateCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newUpdate(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newUpdateCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newUpdate(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - public static ContentProviderOperation.Builder newDeleteCpo(Uri uri, boolean yield) { - return ContentProviderOperation.newDelete(addCallerIsSyncAdapterParameter(uri)) - .withYieldAllowed(yield); + public static ContentProviderOperation.Builder newDeleteCpo(Uri uri, + boolean isSyncOperation, boolean isYieldAllowed) { + return ContentProviderOperation + .newDelete(addCallerIsSyncAdapterParameter(uri, isSyncOperation)) + .withYieldAllowed(isYieldAllowed); } - private static Uri addCallerIsSyncAdapterParameter(Uri uri) { - return uri.buildUpon().appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") - .build(); + private static Uri addCallerIsSyncAdapterParameter(Uri uri, boolean isSyncOperation) { + if (isSyncOperation) { + // If we're in the middle of a real sync-adapter operation, then go ahead + // and tell the Contacts provider that we're the sync adapter. That + // gives us some special permissions - like the ability to really + // delete a contact, and the ability to clear the dirty flag. + // + // If we're not in the middle of a sync operation (for example, we just + // locally created/edited a new contact), then we don't want to use + // the special permissions, and the system will automagically mark + // the contact as 'dirty' for us! + return uri.buildUpon() + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build(); + } + return uri; } } diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java index 7b60d5bd7..e8a99a448 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/platform/SampleSyncAdapterColumns.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the diff --git a/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java b/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java index 206189ad5..0ca8dee5b 100644 --- a/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java +++ b/samples/SampleSyncAdapter/src/com/example/android/samplesync/syncadapter/SyncAdapter.java @@ -1,12 +1,12 @@ /* * Copyright (C) 2010 The Android Open Source Project - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the @@ -24,12 +24,12 @@ import android.content.ContentProviderClient; import android.content.Context; import android.content.SyncResult; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import com.example.android.samplesync.Constants; import com.example.android.samplesync.client.NetworkUtilities; -import com.example.android.samplesync.client.User; -import com.example.android.samplesync.client.User.Status; +import com.example.android.samplesync.client.RawContact; import com.example.android.samplesync.platform.ContactManager; import org.apache.http.ParseException; @@ -37,23 +37,27 @@ import org.apache.http.auth.AuthenticationException; import org.json.JSONException; import java.io.IOException; +import java.util.ArrayList; import java.util.Date; import java.util.List; /** * SyncAdapter implementation for syncing sample SyncAdapter contacts to the - * platform ContactOperations provider. + * platform ContactOperations provider. This sample shows a basic 2-way + * sync between the client and a sample server. It also contains an + * example of how to update the contacts' status messages, which + * would be useful for a messaging or social networking client. */ public class SyncAdapter extends AbstractThreadedSyncAdapter { private static final String TAG = "SyncAdapter"; + private static final String SYNC_MARKER_KEY = "com.example.android.samplesync.marker"; + private static final boolean NOTIFY_AUTH_FAILURE = true; private final AccountManager mAccountManager; private final Context mContext; - private Date mLastUpdated; - public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); mContext = context; @@ -64,42 +68,101 @@ public class SyncAdapter extends AbstractThreadedSyncAdapter { public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { - List<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)); } } |
