从Android 2.0(API level 5)开始,可以定制同步provider,把系统通讯录、日历等整合起来。遗憾的是,通过远程服务执行同步是不可靠的,因为任意一点的失误都可能导致Android系统崩溃和重启(很少能够看出哪个地方做错了)。理想情况下,随着Android的发展,同步将变得更加简单,不那么复杂。现在,同步这个过程包含两个部分——认证(账户认证)和同步(Sync provider)。
在深入细节之前,要指出的是,这里提供的例子有两个组成部分:服务器端和Android客户端。服务器端是一个基本的Web服务,它接收特定的GET请求,返回JSON格式的响应。在每个小节中都提供了相关的GET URI及响应示例。本书的源代码中包含了完整的服务器端源代码。
要注意的另外一点是,在这个示例中,选择的是同步账户信息。这不是唯一可以执行同步的数据,可以同步任何可以访问的内容提供者,甚至是应用特定的存储数据。
认证
为了使客户端能够通过Android账户认证系统和远程服务端进行认证,必须提供3种信息:
·android.accounts.AccountAuthenticator intent所触发的服务,其在onBind方法中返回AbstractAccountAuthenticator子类。
·提示用户输入其校验信息的活动。
·描述账户数据如何显示的XML文件。
我们先来探讨服务。在manifest文件中,需要启用android.permission.AUTHENTICATE_ACCOUNTS。
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
然后,在manifest文件中描述服务。注意,在intent-filter描述符中包含了android.accounts.AccountAuthenticator intent。该manifest文件还描述了AccountAuthenticator的资源:
<service android:name=".sync.authsync.AuthenticationService"> <intent-filter> <action android:name="android.accounts.AccountAuthenticator" /> </intent-filter> <meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator" /></service>
在manifest文件中标示的资源见下文。其中包含的accountType,可以区分不同的Authenticator。修改该XML文件时要十分小心(例如不要直接把字符串赋值给android:label或包含不存在的drawable),因为如果内容不正确,当你添加一个新的账户时,Android会崩溃(在Account&Sync设置中):
<?xml version="1.0" encoding="utf-8"?><account-authenticator xmlns:android="http://schemas.android.com/apk/res/android" android:accountType="com.oreilly.demo.pa.ch17.sync" android:icon="@drawable/icon" android:smallIcon="@drawable/icon" android:label="@string/authlabel"/>
因为在manifest文件中已经描述了服务,所以现在转而考虑service本身。注意,onBind方法返回的是Authenticator类。该Authenticator类继承了AbstractAccount-Authenticator类:
package com.oreilly.demo.android.pa.clientserver.client.sync.authsync;import android.app.Service;import android.content.Intent;import android.os.IBinder;public class AuthenticationService extends Service { private static final Object lock = new Object; private Authenticator auth; @Override public void onCreate { synchronized (lock) { if (auth == null) { auth = new Authenticator(this); } } } @Override public IBinder onBind(Intent intent) { return auth.getIBinder; }}
在探讨Authenticator类的全部源代码之前,先来看看在AbstractAccountAuthenticator中包含的一个重要方法——addAccount。当用户从Add Account屏幕中选择自定义账户时,最终会调用这个方法。LoginActivity(我们自定义的Activity,在用户登录时会弹出对话框)是在Intent内描述的,Intent在返回的Bundle中。在intent中包含的AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE键是至关重要的,因为它包含AccountAuthenticatorResponse对象,一旦用户在远程服务上通过认证,会通过AccountAuthenticatorResponse对象返回账户密钥:
public class Authenticator extends AbstractAccountAuthenticator { public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String requiredFeatures, Bundle options) { Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); Bundle bundle = new Bundle; bundle.putParcelable(AccountManager.KEY_INTENT, intent); return bundle; }}
以下是完整的Authenticator activity,它继承了AbstractAccountAuthenticator:
package com.oreilly.demo.android.pa.clientserver.client.sync.authsync;import com.oreilly.demo.android.pa.clientserver.client.sync.LoginActivity;import android.accounts.AbstractAccountAuthenticator;import android.accounts.Account;import android.accounts.AccountAuthenticatorResponse;import android.accounts.AccountManager;import android.content.Context;import android.content.Intent;import android.os.Bundle;public class Authenticator extends AbstractAccountAuthenticator { public static final String AUTHTOKEN_TYPE = "com.oreilly.demo.android.pa.clientserver.client.sync"; public static final String AUTHTOKEN_TYPE = "com.oreilly.demo.android.pa.clientserver.client.sync"; private final Context context; public Authenticator(Context context) { super(context); this.context = context; } @Override public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String requiredFeatures, Bundle options) { Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); Bundle bundle = new Bundle; bundle.putParcelable(AccountManager.KEY_INTENT, intent); return bundle;}@Overridepublic Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) { return null;}@Overridepublic Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { return null;}@Overridepublic Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle loginOptions) { return null;}@Overridepublic String getAuthTokenLabel(String authTokenType) { return null;}@Overridepublic Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String features) { return null;} @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle loginOptions) { return null; }}
在这个示例中,访问远程服务器需要调用登录API(通过HTTP URI访问),其参数包括username和password。如果登录成功,会返回包含token的JSON字符串:
uri: http://<serverBaseUrl>:<port>/login?username=<name>&password=<pass>response: { "token" : "someAuthenticationToken" }
LoginActivity请求用户为该账户输入用户名和密码,然后和远程服务器通信。一旦返回了期望的JSON字符串,会调用handleLoginResponse方法,并把账户的相关信息传回AccountManager:
package com.oreilly.demo.android.pa.clientserver.sync;import org.json.JSONObject;import com.oreilly.demo.android.pa.clientserver.client.R;import com.oreilly.demo.android.pa.clientserver.client.sync.authsync.Authenticator;import android.accounts.Account;import android.accounts.AccountAuthenticatorActivity;import android.accounts.AccountManager;import android.app.Dialog;import android.app.ProgressDialog;import android.content.ContentResolver;import android.content.Intent;import android.os.Bundle;import android.os.Handler;import android.os.Message;import android.provider.ContactsContract;import android.view.View;import android.view.View.OnClickListener;import android.widget.EditText;import android.widget.Toast;public class LoginActivity extends AccountAuthenticatorActivity { public static final String PARAM_AUTHTOKEN_TYPE = "authtokenType"; public static final String PARAM_USERNAME = "username"; public static final String PARAM_PASSWORD = "password"; private String username; private String password; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getVars; setupView; } @Override protected Dialog onCreateDialog(int id) { final ProgressDialog dialog = new ProgressDialog(this); dialog.setMessage("Attemping to login"); dialog.setIndeterminate(true); dialog.setCancelable(false); return dialog; } private void getVars { username = getIntent.getStringExtra(PARAM_USERNAME); } private void setupView { setContentView(R.layout.login); findViewById(R.id.login).setOnClickListener(new OnClickListener { @Override public void onClick(View v) { login; } }); if(username != null) { ((EditText) findViewById(R.id.username)).setText(username); } } private void login { if(((EditText) findViewById(R.id.username)).getText == null || ((EditText) findViewById(R.id.username)).getText.toString. trim.length < 1) { Toast.makeText(this, "Please enter a Username", Toast.LENGTH_SHORT).show; return; } if(((EditText) findViewById(R.id.password)).getText == null || ((EditText) findViewById(R.id.password)).getText.toString. trim.length < 1) { Toast.makeText(this, "Please enter a Password", Toast.LENGTH_SHORT).show; return; } username = ((EditText) findViewById(R.id.username)).getText.toString; password = ((EditText) findViewById(R.id.password)).getText.toString; showDialog(0); Handler loginHandler = new Handler { @Override public void handleMessage(Message msg) { if(msg.what == NetworkUtil.ERR) { dismissDialog(0); Toast.makeText(LoginActivity.this, "Login Failed: "+ msg.obj, Toast.LENGTH_SHORT).show; } else if(msg.what == NetworkUtil.OK) { handleLoginResponse((JSONObject) msg.obj); } } }; NetworkUtil.login(getString(R.string.baseurl), username, password, loginHandler); } private void handleLoginResponse(JSONObject resp) { dismissDialog(0); final Account account = new Account(username, Authenticator.ACCOUNT_TYPE); if (getIntent.getStringExtra(PARAM_USERNAME) == null) { AccountManager.get(this).addAccountExplicitly(account, password, null); ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true); } else { AccountManager.get(this).setPassword(account, password); } Intent intent = new Intent; intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, username); intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE); if (resp.has("token")) { intent.putExtra(AccountManager.KEY_AUTHTOKEN, resp.optString("token")); } setAccountAuthenticatorResult(intent.getExtras); setResult(RESULT_OK, intent); finish; }}
LoginActivity的layout XML文件如下:
<?xml version="1.0" encoding="utf-8" ?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_ android:layout_ android:background="#fff"> <ScrollView android:layout_ android:layout_ android:layout_weight="1"> <LinearLayout android:layout_ android:layout_ android:layout_weight="1" android:orientation="vertical" android:paddingTop="5dip" android:paddingBottom="13dip" android:paddingLeft="20dip" android:paddingRight="20dip"> <EditText android:id="@+id/username" android:singleLine="true" android:layout_ android:layout_ android:minWidth="250dip" android:scrollHorizontally="true" android:capitalize="none" android:hint="Username" android:autoText="false" /> <EditText android:id="@+id/password" android:singleLine="true" android:layout_ android:layout_ android:minWidth="250dip" android:scrollHorizontally="true" android:capitalize="none" android:autoText="false" android:password="true" android:hint="Password" android:inputType="textPassword" /> </LinearLayout> </ScrollView> <FrameLayout android:layout_ android:layout_ android:background="#fff" android:minHeight="54dip" android:paddingTop="4dip" android:paddingLeft="2dip" android:paddingRight="2dip"> <Button android:id="@+id/login" android:layout_ android:layout_ android:layout_gravity="center_horizontal" android:minWidth="100dip" android:text="Login" /> </FrameLayout></LinearLayout>
账户建立好了,接下来可以同步数据了。
同步
为了同步账户数据,还需要处理3个模块:一是注册的service,它监听android.content.SyncAdapter intent,并在onBind方法上返回继承AbstractThreadedSyncAdapter的类;二是XML描述符,它描述要查看和同步的数据结构;三是继承AbstractThreaded-SyncAdapter的类,它处理实际的同步操作。
在我们这个例子中,希望同步之前章节中所描述的账户的联系信息。注意,通讯录信息不是唯一可以执行同步的信息。可以和能够访问的任何内容提供者执行同步,甚至是应用特定的存储数据。
以下许可是在manifest文件中给出的:
<uses-permission android:name="android.permission.GET_ACCOUNTS" /><uses-permission android:name="android.permission.READ_CONTACTS" /><uses-permission android:name="android.permission.WRITE_CONTACTS" /><uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /><uses-permission android:name="android.permission.USE_CREDENTIALS" /><uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /><uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.WRITE_SETTINGS" /><uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" /><uses-permission android:name="android.permission.READ_SYNC_STATS" /><uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /><uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
现在,描述要使用的服务。注意,它包含了android.content.SyncAdapter intent,并且描述了通讯录数据和SyncAdapter的结构:
<service android:name=".sync.authsync.SyncService"> <intent-filter> <action android:name="android.content.SyncAdapter" /> </intent-filter> <meta-data android:name="android.content.SyncAdapter" android:resource="@xml/syncadapter" /> <meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts" /></service>
在sync-adapter XML资源中,要注意accountType描述符。我们希望使用的Android通讯录数据如下:
<?xml version="1.0" encoding="utf-8"?><sync-adapter xmlns:android="http://schemas.android.com/apk/res/android" android:contentAuthority="com.android.contacts" android:accountType="com.oreilly.demo.android.pa.clientserver.client.sync"/>
以下是通讯录描述符XML。注意各个字段的名称:
<?xml version="1.0" encoding="utf-8"?><ContactsSource xmlns:android="http://schemas.android.com/apk/res/android"> <ContactsDataKind android:mimeType="vnd.android.cursor.item/vnd.com.oreilly.demo.android.pa.clientserver.sync.profile" android:icon="@drawable/icon" android:summaryColumn="data2" android:detailColumn="data3" android:detailSocialSummary="true" /></ContactsSource>
所创建的SyncService会返回SyncAdapter类。该自定义类继承AbstractThreadedSync-Adapter:
package com.oreilly.demo.android.pa.clientserver.client.sync.authsync;import android.app.Service;import android.content.Intent;import android.os.IBinder;public class SyncService extends Service { private static final Object lock = new Object; private static SyncAdapter adapter = null; @Override public void onCreate { synchronized (lock) { if (adapter == null) { adapter = new SyncAdapter(getApplicationContext, true); } } } @Override public void onDestroy { adapter = null; } @Override public IBinder onBind(Intent intent) { return adapter.getSyncAdapterBinder; } }
继续该示例,我们在远程服务端创建了getfriends方法。它会接收上一节成功登录所传递回来的token,以及表示最近一次调用是第几次调用(如果是第一次调用,会传递0值)的时间。响应是另一个JSON字符串,它描述了朋友(ID、name和phone)、调用时间(服务器端的UNIX时间),以及该账户增删朋友的历史记录。在历史记录中,type字段值0表示增加,1表示删除。字段who是朋友ID,time是操作的时间:
uri: http://<serverBaseUrl>:<port>/getfriends?token=<token>&time=<lasttime>response:{ "time" : 1295817666232, "history" : [ { "time" : 1295817655342, "type" : 0, "who" : 1 } ], "friend" : [ { "id" : 1, "name" : "Mary", "phone" : "8285552334" } ]}
AbstractThreadedSyncAdapter类继承SyncAdapter类,如下:
public class SyncAdapter extends AbstractThreadedSyncAdapter { private final Context context; private static long lastsynctime = 0; public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); this.context = context; } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { String authtoken = null; try { authtoken = AccountManager.get(context).blockingGetAuthToken(account, Authenticator.AUTHTOKEN_TYPE, true); ListFriends friendsdata = ListFriends.fromJSON( NetworkUtil.getFriends(context.getString(R.string.baseurl), authtoken, lastsynctime, null)); lastsynctime = friendsdata.time; sync(account, friendsdata); } catch (Exception e) { e.printStackTrace; } } private void sync(Account account, ListFriends data) { // MAGIC HAPPENS }}
SyncAdapter类的完整代码如下,包括当sync方法接收数据时发生的各种操作。它包含通讯录信息的各种增删操作。在前面的章节中涵盖了Contact和ContentProvider操作。
package com.oreilly.demo.android.pa.clientserver.client.sync.authsync;import java.util.ArrayList;import android.accounts.Account;import android.accounts.AccountManager;import android.content.AbstractThreadedSyncAdapter;import android.content.ContentProviderClient;import android.content.ContentProviderOperation;import android.content.ContentUris;import android.content.Context;import android.content.SyncResult;import android.database.Cursor;import android.os.Bundle;import android.provider.ContactsContract;import android.provider.ContactsContract.RawContacts;import com.oreilly.demo.android.pa.clientserver.client.R;import com.oreilly.demo.android.pa.clientserver.client.sync.NetworkUtil;import com.oreilly.demo.android.pa.clientserver.client.sync.dataobjects.Change;import com.oreilly.demo.android.pa.clientserver.client.sync.dataobjects.ListFriends;import com.oreilly.demo.android.pa.clientserver.client.sync.dataobjects.User;public class SyncAdapter extends AbstractThreadedSyncAdapter { private final Context context; private static long lastsynctime = 0; public SyncAdapter(Context context, boolean autoInitialize) { super(context, autoInitialize); this.context = context; } @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { String authtoken = null; try { // get accounttoken. this eventually calls our Authenticator // getAuthToken authtoken = AccountManager.get(context).blockingGetAuthToken(account, Authenticator.AUTHTOKEN_TYPE, true); ListFriends friendsdata = ListFriends.fromJSON( NetworkUtil.getFriends(context.getString(R.string.baseurl), authtoken, lastsynctime, null)); lastsynctime = friendsdata.time; sync(account, friendsdata); } catch (Exception e) { e.printStackTrace; } } // where the magic happens private void sync(Account account, ListFriends data) { User self = new User; self.username = account.name; ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>; // cycle through the history to find the deletes if(data.history != null && !data.history.isEmpty) { for(Change change : data.history) { if(change.type == Change.ChangeType.DELETE) { ContentProviderOperation op = delete(account, change.who); if(op != null) ops.add(op); } } } // cycle through the friends to find ones we do not already have and add them if(data.friends != null && !data.friends.isEmpty) { for(User f : data.friends) { ArrayList<ContentProviderOperation> op = add(account, f); if(op != null) ops.addAll(op); } } if(!ops.isEmpty) { try { context.getContentResolver.applyBatch(ContactsContract.AUTHORITY, ops); } catch (Exception e) { e.printStackTrace; } } } // adding a contact. note we are storing the id referenced in the response // from the server in the SYNC1 field - this way we can find it with this // server based id private ArrayList<ContentProviderOperation> add(Account account, User f) { long rawid = lookupRawContact(f.id); if(rawid != 0) return null; ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>; ops.add(ContentProviderOperation.newInsert( ContactsContract.RawContacts.CONTENT_URI) .withValue(RawContacts.SOURCE_ID, 0) .withValue(RawContacts.SYNC1, f.id) .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE) .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) .build); if(f.name != null && f.name.trim.length > 0) { ops.add(ContentProviderOperation.newInsert( ContactsContract.Data.CONTENT_URI) .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) .withValue(ContactsContract.CommonDataKinds. StructuredName.DISPLAY_NAME, f.name) .build); } if(f.phone != null && f.phone.trim.length > 0) { ops.add(ContentProviderOperation.newInsert (ContactsContract.Data.CONTENT_URI) .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, f.phone) .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_HOME) .build); } ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) .withValue(ContactsContract.Data.MIMETYPE, "vnd.android.cursor.item/vnd.com.oreilly.demo.android.pa.clientserver.client.sync.profile") .withValue(ContactsContract.Data.DATA2, "Ch15 Profile") .withValue(ContactsContract.Data.DATA3, "View profile") .build ); return ops; } // delete contact via the server based id private ContentProviderOperation delete(Account account, long id) { long rawid = lookupRawContact(id); if(rawid == 0) return null; return ContentProviderOperation.newDelete( ContentUris.withAppendedId( ContactsContract.RawContacts.CONTENT_URI, rawid)) .build; } // look up the actual raw id via the id we have stored in the SYNC1 field private long lookupRawContact(long id) { long rawid = 0; Cursor c = context.getContentResolver.query( RawContacts.CONTENT_URI, new String {RawContacts._ID}, RawContacts.ACCOUNT_TYPE + "='" + Authenticator.ACCOUNT_TYPE + "' AND "+ RawContacts.SYNC1 + "=?", new String {String.valueOf(id)}, null); try { if(c.moveToFirst) { rawid = c.getLong(0); } } finally { if (c != null) { c.close; c = null; } } return rawid; }}
在前面的SyncAdapter类中可能缺失了一个重要的详细信息:在执行onPerformSync调用时,我们希望通过blockingGetAuthToken方法从AccountManager中获取authtoken。它最终会调用和该账户关联的AbstractAccountAuthenticator类。在这个例子中,它调用的是我们在前一节中提到过的Authenticator类。在Authenticator类中,会调用getAuthToken方法,示例如下:
@Overridepublic Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle loginOptions) { // check and make sure it is the right token type we want if (!authTokenType.equals(AUTHTOKEN_TYPE)) { final Bundle result = new Bundle; result.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType"); return result; } // if we have the password, let's try and get the current // authtoken from the server String password = AccountManager.get(context).getPassword(account); if (password != null) { JSONObject json = NetworkUtil.login(context.getString(R.string.baseurl), account.name, password, true, null); if(json != null) { Bundle result = new Bundle; result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE); result.putString(AccountManager.KEY_AUTHTOKEN, json.optString("token")); return result; } } // if all else fails let's see about getting the user to log in Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(LoginActivity.PARAM_USERNAME, account.name); intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response); Bundle bundle = new Bundle; bundle.putParcelable(AccountManager.KEY_INTENT, intent); return bundle;}