语音助手 Tap-to-Read

Android Automotive 认为语音是安全驾驶互动的重要组成部分,也是用户在驾驶时与 Android Automotive 操作系统互动最安全的方式之一。因此,我们扩展了 Android 语音助理 API(包括 VoiceInteractionSession),以使语音助理能够为用户执行在驾驶时难以完成的任务。

当用户与消息通知互动时,Tap-to-Read 使语音助理能够代表用户朗读和回复短信。要提供此功能,您可以将语音助理与 CarVoiceInteractionSession 集成。

在 Automotive 中,发布到通知中心的通知(标识为 INBOXINBOX_IN_GROUP)(例如,短信)包含一个播放按钮。用户可以点击播放以让选定的语音助理朗读通知,并可以选择通过语音回复。

Tap-to-read notification

图 1. 带有“播放”按钮的 Tap-to-Read 通知。

与 CarVoiceInteractionSession 集成

以下部分介绍如何将语音助理与 CarVoiceInteractionSession 集成。

支持语音互动

提供车载语音互动服务的应用必须与现有的 Android 语音互动集成。要了解详情,请参阅 适用于 Android 的 Google 助理VoiceInteractionSession 除外)。虽然所有语音互动 API 元素与在移动设备上实现的元素保持一致,但 CarVoiceInteractionSession(在实现 CarVoiceInteractionSession 中介绍)取代了 VoiceInteractionSession。如需了解详情,请参阅以下页面:

实现 CarVoiceInteractionSession

CarVoiceInteractionSession 公开了可用于让语音助理朗读短信,然后代表用户回复这些短信的 API。

CarVoiceInteractionSessionVoiceInteractionSession 类之间的主要区别在于,CarVoiceInteractionSessiononShow 中传入操作,以便语音助理可以在 CarVoiceInteractionSession 启动会话后立即检测到用户请求的上下文。onShow 的每个类的参数在下表中列出:

CarVoiceInteractionSession VoiceInteractionSession
onShow 采用以下三个参数:
  • args
  • showFlags
  • actions
onShow 采用以下两个参数:
  • args
  • showFlags

Android 10 中的更改

从 Android 10 开始,平台调用 VoiceInteractionService.onGetSupportedVoiceActions 以检测支持哪些操作。语音助理会替换和实现 VoiceInteractionService.onGetSupportedVoiceActions,如下例所示:

public class MyInteractionService extends VoiceInteractionService {
    private static final List SUPPORTED_VOICE_ACTIONS = Arrays.asList(
        CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION);

    @Override
    public Set onGetSupportedVoiceActions(@NonNull Set voiceActions) {
       Set result = new HashSet<>(voiceActions);
       result.retainAll(SUPPORTED_VOICE_ACTIONS);
       return result;
   }
}

有效操作在下表中描述。有关每个操作的详细信息,请参阅时序图

操作 预期载荷 预期语音互动操作
VOICE_ACTION_READ_NOTIFICATION 向用户朗读消息,然后在消息成功读取后触发“标记为已读”待处理 intent。可以选择提示用户回复。
VOICE_ACTION_REPLY_NOTIFICATION 带有键的 Parcelable。
KEY_NOTIFICATION,映射到 StatusBarNotification
需要 android.permission.BIND_NOTIFICATION_LISTENER_SERVICE
提示用户说出回复消息,将回复消息输入到待处理 intent 的 RemoteInputReply 中,然后触发待处理 intent。
VOICE_ACTION_HANDLE_EXCEPTION 带有键的字符串。
KEY_EXCEPTION,映射到 ExceptionValue(在异常值中介绍)。
KEY_FALLBACK_ASSISTANT_ENABLED,映射到布尔值。如果值为 true,则可以处理用户请求的备用助理已被停用。
异常的预期操作在异常的文档中定义。

异常值

EXCEPTION_NOTIFICATION_LISTENER_PERMISSIONS_MISSING 向语音助理指示它缺少 Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE 权限,并从用户处获取此权限。

请求通知侦听器权限

如果默认语音助理没有通知侦听器权限,则平台的 FallbackAssistant(如果由汽车制造商启用)可能会在通知语音助理请求权限之前朗读消息。要确定 FallbackAssistant 是否已启用并已读取消息,语音助理应检查载荷中的 KEY_FALLBACK_ASSISTANT_ENABLED 布尔值。

平台建议语音助理为此权限的请求次数添加速率限制逻辑。这样做是为了尊重不想授予语音助理此权限并希望 FallbackAssistant 朗读短信的用户。每次用户在消息通知上按播放时都提示用户授予权限可能会带来负面的用户体验。平台不会代表语音助理施加速率限制。

在请求通知侦听器权限时,语音助理应使用 CarUxRestrictionsManager 来确定用户是已停车还是正在驾驶。如果用户正在驾驶,则语音助理会显示一个通知,其中提供有关如何授予权限的说明。这样做有助于(并提醒)用户在更安全时授予权限。

使用 StatusBarNotification

使用“读取”和“回复”语音操作传入的 StatusBarNotification 始终是汽车兼容的消息通知,如 通知用户消息中所述。虽然某些通知可能没有“回复待处理 intent”,但它们都具有“标记为已读待处理 intent”。

为了简化与通知的互动,请使用 NotificationPayloadHandler,它提供从通知中提取消息以及将回复消息写入通知的相应待处理 intent 的方法。在语音助理读取消息后,语音助理必须触发“标记为已读” intent。

满足 Tap-to-Read 前提条件

仅当用户触发语音操作以读取和回复消息时,才会通知默认语音助理的 VoiceInteractionSession。如上所述,此默认语音助理还必须具有通知侦听器权限。

时序图

下图显示了 CarVoiceInteractionSession 操作的逻辑流程:

VOICE_ACTION_READ_NOTIFICATION

图 2. VOICE_ACTION_READ_NOTIFICATION 的时序图。

在图 3 的情况下,建议应用速率限制权限请求

VOICE_ACTION_REPLY_NOTIFICATION

图 3. VOICE_ACTION_REPLY_NOTIFICATION 的时序图。

VOICE_ACTION_HANDLE_EXCEPTION

图 4. VOICE_ACTION_HANDLE_EXCEPTION 的时序图。

读取应用名称

如果您希望语音助理在消息朗读期间朗读消息应用的名称(例如,“Hangouts 的 Sam 说...”),请创建一个类似于以下代码示例中所示的函数,以确保助理读取的名称正确:

@Nullable
String getMessageApplicationName(Context context, StatusBarNotification statusBarNotification) {
    ApplicationInfo info = getApplicationInfo(context, statusBarNotification.getPackageName());
    if (info == null) return null;

    Notification notification = statusBarNotification.getNotification();

    // Sometimes system packages will post on behalf of other apps, so check this
    // field for a system app notification.
    if (isSystemApp(info)
            && notification.extras.containsKey(Notification.EXTRA_SUBSTITUTE_APP_NAME)) {
        return notification.extras.getString(Notification.EXTRA_SUBSTITUTE_APP_NAME);
    } else {
        PackageManager pm = context.getPackageManager();
        return String.valueOf(pm.getApplicationLabel(info));
    }
}

@Nullable
ApplicationInfo getApplicationInfo(Context context, String packageName) {
    final PackageManager pm = context.getPackageManager();
    ApplicationInfo info;
    try {
        info = pm.getApplicationInfo(packageName, 0);
    } catch (PackageManager.NameNotFoundException e) {
        return null;
    }
    return info;
}

boolean isSystemApp(ApplicationInfo info) {
    return (info.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
}