如何从系统通讯录跳转到自己的 app

效果展示


方案概述

如何在联系人中添加自己 app 的按钮

  1. 首先你需要一个 Android 系统的账户,这个在系统设置中可以找到,你会发现你的手机已经有很多其他 app 的账户存在了,比如微信,支付宝,twitter 等。
  2. 然后在这个账户下添加你的联系人信息,这里你可以声明你的联系人结构,告诉系统它有一个按钮,对应的 action 是 xxx,然后你写一个可以接收 actionxxxActivity,就能够在点击按钮后跳转到这个 Activity 了。

简单来说就是上面这两个步骤,具体实现可以看下面的代码实现

如何将自己 app 的按钮添加到通讯录已存在的联系人中

在使用 telegram 时,我们会发现自己的一些通讯录联系人被添加上了 telegram 的按钮。效果如图:

实际上这并不是 telegram 将自己的按钮塞到了通讯录中,而是 Android 系统判断,telegram 添加进来的这个人,手机号以及姓名,都和通讯录中已有的一个人相同,因此自动将两个联系人合并显示了。 本质上还是两个联系人,你也可以选择让这两个联系人分开独立显示,只需要在联系人详情页取消他们的关联即可。另外,如果你卸载了 telegram,你会发现那个按钮也会消失,因为那个有按钮的联系人随着 telegram 的卸载被删除了。

代码实现

嫌麻烦的同学可以直接看源码:
https://github.com/yueban/CustomContact

1. 添加系统级账户

添加账户认证服务 AuthenticatorService,继承于 Service

1
2
3
4
5
6
7
8
9
10
11
12
<service
android:name=".account.AuthenticatorService"
android:exported="true">
<!-- 接收账户认证 action -->
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<!-- 账户信息 -->
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/auth" />
</service>

声明你要创建的账户信息 xml/auth,注意这里的 android:accountType,之后创建账户,删除账户,以及在账户下添加联系人,都需要与这里的 accountType 一致

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:smallIcon="@mipmap/ic_launcher" />

AuthenticatorServiceonBind 方法中,返回一个账户认证对象 AuthenticatorAuthenticator 继承于 AbstractAccountAuthenticator

1
2
3
4
5
6
7
override fun onBind(intent: Intent): IBinder? {
return if (AccountManager.ACTION_AUTHENTICATOR_INTENT == intent.action) {
Authenticator(this).iBinder
} else {
null
}
}

添加必要的权限(不需要在 app 中动态申请)

1
2
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />

然后就可以创建你自己 app 的系统级账户了。注意,这里的 account_type 必须和 xml/auth 中声明的一致。

1
2
val account = Account("account_name", "account_type")
AccountManager.get(context).addAccountExplicitly(account, "", null)

进入系统设置 → 账户,应该可以看到一个名为 account_name 的账户,这就是我们刚刚创建的。

这部分源码对应的 commit 为:
https://github.com/yueban/CustomContact/commit/4ce5e365cd4670a0517e162e79234014808ce370

2. 向这个系统级账户中添加联系人信息

添加联系人同步服务 ContactsSyncAdapterService,继承于 Service

1
2
3
4
5
6
7
8
9
10
11
12
13
<service
android:name=".contact.ContactsSyncAdapterService"
android:exported="true">
<!-- 接收同步 action -->
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>

<!-- 声明这个同步服务的类型 -->
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts" />
</service>

声明同步服务的类型 xml/sync_contacts,我们使用联系人同步,因此是 com.android.contacts注意,这里的 account_type 必须和 xml/auth 中声明的一致。

1
2
3
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type"
android:contentAuthority="com.android.contacts" />

ContactsSyncAdapterServiceonBind 方法中,返回一个联系人同步对象 SyncAdapterImplSyncAdapterImpl 继承于 AbstractThreadedSyncAdapter

1
2
3
override fun onBind(intent: Intent?): IBinder {
return SyncAdapterImpl(this).syncAdapterBinder
}

声明必要的权限,其中 联系人读写权限需要在 app 中动态申请

1
2
3
4
5
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<!-- 联系人读写权限需要动态申请 -->
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />

然后,我们就可以向第 1 步创建的账户中,插入联系人信息了。注意,这里的 account_name, account_type 必须与第 1 步 addAccountExplicitly 时使用的一致。 (也就是 account_type 必须与 xml/auth 中声明的一致)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
val ops =
ArrayList<ContentProviderOperation>()
// specify account_type, account_name
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(
ContactsContract.RawContacts.CONTENT_URI
)
).withValue(
ContactsContract.RawContacts.ACCOUNT_NAME,
"account_name"
).withValue(
ContactsContract.RawContacts.ACCOUNT_TYPE,
"account_type"
).build()
)
// make contact visible even if it not in any group
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(
ContactsContract.Settings.CONTENT_URI
)
).withValue(
ContactsContract.RawContacts.ACCOUNT_NAME,
"account_name"
).withValue(
ContactsContract.RawContacts.ACCOUNT_TYPE,
"account_type"
).withValue(ContactsContract.Settings.UNGROUPED_VISIBLE, 1).build()
)
// name
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(
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,
"Bob"
).build()
)
// phone
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(
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,
"010-1234"
).build()
)

context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)

打开系统通讯录 app,应该可以看到一个名为 Bob,电话为 010-1234 的联系人,进入这个联系人的详情页,滑到最底部,应该可以看到这个联系人来自我们自己创建的账户。

这部分源码对应的 commit 为:
https://github.com/yueban/CustomContact/commit/3177c9fd9aef9c2f424e7138f23776198230ba4d

3. 联系人中添加自己 app 的按钮

首先我们需要声明自己账户下联系人独有的数据类型。在联系人同步服务 ContactsSyncAdapterService 中声明自己账户下联系人的数据结构。

1
2
3
4
5
6
7
8
9
<service
android:name=".contact.ContactsSyncAdapterService"
android:exported="true">
...
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contact_structure" />
...
</service>

声明自己账户下联系人的数据结构 xml/contact_structure。这里我们声明了两个自己联系人独有的字段,其中 mimeType 为字段的唯一标识,而 detailColumn 则是这个字段在联系人详情页的显示的内容。(data2Android 系统中预定好的自定义字段名,往后看就会知道它是如何工作的)

这里的两个 mimeType 分别为:

  • vnd.android.cursor.item/vnd.com.yueban.customcontact.message
  • vnd.android.cursor.item/vnd.com.yueban.customcontact.call
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<ContactsSource xmlns:android="http://schemas.android.com/apk/res/android">
<ContactsDataKind
android:detailColumn="data2"
android:detailSocialSummary="true"
android:icon="@mipmap/ic_launcher"
android:mimeType="vnd.android.cursor.item/vnd.com.yueban.customcontact.message"
android:summaryColumn="data1" />
<ContactsDataKind
android:detailColumn="data2"
android:detailSocialSummary="true"
android:icon="@mipmap/ic_launcher"
android:mimeType="vnd.android.cursor.item/vnd.com.yueban.customcontact.call"
android:summaryColumn="data1" />
</ContactsSource>

然后,我们就可以在插入联系人时,设置我们刚刚添加的两个字段了。注意,这里的 ContactsContract.Data.MIMETYPE 必须和我们在 xml/contact_structure 中定义的 mimeType 相同。 而我们为 ContactsContract.Data.DATA2 设置的值,会被 detailColumn 引用到,最终会显示在联系人详情页中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
val ops = ArrayList<ContentProviderOperation>()
//...

// 第 1 个字段
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(
ContactsContract.Data.CONTENT_URI
)
).withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0).withValue(
ContactsContract.Data.MIMETYPE,
"vnd.android.cursor.item/vnd.com.yueban.customcontact.message"
).withValue(ContactsContract.Data.DATA1, phoneNumber).withValue(
ContactsContract.Data.DATA2,
"message: 010-1234"
).build()
)
// 第 2 个字段
ops.add(
ContentProviderOperation.newInsert(
addCallerIsSyncAdapterParameter(ContactsContract.Data.CONTENT_URI)
)
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
.withValue(
ContactsContract.Data.MIMETYPE,
"vnd.android.cursor.item/vnd.com.yueban.customcontact.call"
)
.withValue(ContactsContract.Data.DATA1, phoneNumber)
.withValue(
ContactsContract.Data.DATA2,
"call: 010-1234"
)
.build()
)

//...

我们再次打开 Bob 这个人的详情页,就会看到多了两个按钮,其中一个为 message: 010-1234,另一个为 message: 010-1234

最后,我们添加一个 DetailActivity 用于接收这两个按钮点击的跳转事件。通过声明字段对应的 mimeType,即可接收到按钮点击后跳转的事件。

1
2
3
4
5
6
7
8
9
10
11
<activity android:name=".DetailActivity">
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />

<!-- 声明接收的 mimeType -->
<data android:mimeType="vnd.android.cursor.item/vnd.com.yueban.customcontact.message" />
<data android:mimeType="vnd.android.cursor.item/vnd.com.yueban.customcontact.call" />
</intent-filter>
</activity>

DetailActivity 中处理 Intent,判断是从哪个按钮跳转过来的。

1
2
3
4
5
6
7
8
when (intent.type) {
CustomContactManager.CONTACT_MIME_TYPE_MESSAGE -> {
text_view.text = "message from contact"
}
CustomContactManager.CONTACT_MIME_TYPE_CALL -> {
text_view.text = "call from contact"
}
}

这里的 intent.type 即对应我们插入联系人时设置的 ``ContactsContract.Data.MIMETYPE,也就是xml/contact_structure中字段定义的mimeType`。

最后再运行一下 app,点击 Bobmessage: 010-1234 按钮,即可跳转到 DetailActivity

这部分源码对应的 commit 为:
https://github.com/yueban/CustomContact/commit/1db65e07670c1144b735b073ef66cd03668ea5e6

参考

  1. Create a custom account type
  2. Android account, sync adapter, and contacts contract database to link to your app
  3. SampleContacts
  4. Set a custom contact item in android address book
  5. Telegram
  6. How does Android contact linking work?