【android bluetooth 协议分析 18】【PBAP详解 1】【为何我们的通话记录中会出现1-521-8xx-1x9x】
一、背景
今天上报了一例, 车机通讯录姓名栏显示电话号码时电话号码分割异常。
# 类似下面的显示
# x: 为省略掉的数字1-521-8xx-1x9x
1. btsnoop 日志
我将 btsnoop 中的 电话簿信息 导出了:
BEGIN:VCARD
VERSION:3.0
N:
FN:
TEL;TYPE=CELL:15218xx1x9x
END:VCARD
- 发现他的 N: 和 FN: 下都是空的,也就是这个电话,没有保存联系人姓名。
那这里就很奇怪, 名字是空的, 那为何 app 侧,能拿到 类似 1-521-8xx-1x9x
这种显示呢。
首先 app 侧肯定是没有做特殊处理的。
带着上面的疑问开始今天的探索。
二、如何拉取联系人数据
本节我们重点梳理一下,车机是如何 拉起手机联系人数据,并如何解析为对应的 VCARD 的。
1. 分批拉取手机端联系人
downloadContacts(String path)
方法是 PBAP (Phone Book Access Profile) 客户端侧的逻辑,主要负责 从对端设备(比如手机)分批拉取联系人 vCard。
方法整体作用:
-
输入:
path
→ 要下载的联系人路径(PBAP 定义了几个标准路径,比如"telecom/pb.vcf"
电话簿、"telecom/ich.vcf"
已接来电、"telecom/och.vcf"
已拨电话等)。 -
输出:返回布尔值,表示联系人是否成功下载。
-
流程:按批次请求 vCard → 解析成
VCardEntry
→ 放入处理队列 → 通知 UI / 上层。 -
android/app/src/com/android/bluetooth/pbapclient/PbapClientConnectionHandler.java
@VisibleForTestingprivate boolean downloadContacts(String path) {boolean result = false;try {// 先获取联系人总数int numberOfContactsRemaining = getSize(path);int startOffset = 1; // PBAP 的记录索引是从 1 开始的(不是从 0)。/*每次循环下载一批联系人批大小 = DEFAULT_BATCH_SIZE(常见值 50、100)。同时不能超过剩余数量和 PBAP 的 UPPER_LIMIT(规范要求最大 65535)。*/while ((numberOfContactsRemaining > 0) && (startOffset <= UPPER_LIMIT)) {int numberOfContactsToDownload =Math.min(Math.min(DEFAULT_BATCH_SIZE, numberOfContactsRemaining),UPPER_LIMIT - startOffset + 1);Log.d(TAG, "downloadContacts startOffset: " + startOffset + ", numberOfContactsToDownload: " + numberOfContactsToDownload);/*BluetoothPbapRequestPullPhoneBook:代表一个 PBAP 请求,参数包括:path → 目标电话簿路径。PBAP_REQUESTED_FIELDS → 要求返回的字段(姓名、号码等)。VCARD_TYPE_30 → 请求 vCard 3.0 格式。numberOfContactsToDownload → 这次要取多少条。startOffset → 从第几条开始。request.execute(mObexSession):通过 OBEX 会话真正发起请求。*/BluetoothPbapRequestPullPhoneBook request =new BluetoothPbapRequestPullPhoneBook(path, mAccount,PBAP_REQUESTED_FIELDS, VCARD_TYPE_30,numberOfContactsToDownload, startOffset);request.execute(mObexSession); // 本例 重点分析 该条路径------>/*处理返回的 vCardrequest.getList() → 拿到返回的 vCard 列表,每个元素是 VCardEntry(已经解析成联系人对象)。processor.setResults(vcards) → 把结果交给 PhonebookPullRequest 处理器。*/PhonebookPullRequest processor =new PhonebookPullRequest(mPbapClientStateMachine.getContext(),mAccount);ArrayList<VCardEntry> vcards = request.getList(); // 本例 重点分析 该条路径------>processor.setResults(vcards);// 偏移量递增,继续下一批。startOffset += numberOfContactsToDownload;numberOfContactsRemaining -= numberOfContactsToDownload;}// 如果联系人很多,超过 UPPER_LIMIT,就放弃剩下的。if ((startOffset > UPPER_LIMIT) && (numberOfContactsRemaining > 0)) {Log.w(TAG, "Download contacts incomplete, index exceeded upper limit.");}...} catch (IOException e) {...} finally {return result;}}
2. 如何触发下载
BluetoothPbapRequestPullPhoneBook request =new BluetoothPbapRequestPullPhoneBook(path, mAccount,PBAP_REQUESTED_FIELDS, VCARD_TYPE_30,numberOfContactsToDownload, startOffset);request.execute(mObexSession); // 本例 重点分析 该条路径------>
- android/app/src/com/android/bluetooth/pbapclient/BluetoothPbapRequestPullPhoneBook.java
final class BluetoothPbapRequestPullPhoneBook extends BluetoothPbapRequest {
BluetoothPbapRequestPullPhoneBook extends BluetoothPbapRequest
request.execute
: 调用的BluetoothPbapRequest
1. PBAP 客户端 GET 请求执行流程
- android/app/src/com/android/bluetooth/pbapclient/BluetoothPbapRequest.java
// BluetoothPbapRequestpublic void execute(ClientSession session) throws IOException {if (DBG) Log.v(TAG, "execute");// 1. 如果请求已经被标记为中止,就不再执行if (mAborted) {mResponseCode = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;return;}try {// 2. 通过 OBEX ClientSession 发起 GET 请求mOp = (ClientOperation) session.get(mHeaderSet);// 3. PBAP 规范要求 GET 必须带 Final bit (Spec 6.2.2)mOp.setGetFinalFlag(true);// 4. 触发实际操作,启用非缓冲流,这样可以随时中止mOp.continueOperation(true, false);// 5. 读取响应的 Header (OBEX 层返回的 header,比如 Body 长度、结束标志等)readResponseHeaders(mOp.getReceivedHeader());// 6. 打开 InputStream,用来读取返回的主体数据 (vCard 列表)InputStream is = mOp.openInputStream();// 7. 读到对侧数据后 由 BluetoothPbapVcardList 来解析 vCard 内容readResponse(is);// 本例 重点分析 该条路径------>is.close();// 8. 关闭 OBEX 操作对象mOp.close();// 9. 获取响应码,比如 OBEX_HTTP_OK (0x20)mResponseCode = mOp.getResponseCode();if (DBG) Log.d(TAG, "mResponseCode=" + mResponseCode);// 10. 检查返回码是否合法checkResponseCode(mResponseCode);} catch (IOException e) {Log.e(TAG, "IOException occured when processing request", e);mResponseCode = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;throw e;}}
2. 当拿到对端电话簿时该如何处理
当拿到 对侧数据后,会回调 BluetoothPbapRequestPullPhoneBook::readResponse
- android/app/src/com/android/bluetooth/pbapclient/BluetoothPbapRequestPullPhoneBook.java
protected void readResponse(InputStream stream) throws IOException {if (VDBG) Log.v(TAG, "readResponse");mResponse = new BluetoothPbapVcardList(mAccount, stream, mFormat); // 此时填充if (VDBG) {Log.d(TAG, "Read " + mResponse.getCount() + " entries.");}}
备注:TAG 1 BluetoothPbapVcardList.parse 调用处
- android/app/src/com/android/bluetooth/pbapclient/BluetoothPbapVcardList.java
BluetoothPbapVcardList(Account account, InputStream in, byte format) throws IOException {mAccount = account;parse(in, format); // 触发解析}private void parse(InputStream in, byte format) throws IOException {VCardParser parser;// 这里是 VCARD_TYPE_30if (format == PbapClientConnectionHandler.VCARD_TYPE_30) {parser = new VCardParser_V30();} else {parser = new VCardParser_V21();}VCardEntryConstructor constructor =new VCardEntryConstructor(VCardConfig.VCARD_TYPE_V21_GENERIC, mAccount);VCardEntryCounter counter = new VCardEntryCounter();CardEntryHandler handler = new CardEntryHandler();constructor.addEntryHandler(handler);parser.addInterpreter(constructor);parser.addInterpreter(counter);try {parser.parse(in); // 最终 给到了 VCardParser_V30 去触发解析, 最终调用到 VCardParserImpl_V21.parse} catch (VCardException e) {e.printStackTrace();}}
// java/com/android/vcard/VCardParser_V30.javaprivate final VCardParserImpl_V30 mVCardParserImpl;public void parse(InputStream is) throws IOException, VCardException {mVCardParserImpl.parse(is);}
// java/com/android/vcard/VCardParserImpl_V30.java
class VCardParserImpl_V30 extends VCardParserImpl_V21 {}
VCardParserImpl_V30
继承于VCardParserImpl_V21
1. VCardParserImpl_V21.parse
最终调用到 VCardParserImpl_V21.parse
中去解析
VCardParserImpl_V21.parse()
是 PBAP 拉下来的 vCard 数据被解析的入口,它决定了 联系人号码什么时候进入 AOSP 的 vCard → Contacts 转换链路
// java/com/android/vcard/VCardParserImpl_V21.javapublic void parse(InputStream is) throws IOException, VCardException {if (is == null) {throw new NullPointerException("InputStream must not be null.");}// 1. 用中间字符集包装输入流final InputStreamReader tmpReader = new InputStreamReader(is, mIntermediateCharset);mReader = new CustomBufferedReader(tmpReader);final long start = System.currentTimeMillis();for (VCardInterpreter interpreter : mInterpreterList) {// 2. 通知所有 interpreter:一个 vCard 文件开始interpreter.onVCardStarted();}// 3. 循环解析每个 vCardwhile (true) {synchronized (this) {if (mCanceled) {Log.i(LOG_TAG, "Cancel request has come. exitting parse operation.");break;}}Log.i(LOG_TAG, "parseOneVCard()");if (!parseOneVCard()) {// <--- 核心,解析单个 vCardbreak;}}// 4. 通知所有 interpreter:整个 vCard 文件结束for (VCardInterpreter interpreter : mInterpreterList) {interpreter.onVCardEnded();}}
输入流 (is
)
来自 PBAP GET 请求 (BluetoothPbapRequestPullPhoneBook.execute()
→ readResponse(is)
)。
→ 内容就是远端手机发来的 VCARD 2.1/3.0 格式文本,例如:
BEGIN:VCARD
VERSION:2.1
N:;Leo;;;
FN:Leo
TEL;CELL:15218xx1x9x
END:VCARD
-
parseOneVCard()
负责逐行读TEL:xxx
、FN:xxx
,并调用 interpreter 来处理。-
解析到
TEL:1521xxxxxxxx
→ 调用interpreter.onPhoneNumber("1521xxxxxx")
。 -
interpreter 一般是
VCardEntryConstructor
,它会把号码存到VCardEntry
的PhoneData
。
-
-
号码在这里不会被改动
-
VCardParserImpl_V21
只做 语法解析。 -
它不会调用
PhoneNumberUtils.formatNumber()
。 -
所以
TEL:152xxxxxxx
进入VCardEntry
时仍然是原样。
-
2. parseOneVCard
VCardParserImpl_V21.parseOneVCard()
,这就是 单个联系人 vCard 的解析流程
备注:TAG2 onEntryStarted 调用处
// java/com/android/vcard/VCardParserImpl_V21.javaprivate boolean parseOneVCard() throws IOException, VCardException {// 1. 每个 vCard 的初始状态重置mCurrentEncoding = DEFAULT_ENCODING;mCurrentCharset = DEFAULT_CHARSET;// 2. 跳过前置垃圾数据,找到 "BEGIN:VCARD"boolean allowGarbage = true;if (!readBeginVCard(allowGarbage)) {return false;// 没有更多 vCard 了}// 3. 通知所有 interpreter:一个新 entry 开始for (VCardInterpreter interpreter : mInterpreterList) {interpreter.onEntryStarted();}Log.i(LOG_TAG, "parseOneVCard 1");// 4. 真正逐行解析 vCard 内容 (FN, N, TEL...)parseItems();// 5. 通知 interpreter:entry 结束for (VCardInterpreter interpreter : mInterpreterList) {interpreter.onEntryEnded();}return true;}
protected void parseItems() throws IOException, VCardException {boolean ended = false;try {Log.i(LOG_TAG, "parseItems 1");// 第一次尝试解析项目, - 如果遇到注释行异常,记录日志但继续执行ended = parseItem();} catch (VCardInvalidCommentLineException e) {Log.e(LOG_TAG, "Invalid line which looks like some comment was found. Ignored.");}while (!ended) {try {// 只要 parseItem() 返回 false 就继续循环Log.i(LOG_TAG, "parseItems 2");ended = parseItem();} catch (VCardInvalidCommentLineException e) {Log.e(LOG_TAG, "Invalid line which looks like some comment was found. Ignored.");}}}
- 尝试解析第一个项目管理
- 如果未结束,进入循环继续解析后续项目
- 遇到注释行时记录错误但继续执行
- 直到
parseItem()
返回true
表示解析完成
这种方法确保了 vCard 数据的稳健解析,即使文件中包含不规范的注释行也能继续处理。
protected boolean parseItem() throws IOException, VCardException {// 重置当前编码为默认值,为解析新项目做准备mCurrentEncoding = DEFAULT_ENCODING;final String line = getNonEmptyLine();// 读取非空行final VCardProperty propertyData = constructPropertyData(line);// 构造属性对象final String propertyNameUpper = propertyData.getName().toUpperCase();final String propertyRawValue = propertyData.getRawValue();if (propertyNameUpper.equals(VCardConstants.PROPERTY_BEGIN)) {if (propertyRawValue.equalsIgnoreCase("VCARD")) {handleNest();// 处理嵌套的 vCard} else {throw new VCardException("Unknown BEGIN type: " + propertyRawValue);}} else if (propertyNameUpper.equals(VCardConstants.PROPERTY_END)) {if (propertyRawValue.equalsIgnoreCase("VCARD")) {return true; // 当前 vCard 解析结束} else {throw new VCardException("Unknown END type: " + propertyRawValue);}} else {Log.i(LOG_TAG, "parseItem property: " + propertyData);parseItemInter(propertyData, propertyNameUpper);// 解析具体属性内容}return false;// 解析未结束,继续下一个项目}
开始解析↓
读取一行 → 构造属性对象↓
判断属性类型:- BEGIN VCARD: 处理嵌套 → 返回false继续- END VCARD: 返回true结束- 普通属性: 解析内容 → 返回false继续
输入数据:
BEGIN:VCARD
FN:John Doe
TEL:123-456-7890
END:VCARD执行流程:
1. parseItem() → BEGIN:VCARD → handleNest() → return false
2. parseItem() → FN:John Doe → parseItemInter() → return false
3. parseItem() → TEL:123-456-7890 → parseItemInter() → return false
4. parseItem() → END:VCARD → return true (解析结束)
private void parseItemInter(VCardProperty property, String propertyNameUpper)throws IOException, VCardException {String propertyRawValue = property.getRawValue();if (propertyNameUpper.equals(VCardConstants.PROPERTY_AGENT)) {handleAgent(property);// 专门处理 AGENT 属性,AGENT 属性有特殊处理逻辑,可能是嵌套 vCard 或复杂结构} else if (isValidPropertyName(propertyNameUpper)) {// 版本兼容性检查if (propertyNameUpper.equals(VCardConstants.PROPERTY_VERSION) &&!propertyRawValue.equals(getVersionString())) {throw new VCardVersionException("Incompatible version: " + propertyRawValue + " != " + getVersionString());}Log.i(LOG_TAG, "parseItemInter property: " + property);handlePropertyValue(property, propertyNameUpper);// 处理普通属性值} else {throw new VCardException("Unknown property name: \"" + propertyNameUpper + "\"");}}
属性名称判断:
├── BEGIN/END: parseItem() 直接处理 (结构控制)
├── AGENT: parseItemInter() → handleAgent() (特殊处理)
├── 已知有效属性:
│ ├── VERSION: 版本验证 → handlePropertyValue()
│ └── 其他: handlePropertyValue() (通用处理)
└── 未知属性: 抛出异常BEGIN:VCARD
VERSION:4.0
FN:John Doe
AGENT:...
TEL:123-456-7890
UNKNOWN_PROP:value # 这会抛出异常
END:VCARD
处理流程:
BEGIN:VCARD
→parseItem()
处理VERSION:4.0
→ 版本验证 →handlePropertyValue()
FN:John Doe
→handlePropertyValue()
AGENT:...
→handleAgent()
UNKNOWN_PROP:value
→ 抛出VCardException
这种设计确保了 vCard 解析的健壮性和可扩展性,同时保持了清晰的代码结构。
/*** 处理vCard属性值的核心方法,根据不同的编码方式对属性值进行解码和处理* * @param property 包含属性名、原始值、参数等信息的属性对象* @param propertyName 大写的属性名称,用于快速判断属性类型* @throws IOException 当读取数据发生IO异常时抛出* @throws VCardException 当vCard格式错误或解析失败时抛出*/
protected void handlePropertyValue(VCardProperty property, String propertyName)throws IOException, VCardException {// 获取属性基本信息final String propertyNameUpper = property.getName().toUpperCase();String propertyRawValue = property.getRawValue();// 字符集设置:源字符集(中间处理用)和目标字符集(最终输出用)final String sourceCharset = VCardConfig.DEFAULT_INTERMEDIATE_CHARSET;final Collection<String> charsetCollection =property.getParameters(VCardConstants.PARAM_CHARSET);// 优先使用属性中指定的字符集,如果没有则使用默认导入字符集String targetCharset =((charsetCollection != null) ? charsetCollection.iterator().next() : null);if (TextUtils.isEmpty(targetCharset)) {targetCharset = VCardConfig.DEFAULT_IMPORT_CHARSET;}// 特殊处理:ADR(地址)、ORG(组织)、N(姓名)属性,这些属性具有可分割的结构化值// TODO: 根据vCard规范实现更准确的"可分割属性"判断机制if (propertyNameUpper.equals(VCardConstants.PROPERTY_ADR)|| propertyNameUpper.equals(VCardConstants.PROPERTY_ORG)|| propertyNameUpper.equals(VCardConstants.PROPERTY_N)) {handleAdrOrgN(property, propertyRawValue, sourceCharset, targetCharset);return; // 特殊处理完成后直接返回}// 情况1:Quoted-Printable编码处理if (mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_QP) ||// 特殊情况:处理Android导出器bug导致的FN属性缺少编码声明(bug号b/7292017)(propertyNameUpper.equals(VCardConstants.PROPERTY_FN) &&property.getParameters(VCardConstants.PARAM_ENCODING) == null &&VCardUtils.appearsLikeAndroidVCardQuotedPrintable(propertyRawValue))) {// 提取QP编码部分并进行解码final String quotedPrintablePart = getQuotedPrintablePart(propertyRawValue);final String propertyEncodedValue =VCardUtils.parseQuotedPrintable(quotedPrintablePart,false, sourceCharset, targetCharset);// 更新属性值:设置原始QP编码值和解码后的文本值property.setRawValue(quotedPrintablePart);property.setValues(propertyEncodedValue);// 通知所有解释器属性创建完成for (VCardInterpreter interpreter : mInterpreterList) {Log.i(LOG_TAG, "handlePropertyValue 1 property: " + property);interpreter.onPropertyCreated(property);}} // 情况2:Base64编码处理(用于图片、附件等二进制数据)else if (mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_BASE64)|| mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_B)) {// 处理可能的大文件导致的OutOfMemoryErrortry {final String base64Property = getBase64(propertyRawValue);try {// Base64解码为字节数组property.setByteValue(Base64.decode(base64Property, Base64.DEFAULT));} catch (IllegalArgumentException e) {throw new VCardException("Decode error on base64 photo: " + propertyRawValue);}for (VCardInterpreter interpreter : mInterpreterList) {Log.i(LOG_TAG, "handlePropertyValue 2 property: " + property);interpreter.onPropertyCreated(property);}} catch (OutOfMemoryError error) {// 内存不足时的降级处理:记录错误但仍通知解释器Log.e(LOG_TAG, "OutOfMemoryError happened during parsing BASE64 data!");for (VCardInterpreter interpreter : mInterpreterList) {Log.i(LOG_TAG, "handlePropertyValue 3 property: " + property);interpreter.onPropertyCreated(property);}}} // 情况3:7BIT/8BIT编码或其他文本编码处理else {// 检查不支持的编码类型并记录警告(但继续处理)if (!(mCurrentEncoding.equals("7BIT") || mCurrentEncoding.equals("8BIT") ||mCurrentEncoding.startsWith("X-"))) {Log.w(LOG_TAG,String.format("The encoding \"%s\" is unsupported by vCard %s",mCurrentEncoding, getVersionString()));}// vCard 2.1特殊处理:处理不符合规范的行折叠(RFC 2425特性,vCard 2.1不支持)// 示例:多行EMAIL属性需要合并处理// 注意:vCard 3.0有正式的行折叠机制,所以只需要在2.1版本处理此问题if (getVersion() == VCardConfig.VERSION_21) {StringBuilder builder = null;while (true) {final String nextLine = peekLine(); // 预览下一行而不消耗// 检查下一行是否以空格开头(表示是续行)且不是END:VCARD关键行if (!TextUtils.isEmpty(nextLine) &&nextLine.charAt(0) == ' ' &&!"END:VCARD".contains(nextLine.toUpperCase())) {getLine(); // 消耗续行// 延迟创建StringBuilder,只在确实有续行时使用if (builder == null) {builder = new StringBuilder();builder.append(propertyRawValue);}// 去除续行前的空格并追加内容builder.append(nextLine.substring(1));} else {break; // 没有更多续行,退出循环}}// 如果有续行被合并,更新属性原始值if (builder != null) {propertyRawValue = builder.toString();}}// 标准文本处理流程:字符集转换 + 可能的文本反转义ArrayList<String> propertyValueList = new ArrayList<String>();String value = maybeUnescapeText(VCardUtils.convertStringCharset(propertyRawValue, sourceCharset, targetCharset));propertyValueList.add(value);property.setValues(propertyValueList);// 通知所有解释器属性创建完成for (VCardInterpreter interpreter : mInterpreterList) {Log.i(LOG_TAG, "handlePropertyValue 4 property: " + property);interpreter.onPropertyCreated(property); // 继续这里的分析}}
}
- java/com/android/vcard/VCardEntryConstructor.java
public class VCardEntryConstructor implements VCardInterpreter {public void onPropertyCreated(VCardProperty property) {Log.i(LOG_TAG, "onPropertyCreated property: " + property);mCurrentEntry.addProperty(property); // 这里会调用到 VCardEntry.addProperty}
}
3. VCardEntry.addProperty
这个方法是vCard解析器的数据收集核心,它将标准化的vCard属性转换为Android联系人数据库的结构化数据模型。
- java/com/android/vcard/VCardEntry.java
/*** 将vCard属性添加到联系人数据模型中的核心方法* 根据属性名称分发到不同的处理逻辑,构建完整的联系人信息* * @param property 包含属性名、参数、值列表等信息的vCard属性对象*/
public void addProperty(final VCardProperty property) {// 提取属性基本信息final String propertyName = property.getName();final Map<String, Collection<String>> paramMap = property.getParameterMap();final List<String> propertyValueList = property.getValueList();byte[] propertyBytes = property.getByteValue();// 有效性检查:跳过没有有效值的属性if ((propertyValueList == null || propertyValueList.size() == 0)&& propertyBytes == null) {return;}// 将值列表转换为单个字符串(适用于文本属性)final String propValue = (propertyValueList != null? listToString(propertyValueList).trim(): null);// 属性类型分发处理if (propertyName.equals(VCardConstants.PROPERTY_VERSION)) {// vCard版本信息,解析过程中已处理,此处忽略} else if (propertyName.equals(VCardConstants.PROPERTY_FN)) {// 格式化姓名(Formatted Name)mNameData.mFormatted = propValue;} else if (propertyName.equals(VCardConstants.PROPERTY_NAME)) {// vCard 3.0中的NAME属性,仅在FN不存在时使用(尽管vCard 3.0要求必须有FN)if (TextUtils.isEmpty(mNameData.mFormatted)) {mNameData.mFormatted = propValue;}} else if (propertyName.equals(VCardConstants.PROPERTY_N)) {// 结构化姓名(包含姓、名、前缀、后缀等组成部分)handleNProperty(propertyValueList, paramMap);} else if (propertyName.equals(VCardConstants.PROPERTY_SORT_STRING)) {// 排序字符串mNameData.mSortString = propValue;} else if (propertyName.equals(VCardConstants.PROPERTY_NICKNAME)|| propertyName.equals(VCardConstants.ImportOnly.PROPERTY_X_NICKNAME)) {// 昵称处理addNickName(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_SOUND)) {// 语音姓名(用于发音)Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);if (typeCollection != null&& typeCollection.contains(VCardConstants.PARAM_TYPE_X_IRMC_N)) {// 处理IRMC-N类型的语音属性:将值按分号分割为拼音名字段final List<String> phoneticNameList = VCardUtils.constructListFromValue(propValue,mVCardType);handlePhoneticNameFromSound(phoneticNameList);} else {// 忽略其他类型的SOUND属性,因为Android无法理解其含义}} else if (propertyName.equals(VCardConstants.PROPERTY_ADR)) {// 地址处理boolean valuesAreAllEmpty = true;for (String value : propertyValueList) {if (!TextUtils.isEmpty(value)) {valuesAreAllEmpty = false;break;}}if (valuesAreAllEmpty) {return; // 跳过全空的地址}int type = -1;String label = null;boolean isPrimary = false;final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);if (typeCollection != null) {for (final String typeStringOrg : typeCollection) {final String typeStringUpperCase = typeStringOrg.toUpperCase();if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) {isPrimary = true; // 首选地址} else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_HOME)) {type = StructuredPostal.TYPE_HOME;label = null;} else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_WORK)|| typeStringUpperCase.equalsIgnoreCase(VCardConstants.PARAM_EXTRA_TYPE_COMPANY)) {// "COMPANY"由Windows Mobile发出,vCard 2.1不支持,假定与"WORK"相同type = StructuredPostal.TYPE_WORK;label = null;} else if (typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_PARCEL)|| typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_DOM)|| typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_INTL)) {// 没有适当的方式存储这些地址类型信息} else if (type < 0) { // 如果之前没有指定其他类型type = StructuredPostal.TYPE_CUSTOM;if (typeStringUpperCase.startsWith("X-")) { // 处理X-前缀的自定义类型label = typeStringOrg.substring(2);} else {label = typeStringOrg;}// 如果自定义类型是"other",映射到TYPE_OTHERif (VCardConstants.PARAM_ADR_EXTRA_TYPE_OTHER.equals(label.toUpperCase())) {type = StructuredPostal.TYPE_OTHER;label = null;}}}}// 默认使用"家庭"地址类型if (type < 0) {type = StructuredPostal.TYPE_HOME;}addPostal(type, propertyValueList, label, isPrimary);} else if (propertyName.equals(VCardConstants.PROPERTY_EMAIL)) {// 电子邮件处理int type = -1;String label = null;boolean isPrimary = false;final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);if (typeCollection != null) {for (final String typeStringOrg : typeCollection) {final String typeStringUpperCase = typeStringOrg.toUpperCase();if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) {isPrimary = true;} else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_HOME)) {type = Email.TYPE_HOME;} else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_WORK)) {type = Email.TYPE_WORK;} else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_CELL)) {type = Email.TYPE_MOBILE;} else if (type < 0) { // 如果之前没有指定其他类型if (typeStringUpperCase.startsWith("X-")) { // 处理X-前缀的自定义类型label = typeStringOrg.substring(2);} else {label = typeStringOrg;}type = Email.TYPE_CUSTOM;}}}if (type < 0) {type = Email.TYPE_OTHER;}addEmail(type, propValue, label, isPrimary);} else if (propertyName.equals(VCardConstants.PROPERTY_ORG)) {// 组织信息处理final int type = Organization.TYPE_WORK; // vCard规范未指定其他类型boolean isPrimary = false;Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);if (typeCollection != null) {for (String typeString : typeCollection) {if (typeString.equals(VCardConstants.PARAM_TYPE_PREF)) {isPrimary = true;}}}handleOrgValue(type, propertyValueList, paramMap, isPrimary);} else if (propertyName.equals(VCardConstants.PROPERTY_TITLE)) {// 职位/头衔处理handleTitleValue(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_ROLE)) {// 角色(与TITLE冲突,暂时忽略)// handleTitleValue(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_PHOTO)|| propertyName.equals(VCardConstants.PROPERTY_LOGO)) {// 照片或Logo处理Collection<String> paramMapValue = paramMap.get("VALUE");if (paramMapValue != null && paramMapValue.contains("URL")) {// 当前没有适当的测试案例处理URL类型的图片} else {final Collection<String> typeCollection = paramMap.get("TYPE");String formatName = null;boolean isPrimary = false;if (typeCollection != null) {for (String typeValue : typeCollection) {if (VCardConstants.PARAM_TYPE_PREF.equals(typeValue)) {isPrimary = true;} else if (formatName == null) {formatName = typeValue; // 图片格式}}}addPhotoBytes(formatName, propertyBytes, isPrimary);}} else if (propertyName.equals(VCardConstants.PROPERTY_TEL)) {// 电话号码处理String phoneNumber = null;boolean isSip = false;if (VCardConfig.isVersion40(mVCardType)) {// vCard 4.0中电话号码使用URI格式if (propValue.startsWith("sip:")) {isSip = true; // SIP语音电话} else if (propValue.startsWith("tel:")) {phoneNumber = propValue.substring(4); // 提取电话号码} else {// 未知协议,保留原始值phoneNumber = propValue;}} else {phoneNumber = propValue; // vCard 3.0及以下版本直接使用}if (isSip) {// SIP号码特殊处理final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);handleSipCase(propValue, typeCollection);} else {if (propValue.length() == 0) {return; // 跳过空电话号码}final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);final Object typeObject = VCardUtils.getPhoneTypeFromStrings(typeCollection,phoneNumber);final int type;final String label;if (typeObject instanceof Integer) {type = (Integer) typeObject; // 标准类型label = null;} else {type = Phone.TYPE_CUSTOM; // 自定义类型label = typeObject.toString();}final boolean isPrimary = typeCollection != null &&typeCollection.contains(VCardConstants.PARAM_TYPE_PREF);Log.i(TAG, "addPhone 1 type: " + type + ", phoneNumber: " + phoneNumber + ", label: " + label + ", isPrimary: " + isPrimary);addPhone(type, phoneNumber, label, isPrimary); // 本例重点分析这里}} else if (propertyName.equals(VCardConstants.PROPERTY_X_SKYPE_PSTNNUMBER)) {// Skype电话号码扩展属性Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);final int type = Phone.TYPE_OTHER;final boolean isPrimary = typeCollection != null&& typeCollection.contains(VCardConstants.PARAM_TYPE_PREF);Log.i(TAG, "addPhone 2 type: " + type + ", propValue: " + propValue + ", label: " + null + ", isPrimary: " + isPrimary);addPhone(type, propValue, null, isPrimary);} else if (sImMap.containsKey(propertyName)) {// 即时通讯账号处理(通过预定义的IM协议映射)final int protocol = sImMap.get(propertyName);boolean isPrimary = false;int type = -1;final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);if (typeCollection != null) {for (String typeString : typeCollection) {if (typeString.equals(VCardConstants.PARAM_TYPE_PREF)) {isPrimary = true;} else if (type < 0) {if (typeString.equalsIgnoreCase(VCardConstants.PARAM_TYPE_HOME)) {type = Im.TYPE_HOME;} else if (typeString.equalsIgnoreCase(VCardConstants.PARAM_TYPE_WORK)) {type = Im.TYPE_WORK;}}}}if (type < 0) {type = Im.TYPE_HOME; // 默认家庭类型}addIm(protocol, null, propValue, type, isPrimary);} else if (propertyName.equals(VCardConstants.PROPERTY_NOTE)) {// 备注信息addNote(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_URL)) {// 网址处理if (mWebsiteList == null) {mWebsiteList = new ArrayList<WebsiteData>(1);}mWebsiteList.add(new WebsiteData(propValue));} else if (propertyName.equals(VCardConstants.PROPERTY_BDAY)) {// 生日信息mBirthday = new BirthdayData(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_ANNIVERSARY)) {// 纪念日信息mAnniversary = new AnniversaryData(propValue);} else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_FIRST_NAME)) {// 拼音名字段扩展属性mNameData.mPhoneticGiven = propValue;} else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_MIDDLE_NAME)) {mNameData.mPhoneticMiddle = propValue;} else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_LAST_NAME)) {mNameData.mPhoneticFamily = propValue;} else if (propertyName.equals(VCardConstants.PROPERTY_IMPP)) {// 即时通讯协议(RFC 4770,vCard 3.0)if (propValue.startsWith("sip:")) {final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);handleSipCase(propValue, typeCollection);}} else if (propertyName.equals(VCardConstants.PROPERTY_X_SIP)) {// SIP扩展属性if (!TextUtils.isEmpty(propValue)) {final Collection<String> typeCollection = paramMap.get(VCardConstants.PARAM_TYPE);handleSipCase(propValue, typeCollection);}} else if (propertyName.equals(VCardConstants.PROPERTY_X_ANDROID_CUSTOM)) {// Android自定义属性处理final List<String> customPropertyList = VCardUtils.constructListFromValue(propValue,mVCardType);handleAndroidCustomProperty(customPropertyList);} else if (propertyName.toUpperCase().startsWith("X-")) {// 处理所有X-开头的扩展属性if (mUnknownXData == null) {mUnknownXData = new ArrayList<Pair<String, String>>();}mUnknownXData.add(new Pair<String, String>(propertyName, propValue));// 保存扩展属性的参数映射if (mUnknownXDataParamMap == null) {if (paramMap != null && !paramMap.isEmpty()) {mUnknownXDataParamMap = new HashMap<String, Collection<String>>();mUnknownXDataParamMap.putAll(paramMap);}}} else {// 未知属性类型,静默忽略}// 注意:某些上面的处理块可能使用了"return",在此处添加逻辑时要小心
}
我们继续接着 :分析
Log.i(TAG, "addPhone 1 type: " + type + ", phoneNumber: " + phoneNumber + ", label: " + label + ", isPrimary: " + isPrimary);addPhone(type, phoneNumber, label, isPrimary); // 本例重点分析这里
4. VCardEntry.addPhone
- java/com/android/vcard/VCardEntry.java
/*** 添加电话号码到联系人数据模型中的方法* 负责电话号码的格式化、清理和存储,处理特殊字符如暂停/等待符* * @param type 电话号码类型(如家庭、工作、移动等),使用Phone类的常量* @param data 原始电话号码字符串* @param label 自定义类型时的标签(如type为TYPE_CUSTOM时使用)* @param isPrimary 是否为主要电话号码*/
private void addPhone(int type, String data, String label, boolean isPrimary) {// 初始化电话号码列表(延迟初始化)if (mPhoneList == null) {mPhoneList = new ArrayList<PhoneData>();}final StringBuilder builder = new StringBuilder();final String trimmed = data.trim(); // 去除首尾空白字符final String formattedNumber; // 格式化后的最终号码Log.i(TAG, "addPhone type: " + type + ", data: " + data + ", label: " + label + ", isPrimary: " + isPrimary);// 情况1:寻呼机号码或配置为禁止格式化时,直接使用原始号码if (type == Phone.TYPE_PAGER || VCardConfig.refrainPhoneNumberFormatting(mVCardType)) {formattedNumber = trimmed;} // 情况2:需要格式化的普通电话号码else {// TODO: 从vCard规范角度看,这些自动转换应该移除// 注意:其他代码(如电话号码格式化器)或模块依赖此自动转换(bug 5178723),// 因此简单地删除此代码不是最佳方案(bug 4177894)boolean hasPauseOrWait = false; // 标记是否包含暂停/等待字符final int length = trimmed.length();// 遍历号码字符,进行清理和转换for (int i = 0; i < length; i++) {char ch = trimmed.charAt(i);// 参考RFC 3601和PhoneNumberUtils文档获取更多信息if (ch == 'p' || ch == 'P') {// 转换暂停符(Pause)为系统标准表示builder.append(PhoneNumberUtils.PAUSE);hasPauseOrWait = true;} else if (ch == 'w' || ch == 'W') {// 转换等待符(Wait)为系统标准表示builder.append(PhoneNumberUtils.WAIT);hasPauseOrWait = true;} else if (PhoneNumberUtils.is12Key(ch) || (i == 0 && ch == '+')) {// 保留数字键字符(0-9、*、#)和首位的"+"号(国际号码前缀)builder.append(ch);}// 注意:其他非数字字符(如空格、括号、连字符等)在此被过滤掉}// 根据是否包含特殊字符决定是否进行完整格式化if (!hasPauseOrWait) { // 我们的案例中会走到这里,强制格式化// 无特殊字符:进行完整的电话号码格式化final int formattingType = VCardUtils.getPhoneNumberFormat(mVCardType);formattedNumber = PhoneNumberUtilsPort.formatNumber(builder.toString(), formattingType);} else {// 包含暂停/等待符:保留原始格式,只进行基础清理formattedNumber = builder.toString();}}// 创建电话号码数据对象并添加到列表PhoneData phoneData = new PhoneData(formattedNumber, type, label, isPrimary);mPhoneList.add(phoneData);
}
该方法主要 做两件事情:
-
电话号码清洗整理:
- 保留字符:数字0-9、*、#、首位的+
- 特殊转换:p/P → 暂停符,w/W → 等待符
- 过滤字符:空格、括号、连字符等格式化字符
-
格式化电话号码
清洗整理举例:
输入: "+1 (555) 123-4567p123w456"
处理:- 保留: +15551234567- 转换: p → PAUSE, w → WAIT- 结果: "+15551234567,123;456"(具体格式取决于系统实现)
格式化举例:
输入: 1521xxxxxxx
格式化为: 1-521-xxx-xxxx
如果仔细阅读到这里, 想必已经知道 我们开篇说的 , 车机通讯录姓名栏显示电话号码时电话号码分割异常。 是怎么回事了吧。
我们可以在 这个判断中做点事情,就可以修复:
// 情况1:寻呼机号码或配置为禁止格式化时,直接使用原始号码if (type == Phone.TYPE_PAGER || VCardConfig.refrainPhoneNumberFormatting(mVCardType)) {formattedNumber = trimmed;}
5. VCardConfig.refrainPhoneNumberFormatting
- java/com/android/vcard/VCardConfig.java
/* package */ static boolean refrainPhoneNumberFormatting(final int vcardType) {return ((vcardType & FLAG_REFRAIN_PHONE_NUMBER_FORMATTING) != 0);}
也就是说,只要 VCardEntry.mVCardType
里面包含了 FLAG_REFRAIN_PHONE_NUMBER_FORMATTING
就可以 避免我们的电话号码被格式化。
问题的解药找到了.
6. 整个流程的日志
01-14 04:56:04.764 2337 6206 I vCard : parseOneVCard()
01-14 04:56:04.764 2337 6206 I vCard : parseOneVCard 1
01-14 04:56:04.764 2337 6206 I vCard : parseItems 1
01-14 04:56:04.764 2337 6206 I vCard : parseItem property: com.android.vcard.VCardProperty@2200971
01-14 04:56:04.764 2337 6206 I vCard : parseItemInter property: com.android.vcard.VCardProperty@2200971
01-14 04:56:04.764 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@2200971
01-14 04:56:04.764 2337 6206 I vCard : onPropertyCreated property: com.android.vcard.VCardProperty@2200971
01-14 04:56:04.764 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@2200971
01-14 04:56:04.764 2337 6206 I vCard : parseItems 2
01-14 04:56:04.765 2337 6206 I vCard : parseItem property: com.android.vcard.VCardProperty@8eaa356
01-14 04:56:04.765 2337 6206 I vCard : parseItemInter property: com.android.vcard.VCardProperty@8eaa356
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 5 property: com.android.vcard.VCardProperty@8eaa356
01-14 04:56:04.765 2337 6206 I vCard : onPropertyCreated property: com.android.vcard.VCardProperty@8eaa356
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 5 property: com.android.vcard.VCardProperty@8eaa356
01-14 04:56:04.765 2337 6206 I vCard : parseItems 2
01-14 04:56:04.765 2337 6206 I vCard : parseItem property: com.android.vcard.VCardProperty@42fefd7
01-14 04:56:04.765 2337 6206 I vCard : parseItemInter property: com.android.vcard.VCardProperty@42fefd7
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@42fefd7
01-14 04:56:04.765 2337 6206 I vCard : onPropertyCreated property: com.android.vcard.VCardProperty@42fefd7
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@42fefd7
01-14 04:56:04.765 2337 6206 I vCard : parseItems 2
01-14 04:56:04.765 2337 6206 I vCard : parseItem property: com.android.vcard.VCardProperty@ac6d0c4
01-14 04:56:04.765 2337 6206 I vCard : parseItemInter property: com.android.vcard.VCardProperty@ac6d0c4
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@ac6d0c4
01-14 04:56:04.765 2337 6206 I vCard : onPropertyCreated property: com.android.vcard.VCardProperty@ac6d0c4
01-14 04:56:04.765 2337 6206 I VCardEntry: addPhone 1 type: 2, phoneNumber: 1521xxxxxxxxx, label: null, isPrimary: false
01-14 04:56:04.765 2337 6206 I VCardEntry: addPhone type: 2, data: 1521xxxxxx, label: null, isPrimary: false
01-14 04:56:04.765 2337 6206 I vCard : handlePropertyValue 4 property: com.android.vcard.VCardProperty@ac6d0c4
01-14 04:56:04.765 2337 6206 I vCard : parseItems 2
01-14 04:56:04.765 2337 6206 I vCard : parseOneVCard()
三、VCardEntry.mVCardType 是哪里初始化的
上一节我们已经找到了 问题解药, 那现在就来梳理, VCardEntry.mVCardType
的初始化流程
1. BluetoothPbapVcardList.parse
在前面已经分析了该函数的调用时机: 可以搜素 TAG 1 BluetoothPbapVcardList.parse 调用处
查看。
// android/app/src/com/android/bluetooth/pbapclient/BluetoothPbapVcardList.javaprivate void parse(InputStream in, byte format) throws IOException {VCardParser parser;if (format == PbapClientConnectionHandler.VCARD_TYPE_30) {parser = new VCardParser_V30();} else {parser = new VCardParser_V21();}VCardEntryConstructor constructor =new VCardEntryConstructor(VCardConfig.VCARD_TYPE_V21_GENERIC /*这里传入了 vcardType*/, mAccount);VCardEntryCounter counter = new VCardEntryCounter();CardEntryHandler handler = new CardEntryHandler();constructor.addEntryHandler(handler);parser.addInterpreter(constructor);parser.addInterpreter(counter);try {Log.i("LOG_TAG_pbapvcardlist", "parse 1");parser.parse(in);} catch (VCardException e) {e.printStackTrace();}}
2. VCardEntryConstructor 构造函数
// java/com/android/vcard/VCardEntryConstructor.javapublic VCardEntryConstructor(final int vcardType, final Account account,String targetCharset) {mVCardType = vcardType; // 将其赋值给了 VCardEntryConstructor.mVCardTypemAccount = account;}// 可以搜索 `TAG2 onEntryStarted 调用处` 看该函数的调用时机public void onEntryStarted() {mCurrentEntry = new VCardEntry(mVCardType, mAccount); // 创建 VCardEntrymEntryStack.add(mCurrentEntry);}
3. VCardEntry 构造函数
- java/com/android/vcard/VCardEntry.java
public VCardEntry(int vcardType, Account account) {mVCardType = vcardType;mAccount = account;}
4. 解药已经很明显了
还需要 我写吗???
四、为何 手机传入的原始名字是空的,而应用能拿到名字呢?
回忆一下:
我将 btsnoop 中的 电话簿信息 导出了:
BEGIN:VCARD
VERSION:3.0
N:
FN:
TEL;TYPE=CELL:15218xx1x9x
END:VCARD
- 发现他的 N: 和 FN: 下都是空的,也就是这个电话,没有保存联系人姓名。
那这里就很奇怪, 名字是空的, 那为何 app 侧,能拿到 类似 1-521-8xx-1x9x
这种显示呢。
该Vcard N, FN 都是空的。 而我们app 用于显示的名字 是通过如下的方式获取的:
VCardEntry entry;
String displayName = entry.getDisplayName();
1. VCardEntry.getDisplayName
// java/com/android/vcard/VCardEntry.javapublic String getDisplayName() {if (mNameData.displayName == null) { // 我们这里第一次 是NUll, 因为没有名字mNameData.displayName = constructDisplayName(); // 通过他来获取}return mNameData.displayName;}
2. VCardEntry.constructDisplayName
/*** 智能构建联系人显示名称的方法* 按照优先级从高到低的顺序尝试多种数据源,确保始终能返回一个可用的显示名称* 优先级顺序:格式化姓名 → 结构化姓名 → 拼音姓名 → 邮箱 → 电话 → 地址 → 公司*/
private String constructDisplayName() {String displayName = null;// 优先级1:使用格式化全名(来自FN或NAME字段) - 这是vCard规范的首选方式if (!TextUtils.isEmpty(mNameData.mFormatted)) {displayName = mNameData.mFormatted; // 直接使用预格式化的姓名} // 优先级2:如果格式化姓名为空,尝试从结构化姓名组件构建显示名称else if (!mNameData.emptyStructuredName()) {// 根据vCard类型和地区习惯,组合姓、名、中间名、前缀、后缀等组件displayName = VCardUtils.constructNameFromElements(mVCardType, mNameData.mFamily,mNameData.mMiddle, mNameData.mGiven, mNameData.mPrefix, mNameData.mSuffix);} // 优先级3:如果标准姓名组件都为空,尝试使用拼音姓名作为备选else if (!mNameData.emptyPhoneticStructuredName()) {// 使用拼音姓名字段构建显示名称(主要用于日语等需要拼音显示的场景)displayName = VCardUtils.constructNameFromElements(mVCardType,mNameData.mPhoneticFamily, mNameData.mPhoneticMiddle, mNameData.mPhoneticGiven);} // 优先级4:姓名信息完全缺失时,使用第一个电子邮件地址作为标识else if (mEmailList != null && mEmailList.size() > 0) {displayName = mEmailList.get(0).mAddress; // 使用首个邮箱地址} // 优先级5:如果没有邮箱,使用第一个电话号码else if (mPhoneList != null && mPhoneList.size() > 0) {displayName = mPhoneList.get(0).mNumber; // 使用首个电话号码} // 优先级6:如果没有联系方式,使用格式化后的地址信息else if (mPostalList != null && mPostalList.size() > 0) {// 获取第一个地址的格式化字符串(根据vCard类型进行适当格式化)displayName = mPostalList.get(0).getFormattedAddress(mVCardType);} // 优先级7:最后备选方案,使用公司或组织名称else if (mOrganizationList != null && mOrganizationList.size() > 0) {displayName = mOrganizationList.get(0).getFormattedString(); // 使用首个组织名称}// 安全保障:如果所有数据源都为空,返回空字符串而非nullif (displayName == null) {displayName = "";}return displayName;
}
1. 智能回退机制
显示名称构建优先级链:
格式化姓名 → 结构化姓名 → 拼音姓名 → 邮箱 → 电话 → 地址 → 公司 → 空字符串
2. 数据完整性保障
-
永远不返回null:最终回退到空字符串,避免空指针异常
-
渐进式降级:从最人性化的姓名数据逐步降级到功能性标识符
-
实用性优先:即使没有姓名,也能用联系方式或组织信息作为标识
3. 国际化考虑
-
使用
VCardUtils.constructNameFromElements()
处理不同地区的姓名顺序 -
支持拼音姓名,适应东亚语言环境的需求
-
根据
mVCardType
适配不同的格式化规则
4. 实际应用场景
// 场景1:完整的联系人信息
// 显示名称 = "张小明" (来自mFormatted)// 场景2:只有结构化姓名组件
// 显示名称 = "张 小明" (通过组件组合)// 场景3:只有联系方式
// 显示名称 = "zhang@example.com" (首个邮箱)// 场景4:最小信息联系人
// 显示名称 = "ABC公司" (组织名称)// 场景5:空联系人
// 显示名称 = "" (空字符串)
这种设计确保了即使vCard数据不完整或格式不规范,系统也能生成一个合理的显示名称,大大提升了用户体验和数据处理的健壮性。
五、 vCard 配置标志和类型 介绍
我们遵循 拔出萝卜带出泥 的原则, 既然我们已经看到了 VCARD_TYPE_V21_GENERIC
:
那我们就来介绍一下,下面都是用来干什么的。
- java/com/android/vcard/VCardConfig.java
// 0x10 is reserved for safety/*** <p>* The flag indicating the vCard composer will add some "X-" properties used only in Android* when the formal vCard specification does not have appropriate fields for that data.* </p>* <p>* For example, Android accepts nickname information while vCard 2.1 does not.* When this flag is on, vCard composer emits alternative "X-" property (like "X-NICKNAME")* instead of just dropping it.* </p>* <p>* vCard parser code automatically parses the field emitted even when this flag is off.* </p>*/private static final int FLAG_USE_ANDROID_PROPERTY = 0x80000000;/*** <p>* The flag indicating the vCard composer will add some "X-" properties seen in the* vCard data emitted by the other softwares/devices when the formal vCard specification* does not have appropriate field(s) for that data.* </p>* <p>* One example is X-PHONETIC-FIRST-NAME/X-PHONETIC-MIDDLE-NAME/X-PHONETIC-LAST-NAME, which are* for phonetic name (how the name is pronounced), seen in the vCard emitted by some other* non-Android devices/softwares. We chose to enable the vCard composer to use those* defact properties since they are also useful for Android devices.* </p>* <p>* Note for developers: only "X-" properties should be added with this flag. vCard 2.1/3.0* allows any kind of "X-" properties but does not allow non-"X-" properties (except IANA tokens* in vCard 3.0). Some external parsers may get confused with non-valid, non-"X-" properties.* </p>*/private static final int FLAG_USE_DEFACT_PROPERTY = 0x40000000;/*** <p>* The flag indicating some specific dialect seen in vCard of DoCoMo (one of Japanese* mobile careers) should be used. This flag does not include any other information like* that "the vCard is for Japanese". So it is "possible" that "the vCard should have DoCoMo's* dialect but the name order should be European", but it is not recommended.* </p>*/private static final int FLAG_DOCOMO = 0x20000000;/*** <p>* The flag indicating the vCard composer does "NOT" use Quoted-Printable toward "primary"* properties even though it is required by vCard 2.1 (QP is prohibited in vCard 3.0).* </p>* <p>* We actually cannot define what is the "primary" property. Note that this is NOT defined* in vCard specification either. Also be aware that it is NOT related to "primary" notion* used in {@link android.provider.ContactsContract}.* This notion is just for vCard composition in Android.* </p>* <p>* We added this Android-specific notion since some (incomplete) vCard exporters for vCard 2.1* do NOT use Quoted-Printable encoding toward some properties related names like "N", "FN", etc.* even when their values contain non-ascii or/and CR/LF, while they use the encoding in the* other properties like "ADR", "ORG", etc.* <p>* We are afraid of the case where some vCard importer also forget handling QP presuming QP is* not used in such fields.* </p>* <p>* This flag is useful when some target importer you are going to focus on does not accept* such properties with Quoted-Printable encoding.* </p>* <p>* Again, we should not use this flag at all for complying vCard 2.1 spec.* </p>* <p>* In vCard 3.0, Quoted-Printable is explicitly "prohibitted", so we don't need to care this* kind of problem (hopefully).* </p>* @hide*/public static final int FLAG_REFRAIN_QP_TO_NAME_PROPERTIES = 0x10000000;/*** <p>* The flag indicating that phonetic name related fields must be converted to* appropriate form. Note that "appropriate" is not defined in any vCard specification.* This is Android-specific.* </p>* <p>* One typical (and currently sole) example where we need this flag is the time when* we need to emit Japanese phonetic names into vCard entries. The property values* should be encoded into half-width katakana when the target importer is Japanese mobile* phones', which are probably not able to parse full-width hiragana/katakana for* historical reasons, while the vCard importers embedded to softwares for PC should be* able to parse them as we expect.* </p>*/public static final int FLAG_CONVERT_PHONETIC_NAME_STRINGS = 0x08000000;/*** <p>* The flag indicating the vCard composer "for 2.1" emits "TYPE=" string toward TYPE params* every time possible. The default behavior does not emit it and is valid in the spec.* In vCrad 3.0, this flag is unnecessary, since "TYPE=" is MUST in vCard 3.0 specification.* </p>* <p>* Detail:* How more than one TYPE fields are expressed is different between vCard 2.1 and vCard 3.0.* </p>* <p>* e.g.* </p>* <ol>* <li>Probably valid in both vCard 2.1 and vCard 3.0: "ADR;TYPE=DOM;TYPE=HOME:..."</li>* <li>Valid in vCard 2.1 but not in vCard 3.0: "ADR;DOM;HOME:..."</li>* <li>Valid in vCard 3.0 but not in vCard 2.1: "ADR;TYPE=DOM,HOME:..."</li>* </ol>* <p>* If you are targeting to the importer which cannot accept TYPE params without "TYPE="* strings (which should be rare though), please use this flag.* </p>* <p>* Example usage:* <pre class="prettyprint">int type = (VCARD_TYPE_V21_GENERIC | FLAG_APPEND_TYPE_PARAM);</pre>* </p>*/public static final int FLAG_APPEND_TYPE_PARAM = 0x04000000;/*** <p>* The flag indicating the vCard composer does touch nothing toward phone number Strings* but leave it as is.* </p>* <p>* The vCard specifications mention nothing toward phone numbers, while some devices* do (wrongly, but with innevitable reasons).* For example, there's a possibility Japanese mobile phones are expected to have* just numbers, hypens, plus, etc. but not usual alphabets, while US mobile phones* should get such characters. To make exported vCard simple for external parsers,* we have used {@link PhoneNumberUtils#formatNumber(String)} during export, and* removed unnecessary characters inside the number (e.g. "111-222-3333 (Miami)"* becomes "111-222-3333").* Unfortunate side effect of that use was some control characters used in the other* areas may be badly affected by the formatting.* </p>* <p>* This flag disables that formatting, affecting both importer and exporter.* If the user is aware of some side effects due to the implicit formatting, use this flag.* </p>* <p>* Caution: this flag will be removed in the future, replaced by some richer functionality.* </p>*/public static final int FLAG_REFRAIN_PHONE_NUMBER_FORMATTING = 0x02000000;/*** <P>* The flag asking exporter to refrain image export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_IMAGE_EXPORT = 0x00800000;/*** <P>* The flag asking exporter to refrain events export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_EVENTS_EXPORT = 0x00400000;/*** <P>* The flag asking exporter to refrain addess export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_ADDRESS_EXPORT = 0x00200000;/*** <P>* The flag asking exporter to refrain email export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_EMAIL_EXPORT = 0x00100000;/*** <P>* The flag asking exporter to refrain organization export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_ORGANIZATION_EXPORT = 0x00080000;/*** <P>* The flag asking exporter to refrain notes export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_NOTES_EXPORT = 0x00040000;/*** <P>* The flag asking exporter to refrain phonetic name export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_PHONETIC_NAME_EXPORT = 0x00020000;/*** <P>* The flag asking exporter to refrain websites export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_WEBSITES_EXPORT = 0x00010000;/*** <P>* The flag asking exporter to refrain nickname export.* </P>* @hide will be deleted in the near future.*/public static final int FLAG_REFRAIN_NICKNAME_EXPORT = 0x00008000;//// The followings are VCard types available from importer/exporter. /////*** <p>* The type indicating nothing. Used by {@link VCardSourceDetector} when it* was not able to guess the exact vCard type.* </p>*/public static final int VCARD_TYPE_UNKNOWN = 0;/*** <p>* Generic vCard format with the vCard 2.1. When composing a vCard entry,* the US convension will be used toward formatting some values.* </p>* <p>* e.g. The order of the display name would be "Prefix Given Middle Family Suffix",* while it should be "Prefix Family Middle Given Suffix" in Japan for example.* </p>* <p>* Uses UTF-8 for the charset as a charset for exporting. Note that old vCard importer* outside Android cannot accept it since vCard 2.1 specifically does not allow* that charset, while we need to use it to support various languages around the world.* </p>* <p>* If you want to use alternative charset, you should notify the charset to the other* compontent to be used.* </p>*/public static final int VCARD_TYPE_V21_GENERIC =(VERSION_21 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static String VCARD_TYPE_V21_GENERIC_STR = "v21_generic";/*** <p>* General vCard format with the version 3.0. Uses UTF-8 for the charset.* </p>* <p>* Not fully ready yet. Use with caution when you use this.* </p>*/public static final int VCARD_TYPE_V30_GENERIC =(VERSION_30 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V30_GENERIC_STR = "v30_generic";/*** General vCard format with the version 4.0.* @hide vCard 4.0 is not published yet.*/public static final int VCARD_TYPE_V40_GENERIC =(VERSION_40 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V40_GENERIC_STR = "v40_generic";/*** <p>* General vCard format for the vCard 2.1 with some Europe convension. Uses Utf-8.* Currently, only name order is considered ("Prefix Middle Given Family Suffix")* </p>*/public static final int VCARD_TYPE_V21_EUROPE =(VERSION_21 | NAME_ORDER_EUROPE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V21_EUROPE_STR = "v21_europe";/*** <p>* General vCard format with the version 3.0 with some Europe convension. Uses UTF-8.* </p>* <p>* Not ready yet. Use with caution when you use this.* </p>*/public static final int VCARD_TYPE_V30_EUROPE =(VERSION_30 | NAME_ORDER_EUROPE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V30_EUROPE_STR = "v30_europe";/*** <p>* The vCard 2.1 format for miscellaneous Japanese devices, using UTF-8 as default charset.* </p>* <p>* Not ready yet. Use with caution when you use this.* </p>*/public static final int VCARD_TYPE_V21_JAPANESE =(VERSION_21 | NAME_ORDER_JAPANESE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V21_JAPANESE_STR = "v21_japanese_utf8";/*** <p>* The vCard 3.0 format for miscellaneous Japanese devices, using UTF-8 as default charset.* </p>* <p>* Not ready yet. Use with caution when you use this.* </p>*/public static final int VCARD_TYPE_V30_JAPANESE =(VERSION_30 | NAME_ORDER_JAPANESE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY);/* package */ static final String VCARD_TYPE_V30_JAPANESE_STR = "v30_japanese_utf8";/*** <p>* The vCard 2.1 based format which (partially) considers the convention in Japanese* mobile phones, where phonetic names are translated to half-width katakana if* possible, etc. It would be better to use Shift_JIS as a charset for maximum* compatibility.* </p>* @hide Should not be available world wide.*/public static final int VCARD_TYPE_V21_JAPANESE_MOBILE =(VERSION_21 | NAME_ORDER_JAPANESE |FLAG_CONVERT_PHONETIC_NAME_STRINGS | FLAG_REFRAIN_QP_TO_NAME_PROPERTIES);/* package */ static final String VCARD_TYPE_V21_JAPANESE_MOBILE_STR = "v21_japanese_mobile";/*** <p>* The vCard format used in DoCoMo, which is one of Japanese mobile phone careers.* </p>* <p>* Base version is vCard 2.1, but the data has several DoCoMo-specific convensions.* No Android-specific property nor defact property is included. The "Primary" properties* are NOT encoded to Quoted-Printable.* </p>* @hide Should not be available world wide.*/public static final int VCARD_TYPE_DOCOMO =(VCARD_TYPE_V21_JAPANESE_MOBILE | FLAG_DOCOMO);/* package */ static final String VCARD_TYPE_DOCOMO_STR = "docomo";public static int VCARD_TYPE_DEFAULT = VCARD_TYPE_V21_GENERIC;
1. vCard 配置标志表
标志名称 | 十六进制值 | 使用场景 | 设计原因 |
---|---|---|---|
FLAG_USE_ANDROID_PROPERTY | 0x80000000 | Android特有数据导出 | vCard规范缺少某些字段时,使用"X-"属性保存Android特有数据(如昵称) |
FLAG_USE_DEFACT_PROPERTY | 0x40000000 | 行业事实标准属性 | 支持非Android设备使用的"X-"属性(如X-PHONETIC-NAME),提高兼容性 |
FLAG_DOCOMO | 0x20000000 | 日本DoCoMo运营商 | 处理日本DoCoMo移动运营商的vCard方言和特殊约定 |
FLAG_REFRAIN_QP_TO_NAME_PROPERTIES | 0x10000000 | 兼容性导出 | 某些导入器无法处理姓名属性的QP编码,避免在姓名相关属性使用Quoted-Printable |
FLAG_CONVERT_PHONETIC_NAME_STRINGS | 0x08000000 | 日语环境兼容 | 将拼音名字段转换为半角片假名,兼容日本手机的历史限制 |
FLAG_APPEND_TYPE_PARAM | 0x04000000 | 类型参数显式化 | 在vCard 2.1中显式添加"TYPE="前缀,提高解析器兼容性 |
FLAG_REFRAIN_PHONE_NUMBER_FORMATTING | 0x02000000 | 电话号码原始格式 | 禁用电话号码自动格式化,保留原始字符(如控制字符) |
FLAG_REFRAIN_IMAGE_EXPORT | 0x00800000 | 排除图片数据 | 导出时不包含图片,减少文件大小或隐私考虑 |
FLAG_REFRAIN_EVENTS_EXPORT | 0x00400000 | 排除事件数据 | 导出时不包含生日、纪念日等事件信息 |
FLAG_REFRAIN_ADDRESS_EXPORT | 0x00200000 | 排除地址数据 | 导出时不包含地址信息 |
FLAG_REFRAIN_EMAIL_EXPORT | 0x00100000 | 排除邮箱数据 | 导出时不包含电子邮件地址 |
FLAG_REFRAIN_ORGANIZATION_EXPORT | 0x00080000 | 排除组织数据 | 导出时不包含公司、职位信息 |
FLAG_REFRAIN_NOTES_EXPORT | 0x00040000 | 排除备注数据 | 导出时不包含备注信息 |
FLAG_REFRAIN_PHONETIC_NAME_EXPORT | 0x00020000 | 排除拼音名数据 | 导出时不包含拼音名字段 |
FLAG_REFRAIN_WEBSITES_EXPORT | 0x00010000 | 排除网站数据 | 导出时不包含网址信息 |
FLAG_REFRAIN_NICKNAME_EXPORT | 0x00008000 | 排除昵称数据 | 导出时不包含昵称信息 |
2. vCard 类型预设表
类型名称 | 版本 | 姓名顺序 | 包含的标志 | 使用场景 |
---|---|---|---|---|
VCARD_TYPE_V21_GENERIC | 2.1 | 默认顺序 | DEFACT+ANDROID | 通用vCard 2.1格式,使用UTF-8,美式姓名顺序 |
VCARD_TYPE_V30_GENERIC | 3.0 | 默认顺序 | DEFACT+ANDROID | 通用vCard 3.0格式,使用UTF-8 |
VCARD_TYPE_V40_GENERIC | 4.0 | 默认顺序 | DEFACT+ANDROID | 通用vCard 4.0格式(实验性) |
VCARD_TYPE_V21_EUROPE | 2.1 | 欧洲顺序 | DEFACT+ANDROID | 欧洲习惯的vCard 2.1,姓名顺序不同 |
VCARD_TYPE_V30_EUROPE | 3.0 | 欧洲顺序 | DEFACT+ANDROID | 欧洲习惯的vCard 3.0 |
VCARD_TYPE_V21_JAPANESE | 2.1 | 日本顺序 | DEFACT+ANDROID | 日本设备的vCard 2.1,UTF-8编码 |
VCARD_TYPE_V30_JAPANESE | 3.0 | 日本顺序 | DEFACT+ANDROID | 日本设备的vCard 3.0,UTF-8编码 |
VCARD_TYPE_V21_JAPANESE_MOBILE | 2.1 | 日本顺序 | 拼音转换+避免QP | 日本手机专用,半角片假名,避免姓名QP编码 |
VCARD_TYPE_DOCOMO | 2.1 | 日本顺序 | 所有日本相关标志 | 日本DoCoMo运营商特有格式 |
3. 设计理念分析
1. 分层设计策略
// 基础版本 + 地区习惯 + 兼容性标志
VCARD_TYPE_V21_JAPANESE_MOBILE = VERSION_21 | NAME_ORDER_JAPANESE | 拼音转换标志 | 避免QP标志
2. 兼容性优先原则
-
向前兼容:支持旧设备和软件的"X-"属性
-
地区兼容:为不同地区(日本、欧洲)提供特定配置
-
规范兼容:在严格遵循规范和实际兼容性间平衡
3. 模块化标志设计
// 通过位运算组合不同功能
int customType = VCARD_TYPE_V21_GENERIC | FLAG_REFRAIN_IMAGE_EXPORT | FLAG_REFRAIN_NOTES_EXPORT;
4. 设计差异原因
差异点 | 设计原因 |
---|---|
日本专用类型较多 | 日本市场有特殊的字符编码需求和手机历史遗留问题 |
vCard 2.1支持更全面 | 2.1版本在实际应用中更广泛,兼容性问题更多 |
ANDROID/DEFACT标志分离 | Android特有属性与行业事实标准属性需要分别控制 |
导出排除标志细化 | 满足不同隐私和数据管理需求 |
5. 实际应用场景
// 场景1:向日本手机导出联系人
VCARD_TYPE_V21_JAPANESE_MOBILE // 使用半角片假名,避免QP编码// 场景2:向PC软件导出,要求规范兼容
VCARD_TYPE_V30_GENERIC // 严格遵循vCard 3.0规范// 场景3:隐私保护导出
VCARD_TYPE_V21_GENERIC | FLAG_REFRAIN_IMAGE_EXPORT | FLAG_REFRAIN_ADDRESS_EXPORT
这种设计提供了极大的灵活性,既能满足国际标准,又能处理各种现实世界中的兼容性问题。