從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; } @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) { return null; } @Override public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { return null; } @Override public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle loginOptions) { return null; } @Override public String getAuthTokenLabel(String authTokenType) { return null; } @Override public 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方法,示例如下:
@Override public 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; }