面向 Android Auto 开发即时通讯应用

对许多驾驶员而言,通过信息保持联络非常重要。聊天应用可让用户知道是否需要接孩子,或者晚餐地点是否已更改。Android 框架可让即时通讯应用使用标准界面将其服务扩展到驾驶体验中,让驾驶员关注于路况。

支持即时通讯的应用可以扩展其即时通讯通知,使 Android Auto 在运行时能够使用它们。这些通知显示在 Auto 中,让用户能够在一致且不易导致分心的界面中阅读和回复信息。此外,在使用 MessagingStyle API 时,您还可以从适用于所有 Android 设备(包括 Android Auto)的优化信息通知中获益。此类优化包括专用于信息通知的界面、改进的动画,以及对内嵌图片的支持。

本课程假设您构建了一个向用户显示信息和接收用户回复的应用,例如聊天应用。本课程将为您演示如何扩展应用,将这些信息移交给 Auto 设备进行显示和回复。

开始

要让您的应用为 Auto 设备提供即时通讯服务,该应用必须能够执行以下操作:

  1. 构建并发送 NotificationCompat.MessagingStyle 对象,这些对象包含回复和标记为已读 Action 对象。
  2. 通过 Service 处理回复对话和将对话标记为已读。
  3. 配置清单,表明该应用支持 Android Auto。

概念和对象

在开始设计应用之前,先了解 Android Auto 如何处理即时通讯会很有用。

一个通信块称为“一条消息”,由 MessagingStyle.Message 类表示。消息包含发送者、消息内容以及消息发送时间。

用户之间的通信称为“对话”,由 MessagingStyle 对象表示。对话(或 MessagingStyle)包含标题、若干消息,以及这是否是群组对话(即对话具有多个其他接收者)。

为了向用户通知对话更新(例如,有新消息),应用会向 Android 系统发布 Notification。此 Notification 使用 MessagingStyle 对象在通知栏中显示特定于即时通讯的界面。Android 平台还会将此 Notification 传递给 Android Auto,然后系统提取 MessagingStyle 并用它通过汽车的显示屏发布通知。

应用还可以向 Notification 添加 Action 对象,使用户能够直接从通知栏中快速回复信息或标记为已读。Android Auto 需要使用标记为已读和回复 Action 对象,以便管理对话。

总而言之,单个对话由一个 Notification 对象表示,该对象使用一个 MessagingStyle 对象设置样式。MessagingStyle 包含对话中的所有消息,它含有一个或多个 MessagingStyle.Message 对象。最后,为了全面符合 Android Auto 规范,您必须将回复和标记为已读 Action 附加到 Notification

即时通讯流

本部分介绍您的应用与 Android Auto 之间的典型即时通讯流。

  1. 应用收到一条消息。
  2. 接着,应用生成包含回复和标记为已读 ActionMessagingStyle 通知。
  3. Android Auto 从 Android 系统接收“新通知”事件,并查找 MessagingStyle、回复 Action 和标记为已读 Action
  4. Android Auto 生成并在汽车显示屏中显示通知。
  5. 如果用户通过汽车显示屏点按通知,Android Auto 触发标记为已读 Action
    • 在后台,应用必须处理这个标记为已读事件。
  6. 如果用户通过语音响应通知,Android Auto 会将用户响应转录包含到回复 Action 中,然后触发此操作。
    • 在后台,应用必须处理此回复事件。

初步假设

本页面不会为您提供有关创建整个即时通讯应用的指导。不过,以下代码示例包含您的应用在开始支持使用 Android Auto 进行即时通讯之前应具备的一些功能。

data class YourAppConversation(
            val id: Int,
            val title: String,
            val recipients: MutableList<YourAppUser>,
            val icon: Bitmap) {
        companion object {
            /** Fetches [YourAppConversation] by its [id]. */
            fun getById(id: Int): YourAppConversation = // ...
        }

        /** Replies to this conversation with the given [message]. */
        fun reply(message: String) {}

        /** Marks this conversation as read. */
        fun markAsRead() {}

        /** Retrieves all unread messages from this conversation. */
        fun getUnreadMessages(): List<YourAppMessage> { return /* ... */ }
    }
    data class YourAppUser(val id: Int, val name: String, val icon: Uri)
    data class YourAppMessage(
        val id: Int,
        val sender: YourAppUser,
        val body: String,
        val timeReceived: Long)
    

声明 Android Auto 支持

当 Android Auto 收到来自即时通讯应用的通知时,会检查该应用是否声明了 Android Auto 支持。要启用此支持,请在应用的清单中添加以下条目:

<application>
        ...
        <meta-data
            android:name="com.google.android.gms.car.application"
            android:resource="@xml/automotive_app_desc"/>
        ...
    </application>
    

此清单条目通过以下路径引用您应创建的另一个 XML 文件:YourAppProject/app/src/main/res/xml/automotive_app_desc.xml,您在这个文件中声明应用所支持的 Android Auto 功能。例如,若要包括对通知的支持,请在 automotive_app_desc.xml 中添加以下代码:

<automotiveApp>
        <uses name="notification" />
    </automotiveApp>
    

如果您的应用需要支持处理短信、彩信和 RCS,您还必须添加以下代码:

<automotiveApp>
        ...
        <uses name="sms" />
    </automotiveApp>
    

导入 AndroidX 核心库

构建用于 Auto 设备的通知需要安装 AndroidX 核心库,您可以按如下所示将这个核心库导入项目中:

  1. 在顶级 build.gradle 文件中,确保包含 Google 的 Maven 代码库,如下所示:

    allprojects {
            repositories {
                google()
            }
        }
        
  2. 在应用模块的 build.gradle 文件中,添加 AndroidX 核心库依赖项,如下所示:

    dependencies {
            implementation 'androidx.core:core:1.0.0'
        }
        

处理用户操作

您的即时通讯应用需要一种通过 Action 处理对话更新的方式。对于 Android Auto,您的应用需要处理两种 Action 对象:回复和标记为已读。推荐的方法是使用 IntentService 实现此目的。IntentService 使系统能灵活地“在后台”处理可能十分占用资源的调用,从而释放应用的主线程。

定义 intent 操作

Intent 操作(不要与通知操作混淆)是简单的字符串,用于标识 Intent 的用途。因为一个服务可以处理多种类型的 Intent,因此定义多个 Intent.action 字符串要比定义多个 IntentServices 容易。

在示例即时通讯应用中,我们有两种类型的操作:回复和标记为已读,具体参见以下代码示例中的声明。

private const val ACTION_REPLY = "com.example.REPLY"
    private const val ACTION_MARK_AS_READ = "com.example.MARK_AS_READ"
    

创建服务

要创建处理这些 Action 的服务,您需要对话 ID 和远程输入键。对话 ID 是由您的应用定义的任意数据结构,用来标识对话;远程输入键将在本节后面详细讨论。以下代码示例创建的就是这样一个服务。

private const val EXTRA_CONVERSATION_ID_KEY = "conversation_id"
    private const val REMOTE_INPUT_RESULT_KEY = "reply_input"

    /**
     * An [IntentService] that handles reply and mark-as-read actions for
     * [YourAppConversation]s.
     */
    class MessagingService : IntentService("MessagingService") {
        override fun onHandleIntent(intent: Intent?) {
            // Fetch our internal data
            val conversationId = intent!!.getIntExtra(EXTRA_CONVERSATION_ID_KEY, -1)

            // And search our database for that conversation
            val conversation = YourAppConversation.getById(conversationId)

            // Then handle the action that was requested in the intent. The TODOs
            // are addressed in a section below.
            when (intent.action) {
                ACTION_REPLY -> TODO()
                ACTION_MARK_AS_READ -> TODO()
            }
        }
    }
    

要将此服务与您的应用关联,您还需要在应用的清单中注册该服务,如下所示。

<application>
        <service android:name="com.example.MessagingService" />
        ...
    </application>
    

生成和处理 Intent

其他应用无法获取触发 MessagingServiceIntent,因为每个 Intent 都是通过 PendingIntent 传递给外部应用的。由于存在此限制,因此您需要创建 RemoteInput 对象以允许其他应用将“回复”文本提供给您的应用,如下所示。

/**
     * Creates a [RemoteInput] which allows remote apps to provide a response string
     * to the underlying [Intent] within a [PendingIntent].
     */
    fun createReplyRemoteInput(context: Context): RemoteInput {
        // RemoteInput.Builder accepts a single parameter: the key to use to store
        // the response in.
        return RemoteInput.Builder(REMOTE_INPUT_RESULT_KEY).build()
        // Note that the RemoteInput has no knowledge of the conversation. This is
        // because the data for the RemoteInput is bound to the reply Intent via
        // static methods in the RemoteInput class.
    }

    /** Creates an [Intent] which handles replying to the given [appConversation]. */
    fun createReplyIntent(
            context: Context, appConversation: YourAppConversation): Intent {
        // Create the intent backed by the MessagingService.
        val intent = Intent(context, MessagingService::class.java)

        // This action string lets the MessagingService know this is a "reply" request.
        intent.action = ACTION_REPLY

        // Provide the conversation id so we know what conversation this applies to.
        intent.putExtra(EXTRA_CONVERSATION_ID_KEY, appConversation.id)

        return intent
    }
    

现在您已了解回复 Intent 中应包含的内容,接下来在 MessagingService 中处理 ACTION_REPLY switch 子句的 TODO 并提取该信息,如下所示:

ACTION_REPLY -> {
        // Extract reply response from the intent using the same key that the
        // above RemoteInput used.
        val results: Bundle = RemoteInput.getResultsFromIntent(intent)
        val message = results.getString(REMOTE_INPUT_RESULT_KEY)

        // Note this conversation object came from above in the MessagingService
        conversation.reply(message)
    }
    

标记为已读 Intent 采用类似的处理方式,但不需要 RemoteInput

/** Creates an [Intent] which handles marking the [appConversation] as read. */
    fun createMarkAsReadIntent(
            context: Context, appConversation: YourAppConversation): Intent {
        val intent = Intent(context, MessagingService::class.java)
        intent.action = ACTION_MARK_AS_READ
        intent.putExtra(EXTRA_CONVERSATION_ID_KEY, appConversation.id)
        return intent
    }
    

以下代码示例在 MessagingService 中处理了 ACTION_MARK_AS_READ switch 子句的 TODO

// Marking as read has no other logic.
    ACTION_MARK_AS_READ -> conversation.markAsRead()
    

向用户提供消息通知

现在已完成即时通讯应用对话操作处理,接下来生成符合 Android Auto 规范的通知。

创建操作

Action 是可通过 Notification 传递到其他应用以在原始应用中触发方法的对象。这是 Android Auto 将对话标记为已读并进行回复的方式。

要创建 Action,请从 Intent 开始,如以下“回复”Intent 所示:

fun createReplyAction(
            context: Context, appConversation: YourAppConversation): Action {
        val replyIntent: Intent = createReplyIntent(context, appConversation)
        // ...
    

然后,我们将此 Intent 封装在 PendingIntent 中,以便为外部应用使用做好准备。PendingIntent 限制对已封装 Intent 的所有访问,仅公开一组选定的方法,允许接收方应用触发 Intent 或获取源应用软件包名称;而绝不允许外部应用访问底层的 Intent 或其中的数据。

    // ...
        val replyPendingIntent = PendingIntent.getService(
            context,
            createReplyId(appConversation), // Method explained later.
            replyIntent,
            PendingIntent.FLAG_UPDATE_CURRENT)
        // ...
    

在设置回复 Action 时,请注意 Android Auto 对回复 Action 有三项要求:

  • 语义操作必须设置为 Action.SEMANTIC_ACTION_REPLY
  • Action 必须指明在触发时不会显示任何界面。
  • Action 必须包含单个 RemoteInput

以下代码示例会在满足上述要求的同时设置回复 Action

    // ...
        val replyAction = Action.Builder(R.drawable.reply, "Reply", replyPendingIntent)
            // Provide context to what firing Action does.
            .setSemanticAction(Action.SEMANTIC_ACTION_REPLY)

            // The action doesn't show any UI (it's a requirement for apps to
            // not show UI for Android Auto).
            .setShowsUserInterface(false)

            // Don't forget the reply RemoteInput. Android Auto will use this to
            // make a system call that will add the response string into
            // the reply intent so it can be extracted by the messaging app.
            .addRemoteInput(createReplyRemoteInput(context))
            .build()

        return replyAction
    }
    

在标记为已读方面,我们采用相同的处理方式,只是没有 RemoteInput。因此,Android Auto 对标记为已读 Action 有两项要求:

  • 语义操作设置为 Action.SEMANTIC_ACTION_MARK_AS_READ
  • 该操作指明在触发时不会显示任何界面。
fun createMarkAsReadAction(
            context: Context, appConversation: YourAppConversation): Action {
        val markAsReadIntent = createMarkAsReadIntent(context, appConversation)
        val markAsReadPendingIntent = PendingIntent.getService(
                context,
                createMarkAsReadId(appConversation), // Method explained below.
                markAsReadIntent,
                PendingIntent.FLAG_UPDATE_CURRENT)
        val markAsReadAction = Action.Builder(
                R.drawable.mark_as_read, "Mark as Read", markAsReadPendingIntent)
            .setSemanticAction(Action.SEMANTIC_ACTION_MARK_AS_READ)
            .setShowsUserInterface(false)
            .build()
        return markAsReadAction
    }
    

创建 MessagingStyle

MessagingStyle 是即时通讯信息的载体,供 Android Auto 朗读对话中的每条消息。首先,必须以 Person 对象的形式指定设备的用户。

fun createMessagingStyle(
            context: Context, appConversation: YourAppConversation): MessagingStyle {
        // Method defined by our messaging app.
        val appDeviceUser: YourAppUser = getAppDeviceUser()

        val devicePerson = Person.Builder()
            // The display name (also the name that's read aloud in Android auto).
            .setName(appDeviceUser.name)

            // The icon to show in the notification shade in the System UI (outside
            // of Android Auto).
            .setIcon(appDeviceUser.icon)

            // A unique key in case there are multiple people in this conversation with
            // the same name.
            .setKey(appDeviceUser.id)
            .build()
        // ...
    

然后,您可以构建 MessagingStyle 对象并提供关于对话的一些详细信息。

    // ...
        val messagingStyle = MessagingStyle(devicePerson)

        // Set the conversation title. Note that if your app's target version is lower
        // than P, this will automatically mark this conversation as a group (to
        // maintain backwards compatibility). Simply use #setGroupConversation after
        // setting the conversation title to explicitly override this behavior. See
        // the documentation for more information.
        messagingStyle.setConversationTitle(appConversation.title)

        // Group conversation means there is more than 1 recipient, so set it as such.
        messagingStyle.setGroupConversation(appConversation.recipients.size > 1)
        // ...
    

最后,添加未读消息。

    // ...
        for (appMessage in appConversation.getUnreadMessages()) {
            // The sender is also represented via a Person object
            val senderPerson = Person.Builder()
                .setName(appMessage.sender.name)
                .setIcon(appMessage.sender.icon)
                .setKey(appMessage.sender.id)
                .build()

            // And the message is added. Note that more complex messages (ie. images)
            // may be created and added by instantiating the MessagingStyle.Message
            // class directly. See documentation for details.
            messagingStyle.addMessage(
                    appMessage.body, appMessage.timeReceived, senderPerson)
        }

        return messagingStyle
    }
    

打包和推送通知

生成 ActionMessagingStyle 对象后,您现在可以构建并发布 Notification

fun notify(context: Context, appConversation: YourAppConversation) {
        // Create our Actions and MessagingStyle from the methods defined above.
        val replyAction = createReplyAction(context, appConversation)
        val markAsReadAction = createMarkAsReadAction(context, appConversation)
        val messagingStyle = createMessagingStyle(context, appConversation)

        // Create the notification.
        val notification = NotificationCompat.Builder(context, channel)
            // A required field for the Android UI.
            .setSmallIcon(R.drawable.notification_icon)

            // Shown in Android Auto as the conversation image.
            .setLargeIcon(appConversation.icon)

            // Add MessagingStyle.
            .setStyle(messagingStyle)

            // Add reply action.
            .addAction(replyAction)

            // Let's say we don't want to show our mark-as-read Action in the
            // notification shade. We can do that by adding it as invisible so it
            // won't appear in Android UI, but still satisfy Android Auto's
            // mark-as-read Action requirement. You're free to add both Actions as
            // visible or invisible. This is just a stylistic choice.
            .addInvisibleAction(markAsReadAction)

            .build()

        // Post the notification for the user to see.
        val notificationManagerCompat = NotificationManagerCompat.from(context)
        notificationManagerCompat.notify(appConversation.id, notification)
    }
    

另请参阅