diff options
author | Rakesh Iyer <rni@google.com> | 2016-10-19 23:53:15 -0700 |
---|---|---|
committer | Rakesh Iyer <rni@google.com> | 2016-10-20 00:27:29 -0700 |
commit | 214c10ceef4ba736d8a7b3cbef06c27826822946 (patch) | |
tree | e77f8cca27a528e62e270e2ba491b330949a9f9a | |
parent | 5ff2120e0fb1f7f38cfe4209f9864ed3c9b1bc6b (diff) | |
download | platform_packages_apps_Car_Stream-214c10ceef4ba736d8a7b3cbef06c27826822946.tar.gz platform_packages_apps_Car_Stream-214c10ceef4ba736d8a7b3cbef06c27826822946.tar.bz2 platform_packages_apps_Car_Stream-214c10ceef4ba736d8a7b3cbef06c27826822946.zip |
Move stream.
Original sha1: f802a6f645c66e914ecfe2c1fd06e4dd1aadc6ef
Credits:
victorchan@
Bug: 32118797
Test: Manual.
Change-Id: I18d5e2a239947b0d6390598bb6a48d8c69cc2d3a
40 files changed, 3862 insertions, 0 deletions
diff --git a/Android.mk b/Android.mk new file mode 100644 index 0000000..1da2310 --- /dev/null +++ b/Android.mk @@ -0,0 +1,65 @@ +# +# Copyright (C) 2016 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res + +include packages/apps/Car/libs/car-stream-ui-lib/car-stream-ui-lib.mk +include packages/apps/Car/libs/car-apps-common/car-apps-common.mk + +include packages/services/Car/car-support-lib/car-support.mk + +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v4 +LOCAL_STATIC_JAVA_LIBRARIES += car-stream-lib + +LOCAL_AAPT_FLAGS += \ + --auto-add-overlay \ + +LOCAL_AAPT_FLAGS += --extra-packages com.android.car.radio.service +LOCAL_STATIC_JAVA_LIBRARIES += car-radio-service + +LOCAL_PACKAGE_NAME := Stream + +LOCAL_MODULE_TAGS := optional + +#TODO: determine if this service should be a privileged module. +LOCAL_PRIVILEGED_MODULE := true + +LOCAL_PROGUARD_ENABLED := disabled + +LOCAL_DEX_PREOPT := false + +# Include support-v7-cardview, if not already included +ifeq (,$(findstring android-support-v7-cardview,$(LOCAL_STATIC_JAVA_LIBRARIES))) +LOCAL_RESOURCE_DIR += frameworks/support/v7/cardview/res +LOCAL_AAPT_FLAGS += --extra-packages android.support.v7.cardview +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v7-cardview +endif + +# Include support-v7-palette, if not already included +ifeq (,$(findstring android-support-v7-palette,$(LOCAL_STATIC_JAVA_LIBRARIES))) +LOCAL_AAPT_FLAGS += --extra-packages android.support.v7.palette +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v7-palette +endif + +# Include android-support-annotations, if not already included +ifeq (,$(findstring android-support-annotations,$(LOCAL_STATIC_JAVA_LIBRARIES))) +LOCAL_STATIC_JAVA_LIBRARIES += android-support-annotations +endif + +include $(BUILD_PACKAGE) diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..8bc0317 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2016 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.car.stream"> + <uses-sdk + android:minSdkVersion="23" + android:targetSdkVersion='23'/> + <uses-permission android:name="android.permission.READ_PHONE_STATE"/> + <uses-permission android:name="android.permission.READ_CONTACTS" /> + <uses-permission android:name="android.permission.WRITE_CONTACTS" /> + <uses-permission android:name="android.permission.CALL_PHONE" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> + <uses-permission android:name="android.permission.READ_CALL_LOG"/> + <uses-permission android:name="android.permission.WAKE_LOCK"/> + <uses-permission android:name="android.permission.DISABLE_KEYGUARD" /> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECEIVE_SMS" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <uses-permission android:name="com.google.android.car.LAUNCH_PROJECTION_APP" /> + <uses-permission android:name="android.permission.GET_ACCOUNTS"/> + <uses-permission android:name="android.permission.CONTROL_INCALL_EXPERIENCE"/> + <uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL"/> + <uses-permission android:name="android.permission.MANAGE_USERS" /> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + + <application android:label="CarStreamService" + android:name="com.android.car.stream.StreamApplication" + android:persistent="true"> + <activity android:name="com.android.car.stream.PermissionsActivity" + android:theme="@android:style/Theme.NoTitleBar" + android:resizeableActivity="true" + android:launchMode="singleTask" + android:label="StreamPermissionActivity"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </activity> + <service + android:name="com.android.car.stream.StreamService" + android:exported="true"> + <intent-filter> + <action android:name="stream.service"/> + </intent-filter> + </service> + + <service android:name="com.android.car.stream.notifications.StreamNotificationListenerService" + android:label="Stream Notification Listener" + android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"> + <intent-filter> + <action android:name="android.service.notification.NotificationListenerService" /> + </intent-filter> + </service> + + <service android:name="com.android.car.stream.telecom.StreamInCallService" + android:permission="android.permission.BIND_INCALL_SERVICE" + android:exported="true"> + <meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="false" /> + <intent-filter> + <action android:name="android.telecom.InCallService"/> + </intent-filter> + </service> + </application> +</manifest> diff --git a/res/drawable/ic_call_black.xml b/res/drawable/ic_call_black.xml new file mode 100644 index 0000000..4a34968 --- /dev/null +++ b/res/drawable/ic_call_black.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:pathData="M0 0h48v48H0z" /> + <path + android:fillColor="#000000" + android:pathData="M13.25 21.59c2.88 5.66 7.51 10.29 13.18 13.17l4.4-4.41c.55-.55 1.34-.71 +2.03-.49C35.1 30.6 37.51 31 40 31c1.11 0 2 .89 2 2v7c0 1.11-.89 2-2 2C21.22 42 6 +26.78 6 8c0-1.11 .9 -2 2-2h7c1.11 0 2 .89 2 2 0 2.49 .4 4.9 1.14 7.14 .22 .69 +.06 1.48-.49 2.03l-4.4 4.42z" /> +</vector> diff --git a/res/drawable/ic_call_missed.xml b/res/drawable/ic_call_missed.xml new file mode 100644 index 0000000..b2e065e --- /dev/null +++ b/res/drawable/ic_call_missed.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + + <path + android:pathData="M0 0h24v24H0z" /> + <path + android:fillColor="@color/car_red_500" + android:pathData="M19.59 7L12 14.59 6.41 9H11V7H3v8h2v-4.59l7 7 9-9z" /> +</vector> diff --git a/res/drawable/ic_mic.xml b/res/drawable/ic_mic.xml new file mode 100644 index 0000000..12e451a --- /dev/null +++ b/res/drawable/ic_mic.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:fillColor="#FFFFFF" + android:pathData="M24 28c3.31 0 5.98-2.69 5.98-6L30 10c0-3.32-2.68-6-6-6-3.31 0-6 2.68-6 6v12c0 +3.31 2.69 6 6 6zm10.6-6c0 6-5.07 10.2-10.6 10.2-5.52 0-10.6-4.2-10.6-10.2H10c0 +6.83 5.44 12.47 12 13.44V42h4v-6.56c6.56-.97 12-6.61 12-13.44h-3.4z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_mic_muted.xml b/res/drawable/ic_mic_muted.xml new file mode 100644 index 0000000..b46a00a --- /dev/null +++ b/res/drawable/ic_mic_muted.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:pathData="M0 0h48v48H0zm0 0h48v48H0z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M38 22h-3.4c0 1.49-.31 2.87-.87 4.1l2.46 2.46C37.33 26.61 38 24.38 38 22zm-8.03 +.33 c0-.11 .03 -.22 .03 -.33V10c0-3.32-2.69-6-6-6s-6 2.68-6 6v.37l11.97 +11.96zM8.55 6L6 8.55l12.02 12.02v1.44c0 3.31 2.67 6 5.98 6 .45 0 .88-.06 +1.3-.15l3.32 3.32c-1.43 .66 -3 1.03-4.62 1.03-5.52 0-10.6-4.2-10.6-10.2H10c0 +6.83 5.44 12.47 12 13.44V42h4v-6.56c1.81-.27 3.53-.9 5.08-1.81L39.45 42 42 39.46 +8.55 6z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_pause.xml b/res/drawable/ic_pause.xml new file mode 100644 index 0000000..638e987 --- /dev/null +++ b/res/drawable/ic_pause.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:fillColor="#000000" + android:pathData="M12 38h8V10h-8v28zm16-28v28h8V10h-8z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_pause_light.xml b/res/drawable/ic_pause_light.xml new file mode 100644 index 0000000..11784f3 --- /dev/null +++ b/res/drawable/ic_pause_light.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:fillColor="#FFFFFF" + android:pathData="M12 38h8V10h-8v28zm16-28v28h8V10h-8z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector> diff --git a/res/drawable/ic_phone.xml b/res/drawable/ic_phone.xml new file mode 100644 index 0000000..fe0f093 --- /dev/null +++ b/res/drawable/ic_phone.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1V20c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z" + android:fillColor="#000000"/> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_phone_hangup.xml b/res/drawable/ic_phone_hangup.xml new file mode 100644 index 0000000..7af35f1 --- /dev/null +++ b/res/drawable/ic_phone_hangup.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z" + android:fillColor="#ffffff"/> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_play_arrow.xml b/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..753afe7 --- /dev/null +++ b/res/drawable/ic_play_arrow.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:pathData="M-838-2232H562v3600H-838z" /> + <path + android:fillColor="#000000" + android:pathData="M16 10v28l22-14z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_play_arrow_light.xml b/res/drawable/ic_play_arrow_light.xml new file mode 100644 index 0000000..41ec9ef --- /dev/null +++ b/res/drawable/ic_play_arrow_light.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:pathData="M-838-2232H562v3600H-838z" /> + <path + android:fillColor="#FFFFFF" + android:pathData="M16 10v28l22-14z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector> diff --git a/res/drawable/ic_skip_next.xml b/res/drawable/ic_skip_next.xml new file mode 100644 index 0000000..29334a3 --- /dev/null +++ b/res/drawable/ic_skip_next.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="#FFFFFF" + android:pathData="M12 36l17-12-17-12v24zm20-24v24h4V12h-4z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_skip_previous.xml b/res/drawable/ic_skip_previous.xml new file mode 100644 index 0000000..0a19a6f --- /dev/null +++ b/res/drawable/ic_skip_previous.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="#FFFFFF" + android:pathData="M12 12h4v24h-4zm7 12l17 12V12z" /> + <path + android:pathData="M0 0h48v48H0z" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_stop.xml b/res/drawable/ic_stop.xml new file mode 100644 index 0000000..105e269 --- /dev/null +++ b/res/drawable/ic_stop.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + + <path + android:pathData="M0 0h48v48H0z" /> + <path + android:fillColor="#000000" + android:pathData="M12 12h24v24H12z" /> +</vector>
\ No newline at end of file diff --git a/res/values/colors.xml b/res/values/colors.xml new file mode 100644 index 0000000..415049a --- /dev/null +++ b/res/values/colors.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<resources> + <!-- The main accent color of the radio app. --> + <color name="car_radio_accent_color">#e91e63</color> <!-- Pink 500 --> +</resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml new file mode 100644 index 0000000..936f912 --- /dev/null +++ b/res/values/dimens.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright 2016 Google Inc. All Rights Reserved. --> +<resources> + <dimen name="stream_card_secondary_icon_dimen">96dp</dimen> + <dimen name="stream_media_icon_size">128dp</dimen> +</resources> diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..751fabf --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright 2016 Google Inc. All Rights Reserved. --> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + + <!-- Permission --> + <skip/> + <!-- Generic instruction on how to enable permissions for Android Auto [CHAR LIMIT=200] --> + <string name="permissions_generic">Use your phone to turn on the permissions in\nSettings > Apps > Android Auto > Permissions</string> + + <string name="permission_not_granted">The following permissions where not granted:<xliff:g id="temperature">%1$s</xliff:g></string> + <string name="all_permission_granted">All permissions granted, starting service</string> + <string name="permission_dialog_title">Permissions</string> + <string name="permission_dialog_positive_button_text">Ok</string> + + <!-- Label for a recent call card [CHAR LIMIT=30] --> + <string name="recent_call">Recent call</string> + + <string name="car_notification_permission_dialog_title">Notification access request</string> + <string name="car_notification_permission_dialog_text">Please enable notification access and then hit the home button</string> + + <!-- Telecom Related strings--> + <!-- Label for voicemail [CHAR LIMIT=30] --> + <string name="voicemail">Voicemail</string> + <!-- Label for current phone call [CHAR LIMIT=30] --> + <string name="unknown_number">Current call</string> + <!-- Label for incoming call [CHAR LIMIT=30] --> + <string name="notification_incoming_call">Select to answer</string> + <!-- Label for button to answer a phone call [CHAR LIMIT=30] --> + <string name="answer_call">Answer</string> + <!-- Label for button to reject a phone call [CHAR LIMIT=30] --> + <string name="reject_call">Reject</string> + <!-- Label for when a call is coming from an unknown caller [CHAR LIMIT=30] --> + <string name="unknown">Unknown</string> + <!-- Label for when a call is a conference call [CHAR LIMIT=30] --> + <string name="conference_call">Conference call</string> + <!-- Label for the currently ongoing call [CHAR LIMIT=30] --> + <string name="ongoing_call">Active • </string> + <!-- Label for the currently dialed call [CHAR LIMIT=30] --> + <string name="dialing_call">Dialing</string> + <!-- Label for a call being disconnected [CHAR LIMIT=30] --> + <string name="disconnecting_call">Disconnecting Call</string> + + <!-- Text for the radio application. --> + <string name="radio_app_name">Radio</string> + + <!-- Text to denote the AM radio band. --> + <string name="radio_am_text">AM</string> + + <!-- Text to denote the FM radio band. --> + <string name="radio_fm_text">FM</string> + + <string name="car_media_component_package" translatable="false">com.android.car.media</string> + + <string name="car_radio_component_package" translatable="false">com.android.car.radio</string> + <string name="car_radio_component_service" translatable="false">com.android.car.radio.RadioService</string> + <string name="car_radio_component_activity" translatable="false">com.android.car.radio.CarRadioProxyActivity</string> +</resources> diff --git a/src/com/android/car/stream/PermissionsActivity.java b/src/com/android/car/stream/PermissionsActivity.java new file mode 100644 index 0000000..16e7719 --- /dev/null +++ b/src/com/android/car/stream/PermissionsActivity.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.os.Bundle; +import android.provider.Settings; +import android.util.Log; +import com.android.car.stream.notifications.StreamNotificationListenerService; + +import java.util.ArrayList; +import java.util.List; + +/** + * A trampoline activity that checks if all permissions necessary are granted. + */ +public class PermissionsActivity extends Activity { + private static final String TAG = "PermissionsActivity"; + private static final String NOTIFICATION_LISTENER_ENABLED = "enabled_notification_listeners"; + + public static final int CAR_PERMISSION_REQUEST_CODE = 1013; // choose a unique number + + private static final String[] PERMISSIONS = new String[]{ + android.Manifest.permission.READ_PHONE_STATE, + android.Manifest.permission.CALL_PHONE, + android.Manifest.permission.READ_CALL_LOG, + android.Manifest.permission.READ_CONTACTS, + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.RECEIVE_SMS, + android.Manifest.permission.READ_EXTERNAL_STORAGE + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + boolean permissionsCheckOnly = getIntent().getExtras() + .getBoolean(StreamConstants.STREAM_PERMISSION_CHECK_PERMISSIONS_ONLY); + + if (permissionsCheckOnly) { + boolean allPermissionsGranted = hasNotificationListenerPermission() + && arePermissionGranted(PERMISSIONS); + setResult(allPermissionsGranted ? RESULT_OK : RESULT_CANCELED); + finish(); + return; + } + + if (!hasNotificationListenerPermission()) { + showNotificationListenerSettings(); + } else { + maybeRequestPermissions(); + } + } + + private void maybeRequestPermissions() { + boolean permissionGranted = arePermissionGranted(PERMISSIONS); + if (!permissionGranted) { + requestPermissions(PERMISSIONS, CAR_PERMISSION_REQUEST_CODE); + } else { + startService(new Intent(this, StreamService.class)); + finish(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, + int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == CAR_PERMISSION_REQUEST_CODE) { + List<String> granted = new ArrayList<>(); + List<String> notGranted = new ArrayList<>(); + for (int i = 0; i < permissions.length; i++) { + String permission = permissions[i]; + int grantResult = grantResults[i]; + if (grantResult == PackageManager.PERMISSION_GRANTED) { + granted.add(permission); + } else { + notGranted.add(permission); + } + } + + if (notGranted.size() > 0) { + StringBuilder stb = new StringBuilder(); + for (String s : notGranted) { + stb.append(" ").append(s); + } + showDialog(getString(R.string.permission_not_granted, stb.toString())); + } else { + showDialog(getString(R.string.all_permission_granted)); + startService(new Intent(this, StreamService.class)); + } + + if (arePermissionGranted(PERMISSIONS)) { + setResult(Activity.RESULT_OK); + } + finish(); + } + } + + private void showDialog(String message) { + new AlertDialog.Builder(this /* context */) + .setTitle(getString(R.string.permission_dialog_title)) + .setMessage(message) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton( + getString(R.string.permission_dialog_positive_button_text), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }) + .show(); + } + + private boolean arePermissionGranted(String[] permissions) { + for (String permission : permissions) { + if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "Permission is not granted: " + permission); + return false; + } + } + return true; + } + + private boolean hasNotificationListenerPermission() { + ComponentName notificationListener = new ComponentName(this, + StreamNotificationListenerService.class); + String listeners = Settings.Secure.getString(getContentResolver(), + NOTIFICATION_LISTENER_ENABLED); + return listeners != null && listeners.contains(notificationListener.flattenToString()); + } + + private void showNotificationListenerSettings() { + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(getString(R.string.car_notification_permission_dialog_title)) + .setMessage(getString(R.string.car_notification_permission_dialog_text)) + .setCancelable(false) + .setNeutralButton(getString(android.R.string.ok), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + Intent settingsIntent = + new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); + startActivity(settingsIntent); + } + }) + .create(); + dialog.show(); + } +} diff --git a/src/com/android/car/stream/StreamApplication.java b/src/com/android/car/stream/StreamApplication.java new file mode 100644 index 0000000..e01e2e7 --- /dev/null +++ b/src/com/android/car/stream/StreamApplication.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream; + +import android.app.Application; +import android.content.Intent; +import android.util.Log; +import com.android.car.stream.media.MediaStreamProducer; +import com.android.car.stream.radio.RadioStreamProducer; +import com.android.car.stream.telecom.CurrentCallStreamProducer; +import com.android.car.stream.telecom.RecentCallStreamProducer; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base application for {@link StreamService} + */ +public class StreamApplication extends Application { + private static final String TAG = "StreamApplication"; + private List<StreamProducer> streamProducers; + + @Override + public void onCreate() { + // TODO(victorchan): start and bind stream service, then pass in bound instance to + // producers. + startService(new Intent(this, StreamService.class)); + + super.onCreate(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "stream application started"); + } + streamProducers = new ArrayList<>(); + streamProducers.add(new CurrentCallStreamProducer(this /* context */)); + streamProducers.add(new RecentCallStreamProducer(this /* context */)); + streamProducers.add(new MediaStreamProducer(this /* context */)); + streamProducers.add(new RadioStreamProducer(this /* context */)); + + startProducers(); + } + + @Override + public void onTerminate() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "StreamApplication terminated"); + } + super.onTerminate(); + stopProducers(); + } + + private void startProducers() { + for (int i = 0; i < streamProducers.size(); i++) { + streamProducers.get(i).start(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Stream producers started: " + + streamProducers.get(i).getClass().getName()); + } + } + } + + private void stopProducers() { + for (int i = 0; i < streamProducers.size(); i++) { + streamProducers.get(i).stop(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Stream producers stopped: " + + streamProducers.get(i).getClass().getName()); + } + } + } +} diff --git a/src/com/android/car/stream/StreamProducer.java b/src/com/android/car/stream/StreamProducer.java new file mode 100644 index 0000000..0cb5212 --- /dev/null +++ b/src/com/android/car/stream/StreamProducer.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.support.annotation.CallSuper; +import android.util.Log; + +/** + * A base class that produces {@link StreamCard} for the StreamService + */ +public abstract class StreamProducer { + private static final String TAG = "StreamProducer"; + + private StreamService mStreamService; + protected Context mContext; + + public StreamProducer(Context context) { + mContext = context; + } + + public final boolean postCard(StreamCard card) { + if (mStreamService != null) { + mStreamService.addStreamCard(card); + return true; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "StreamService not found, unable to post card"); + } + return false; + } + + public final boolean removeCard(StreamCard card) { + if (mStreamService != null) { + mStreamService.removeStreamCard(card); + return true; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "StreamService not found, unable to remove card"); + } + return false; + } + + public void onCardDismissed(StreamCard card) { + // Handle when a StreamCard is dismissed. + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Stream Card dismissed: " + card); + } + } + + /** + * Start the producer and connect to the {@link StreamService} + */ + @CallSuper + public void start() { + Intent streamServiceIntent = new Intent(mContext, StreamService.class); + streamServiceIntent.setAction(StreamConstants.STREAM_PRODUCER_BIND_ACTION); + mContext.bindService(streamServiceIntent, mServiceConnection, 0 /* flags */); + } + + /** + * Stop the producer. + */ + @CallSuper + public void stop() { + mContext.unbindService(mServiceConnection); + } + + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + StreamService.StreamProducerBinder binder + = (StreamService.StreamProducerBinder) service; + mStreamService = binder.getService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mStreamService = null; + } + }; +}
\ No newline at end of file diff --git a/src/com/android/car/stream/StreamService.java b/src/com/android/car/stream/StreamService.java new file mode 100644 index 0000000..75a2c38 --- /dev/null +++ b/src/com/android/car/stream/StreamService.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.util.Pair; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * A service that manages the {@link StreamCard} being generated by the system and notifies + * the {@link IStreamConsumer} that new cards are available. + */ +public class StreamService extends Service { + private static final String TAG = "StreamService"; + private static final int DEFAULT_STREAM_CONSUMER_COUNT = 3; + + // The StreamCard is identified by a key which is comprised of its type and id + private LinkedHashMap<Pair<Integer, Long>, StreamCard> mStreamCards = new LinkedHashMap<>(); + + private List<IStreamConsumer> mConsumers = new ArrayList<>(DEFAULT_STREAM_CONSUMER_COUNT); + + private final IBinder mStreamProducerBinder = new StreamProducerBinder(); + + + public class StreamProducerBinder extends Binder { + StreamService getService() { + return StreamService.this; + } + } + + @Override + public IBinder onBind(Intent intent) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onBind() calling process ID: " + Binder.getCallingPid() + + " StreamService process ID: " + android.os.Process.myPid()); + } + + String action = intent.getAction(); + switch(action){ + case StreamConstants.STREAM_PRODUCER_BIND_ACTION: + return mStreamProducerBinder; + case StreamConstants.STREAM_CONSUMER_BIND_ACTION: + return mStreamConsumerService; + default: + return null; + } + } + + private final IBinder mStreamConsumerService = new IStreamService.Stub() { + @Override + public void registerConsumer(IStreamConsumer consumer) throws RemoteException { + mConsumers.add(consumer); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Consumer registered, total # consumers: " + mConsumers.size()); + } + } + + @Override + public void unregisterConsumer(IStreamConsumer consumer) throws RemoteException { + mConsumers.remove(consumer); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Consumer removed, total # consumers: " + mConsumers.size()); + } + } + + @Override + public List<StreamCard> fetchAllStreamCards() throws RemoteException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Fetching all stream items, # cards: " + mStreamCards.size()); + } + + List<StreamCard> cards = new ArrayList(mStreamCards.values()); + return cards; + } + + @Override + public void notifyStreamCardDismissed(StreamCard card) throws RemoteException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "StreamCard dismissed"); + } + } + + @Override + public void notifyStreamCardInteracted(StreamCard card) throws RemoteException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "StreamCard clicked"); + } + } + }; + + /** + * Add a {@link StreamCard} to the StreamService. The {@link StreamCard} will be published to + * all IStreamListener registered with the StreamService. + */ + public void addStreamCard(StreamCard card) { + if (card == null) { + return; + } + rankStreamCard(card); + mStreamCards.put(getStreamCardKey(card), card); + notifyListenersCardAdded(card); + } + + /** + * Remove a {@link StreamCard} to the StreamService. All registered {@link IStreamConsumer} will + * be notified of the removal. + * + * @param card + */ + public void removeStreamCard(StreamCard card) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Stream Card Removed: " + card.toString()); + } + + if (card == null) { + return; + } + + mStreamCards.remove(getStreamCardKey(card)); + notifyListenersCardRemoved(card); + } + + private Pair<Integer, Long> getStreamCardKey(StreamCard card) { + return new Pair(card.getType(), card.getId()); + } + + private void notifyListenersCardAdded(StreamCard card) { + Iterator<IStreamConsumer> iterator = mConsumers.iterator(); + + while (iterator.hasNext()) { + IStreamConsumer consumer = iterator.next(); + try { + consumer.onStreamCardAdded(card); + } catch (DeadObjectException e) { + iterator.remove(); + Log.w(TAG, "Dead Stream Listener removed"); + } catch (RemoteException e) { + Log.e(TAG, e.getMessage()); + } + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Notify StreamCard added, card: " + card); + Log.d(TAG, "Card Extension: " + card.getCardExtension()); + } + } + + private void notifyListenersCardRemoved(StreamCard card) { + Iterator<IStreamConsumer> iterator = mConsumers.iterator(); + + while (iterator.hasNext()) { + IStreamConsumer consumer = iterator.next(); + try { + consumer.onStreamCardRemoved(card); + } catch (DeadObjectException e) { + iterator.remove(); + Log.w(TAG, "Dead Stream Listener removed"); + } catch (RemoteException e) { + Log.e(TAG, e.getMessage()); + } + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Notify StreamCard removed, card type: " + card.getType()); + } + } + + private void rankStreamCard(StreamCard card) { + // TODO: move this into a separate class once we introduce the actual ranking. + card.setPriority(1); + } +} diff --git a/src/com/android/car/stream/StreamServiceConstants.java b/src/com/android/car/stream/StreamServiceConstants.java new file mode 100644 index 0000000..521a512 --- /dev/null +++ b/src/com/android/car/stream/StreamServiceConstants.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.stream; + +/** + * A class that holds various common constants used through the Stream service. + */ +public class StreamServiceConstants { + /** + * The id that should be used for all media cards. Using a common id for all media cards + * ensure that only one will show at a time. + */ + public static long MEDIA_CARD_ID = -1L; + + /** + * The id within the {@link MediaPlaybackExtension} that indicates this MediaPlaybackExtension + * is coming from a non-radio application. + * + * <p>The reason that this id is necessary is because the radio does not use the MediaSession + * to notify of playback state. Thus, notifications about playback state for media apps and + * radio are not guaranteed to be in order. This id along with {@link #MEDIA_EXTENSION_ID_RADIO} + * will help differentiate which application is firing a change. + */ + public static long MEDIA_EXTENSION_ID_NON_RADIO = -1L; + + /** + * The id within the {@link MediaPlaybackExtension} that indicates this MediaPlaybackExtension + * is coming from a radio application. + * + * @see {@link #MEDIA_EXTENSION_ID_NON_RADIO} + */ + public static long MEDIA_EXTENSION_ID_RADIO= -2L; + + + private StreamServiceConstants() {} +} diff --git a/src/com/android/car/stream/media/MediaAppInfo.java b/src/com/android/car/stream/media/MediaAppInfo.java new file mode 100644 index 0000000..34980c3 --- /dev/null +++ b/src/com/android/car/stream/media/MediaAppInfo.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.util.Log; + +/** + * An immutable class which hold the the information about the currently connected media app, if + * it supports {@link android.service.media.MediaBrowserService}. + */ +public class MediaAppInfo { + private static final String TAG = "MediaAppInfo"; + private static final String KEY_SMALL_ICON = + "com.google.android.gms.car.notification.SmallIcon"; + + /** Third-party defined application theme to use **/ + private static final String THEME_META_DATA_NAME + = "com.google.android.gms.car.application.theme"; + + private final ComponentName mComponentName; + private final Resources mPackageResources; + private final String mAppName; + private final String mPackageName; + private final int mSmallIcon; + + private int mPrimaryColor; + private int mPrimaryColorDark; + private int mAccentColor; + + public MediaAppInfo(Context context, String packageName) { + Resources resources = null; + try { + resources = context.getPackageManager().getResourcesForApplication(packageName); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Unable to get resources for " + packageName); + } + mPackageResources = resources; + + mComponentName = MediaUtils.getMediaBrowserService(packageName, context); + String appName = null; + int smallIconResId = 0; + try { + PackageManager packageManager = context.getPackageManager(); + ServiceInfo serviceInfo = null; + ApplicationInfo appInfo = packageManager.getApplicationInfo(packageName, + PackageManager.GET_META_DATA); + + int labelResId; + + if (mComponentName != null) { + serviceInfo = + packageManager.getServiceInfo(mComponentName, PackageManager.GET_META_DATA); + smallIconResId = serviceInfo.metaData == null ? 0 : serviceInfo.metaData.getInt + (KEY_SMALL_ICON, 0); + labelResId = serviceInfo.labelRes; + } else { + Log.w(TAG, "Service label is null for " + packageName + + ". Falling back to app name."); + labelResId = appInfo.labelRes; + } + + int appTheme = 0; + if (serviceInfo != null && serviceInfo.metaData != null) { + appTheme = serviceInfo.metaData.getInt(THEME_META_DATA_NAME); + } + if (appTheme == 0 && appInfo.metaData != null) { + appTheme = appInfo.metaData.getInt(THEME_META_DATA_NAME); + } + if (appTheme == 0) { + appTheme = appInfo.theme; + } + + fetchAppColors(packageName, appTheme, context); + appName = (labelResId == 0 || mPackageResources == null) ? null + : mPackageResources.getString(labelResId); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Got a component that doesn't exist (" + packageName + ")"); + } + mSmallIcon = smallIconResId; + mAppName = appName; + + mPackageName = packageName; + } + + public ComponentName getComponentName() { + return mComponentName; + } + + public String getAppName() { + return mAppName; + } + + public int getSmallIcon() { + return mSmallIcon; + } + + public String getPackageName() { + return mPackageName; + } + + public Resources getPackageResources() { + return mPackageResources; + } + + public int getMediaClientPrimaryColor() { + return mPrimaryColor; + } + + public int getMediaClientPrimaryColorDark() { + return mPrimaryColorDark; + } + + public int getMediaClientAccentColor() { + return mAccentColor; + } + + private void fetchAppColors(String packageName, int appTheme, Context context) { + TypedArray ta = null; + try { + Context packageContext = context.createPackageContext(packageName, 0); + packageContext.setTheme(appTheme); + Resources.Theme theme = packageContext.getTheme(); + ta = theme.obtainStyledAttributes(new int[]{ + android.R.attr.colorPrimary, + android.R.attr.colorAccent, + android.R.attr.colorPrimaryDark + }); + int defaultColor = + context.getColor(android.R.color.holo_green_light); + mPrimaryColor = ta.getColor(0, defaultColor); + mAccentColor = ta.getColor(1, defaultColor); + mPrimaryColorDark = ta.getColor(2, defaultColor); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Unable to update media client package attributes.", e); + } finally { + if (ta != null) { + ta.recycle(); + } + } + } +}
\ No newline at end of file diff --git a/src/com/android/car/stream/media/MediaConverter.java b/src/com/android/car/stream/media/MediaConverter.java new file mode 100644 index 0000000..41b9f51 --- /dev/null +++ b/src/com/android/car/stream/media/MediaConverter.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.VectorDrawable; +import android.view.KeyEvent; +import android.widget.RemoteViews; +import com.android.car.stream.MediaPlaybackExtension; +import com.android.car.stream.R; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamConstants; +import com.android.car.stream.StreamServiceConstants; + +/** + * A converter that creates a {@link StreamCard} for currently playing media. + */ +public class MediaConverter { + private final PendingIntent mGotoMediaFacetAction; + private final PendingIntent mPauseAction; + private final PendingIntent mSkipToNextAction; + private final PendingIntent mSkipToPreviousAction; + private final PendingIntent mPlayAction; + private final PendingIntent mStopAction; + + private Bitmap mPlayIcon; + private Bitmap mPauseIcon; + + public MediaConverter(Context context) { + String mediaPackage = context.getString(R.string.car_media_component_package); + mGotoMediaFacetAction = createGoToMediaFacetIntent(context, mediaPackage); + + mPauseAction = getMediaActionIntent(context, + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE), + KeyEvent.KEYCODE_MEDIA_PAUSE /* requestCode */); + + mSkipToNextAction = getMediaActionIntent(context, + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD), + KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD /* requestCode */); + + mSkipToPreviousAction = getMediaActionIntent(context, + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD), + KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD /* requestCode */); + + + mPlayAction = getMediaActionIntent(context, + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY), + KeyEvent.KEYCODE_MEDIA_PLAY /* requestCode */); + + mStopAction = getMediaActionIntent(context, + new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP), + KeyEvent.KEYCODE_MEDIA_STOP /* requestCode */); + + int iconSize = context.getResources() + .getDimensionPixelSize(R.dimen.stream_card_secondary_icon_dimen); + mPlayIcon = getBitmap((VectorDrawable) + context.getDrawable(R.drawable.ic_play_arrow), iconSize, iconSize); + mPauseIcon = getBitmap((VectorDrawable) + context.getDrawable(R.drawable.ic_pause), iconSize, iconSize); + } + + public StreamCard convert( + String title, + String subtitle, + Bitmap albumArt, + int appAccentColor, + String appName, + boolean canSkipToNext, + boolean canSkipToPrevious, + boolean hasPause, + boolean isPlaying) { + + StreamCard.Builder builder = new StreamCard.Builder(StreamConstants.CARD_TYPE_MEDIA, + StreamConstants.MEDIA_CARD_ID, System.currentTimeMillis()); + builder.setClickAction(mGotoMediaFacetAction); + builder.setPrimaryText(title); + builder.setSecondaryText(subtitle); + Bitmap icon = isPlaying ? mPlayIcon : mPauseIcon; + builder.setPrimaryIcon(icon); + + MediaPlaybackExtension extension = new MediaPlaybackExtension(title, subtitle, albumArt, + appAccentColor, canSkipToNext, canSkipToPrevious, hasPause, isPlaying, appName, + mStopAction, mPauseAction, mPlayAction, mSkipToNextAction, mSkipToPreviousAction); + + builder.setCardExtension(extension); + return builder.build(); + } + + /** + * Attaches a {@link PendingIntent} to the given {@link RemoteViews}. The PendingIntent will + * send the user to the CarMediaApp when touched. Note that this does not resolve to the + * application currently in the media card; instead, it just opens the last music app. For + * example, if the card is generated by Google Play Music, but the last opened music app + * was Spotify, then Spotify will open when the music card is tapped. + */ + private PendingIntent createGoToMediaFacetIntent(Context context, String mediaPackage) { + Intent intent = context.getPackageManager().getLaunchIntentForPackage(mediaPackage); + intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); + + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private PendingIntent getMediaActionIntent(Context context, KeyEvent ke, int requestCode) { + Intent i = new Intent(Intent.ACTION_MEDIA_BUTTON); + i.setPackage(context.getPackageName()); + i.putExtra(Intent.EXTRA_KEY_EVENT, ke); + + PendingIntent pendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + i, + PendingIntent.FLAG_CANCEL_CURRENT + ); + return pendingIntent; + } + + private static Bitmap getBitmap(VectorDrawable vectorDrawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + vectorDrawable.draw(canvas); + return bitmap; + } +} diff --git a/src/com/android/car/stream/media/MediaPlaybackMonitor.java b/src/com/android/car/stream/media/MediaPlaybackMonitor.java new file mode 100644 index 0000000..c360770 --- /dev/null +++ b/src/com/android/car/stream/media/MediaPlaybackMonitor.java @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.media.MediaDescription; +import android.media.MediaMetadata; +import android.media.session.PlaybackState; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; +import com.android.car.apps.common.BitmapDownloader; +import com.android.car.apps.common.BitmapWorkerOptions; +import com.android.car.stream.R; + +/** + * An service which connects to {@link MediaStateManager} for media updates (playback state and + * metadata) and notifies listeners for these changes. + * <p/> + */ +public class MediaPlaybackMonitor implements MediaStateManager.Listener { + protected static final String TAG = "MediaPlaybackMonitor"; + + // MSG for metadata update handler + private static final int MSG_UPDATE_METADATA = 1; + private static final int MSG_IMAGE_DOWNLOADED = 2; + private static final int MSG_NEW_ALBUM_ART_RECEIVED = 3; + + public interface MediaPlaybackMonitorListener { + void onPlaybackStateChanged(PlaybackState state); + + void onMetadataChanged(String title, String text, Bitmap art, int color, String appName); + + void onAlbumArtUpdated(Bitmap albumArt); + + void onNewAppConnected(); + + void removeMediaStreamCard(); + } + + private static final String[] PREFERRED_BITMAP_ORDER = { + MediaMetadata.METADATA_KEY_ALBUM_ART, + MediaMetadata.METADATA_KEY_ART, + MediaMetadata.METADATA_KEY_DISPLAY_ICON + }; + + private static final String[] PREFERRED_URI_ORDER = { + MediaMetadata.METADATA_KEY_ALBUM_ART_URI, + MediaMetadata.METADATA_KEY_ART_URI, + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI + }; + + private MediaMetadata mCurrentMetadata; + private MediaStatusUpdateHandler mMediaStatusUpdateHandler; + private MediaAppInfo mCurrentMediaAppInfo; + private MediaPlaybackMonitorListener mMonitorListener; + + private Context mContext; + + private final int mIconSize; + + public MediaPlaybackMonitor(Context context, @NonNull MediaPlaybackMonitorListener callback) { + mContext = context; + mMonitorListener = callback; + mIconSize = mContext.getResources().getDimensionPixelSize(R.dimen.stream_media_icon_size); + } + + public final void start() { + mMediaStatusUpdateHandler = new MediaStatusUpdateHandler(); + } + + public final void stop() { + if (mMediaStatusUpdateHandler != null) { + mMediaStatusUpdateHandler.removeCallbacksAndMessages(null); + mMediaStatusUpdateHandler = null; + } + } + + @Override + public void onMediaSessionConnected(PlaybackState state, MediaMetadata metaData, + MediaAppInfo appInfo) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "MediaSession onConnected called"); + } + + // If the current media app is not the same as the new media app, reset + // the media app in MediaStreamManager + if (mCurrentMediaAppInfo == null + || !mCurrentMediaAppInfo.getPackageName().equals(appInfo.getPackageName())) { + mMonitorListener.onNewAppConnected(); + if (mMediaStatusUpdateHandler != null) { + mMediaStatusUpdateHandler.removeCallbacksAndMessages(null); + } + mCurrentMediaAppInfo = appInfo; + } + + if (metaData != null) { + onMetadataChanged(metaData); + } + + if (state != null) { + onPlaybackStateChanged(state); + } + } + + @Override + public void onPlaybackStateChanged(@Nullable PlaybackState state) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onPlaybackStateChanged called " + state.getState()); + } + + if (state == null) { + Log.w(TAG, "playback state is null in onPlaybackStateChanged"); + mMonitorListener.removeMediaStreamCard(); + return; + } + + if (mMonitorListener != null) { + mMonitorListener.onPlaybackStateChanged(state); + } + } + + @Override + public void onMetadataChanged(@Nullable MediaMetadata metadata) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onMetadataChanged called"); + } + if (metadata == null) { + mMonitorListener.removeMediaStreamCard(); + return; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "received " + metadata.getDescription()); + } + // Compare the new metadata and the last we have posted notification for. If both + // metadata and album art are the same, just ignore and return. If the album art is new, + // update the stream item with the new album art. + MediaDescription currentDescription = mCurrentMetadata == null ? + null : mCurrentMetadata.getDescription(); + + if (!MediaUtils.isSameMediaDescription(metadata.getDescription(), currentDescription)) { + Message msg = + mMediaStatusUpdateHandler.obtainMessage(MSG_UPDATE_METADATA, metadata); + // Remove obsolete notifications in the queue. + mMediaStatusUpdateHandler.removeMessages(MSG_UPDATE_METADATA); + mMediaStatusUpdateHandler.sendMessage(msg); + } else { + Bitmap newBitmap = metadata.getDescription().getIconBitmap(); + if (newBitmap == null) { + return; + } + if (newBitmap.sameAs(mMediaStatusUpdateHandler.getCurrentIcon())) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Received duplicate metadata, ignoring..."); + } + } else { + // same metadata, but new album art + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Received metadata with new album art"); + } + Message msg = mMediaStatusUpdateHandler + .obtainMessage(MSG_NEW_ALBUM_ART_RECEIVED, newBitmap); + mMediaStatusUpdateHandler.removeMessages(MSG_NEW_ALBUM_ART_RECEIVED); + mMediaStatusUpdateHandler.sendMessage(msg); + } + } + } + + @Override + public void onSessionDestroyed() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Media session destroyed"); + } + mMonitorListener.removeMediaStreamCard(); + } + + private class BitmapCallback extends BitmapDownloader.BitmapCallback { + final private int mSeq; + + public BitmapCallback(int seq) { + mSeq = seq; + } + + @Override + public void onBitmapRetrieved(Bitmap bitmap) { + if (mMediaStatusUpdateHandler == null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "The callback comes after we finish"); + } + return; + } + Message msg = mMediaStatusUpdateHandler.obtainMessage(MSG_IMAGE_DOWNLOADED, + mSeq, 0, bitmap); + mMediaStatusUpdateHandler.sendMessage(msg); + } + } + + private class MediaStatusUpdateHandler extends Handler { + private int mSeq = 0; + private BitmapCallback mCallback; + private MediaMetadata mMetadata; + private String mTitle; + private String mSubtitle; + private Bitmap mIcon; + private Uri mIconUri; + private final BitmapDownloader mDownloader = BitmapDownloader.getInstance(mContext); + + private void extractMetadata(MediaMetadata metadata) { + if (metadata == mMetadata) { + // We are up to date and must return here, because we've already recycled the bitmap + // inside it. + return; + } + // keep a reference so we know which metadata we have stored. + mMetadata = metadata; + MediaDescription description = metadata.getDescription(); + mTitle = description.getTitle() == null ? null : description.getTitle().toString(); + mSubtitle = description.getSubtitle() == null ? + null : description.getSubtitle().toString(); + final Bitmap originalBitmap = getMetadataBitmap(metadata); + if (originalBitmap != null) { + mIcon = originalBitmap; + } else { + mIcon = null; + } + mIconUri = getMetadataIconUri(metadata); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Album Art Uri: " + mIconUri); + } + } + + private Uri getMetadataIconUri(MediaMetadata metadata) { + // Get the best Uri we can find + for (int i = 0; i < PREFERRED_URI_ORDER.length; i++) { + String iconUri = metadata.getString(PREFERRED_URI_ORDER[i]); + if (!TextUtils.isEmpty(iconUri)) { + return Uri.parse(iconUri); + } + } + return null; + } + + private Bitmap getMetadataBitmap(MediaMetadata metadata) { + // Get the best art bitmap we can find + for (int i = 0; i < PREFERRED_BITMAP_ORDER.length; i++) { + Bitmap bitmap = metadata.getBitmap(PREFERRED_BITMAP_ORDER[i]); + if (bitmap != null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Retrieved bitmap type: " + PREFERRED_BITMAP_ORDER[i] + + " w: " + bitmap.getWidth() + + " h: " + bitmap.getHeight()); + } + return bitmap; + } + } + return null; + } + + public Bitmap getCurrentIcon() { + return mIcon; + } + + @Override + public void handleMessage(Message msg) { + MediaAppInfo mediaAppInfo = mCurrentMediaAppInfo; + int color = mediaAppInfo.getMediaClientAccentColor(); + String appName = mediaAppInfo.getAppName(); + switch (msg.what) { + case MSG_UPDATE_METADATA: + mSeq++; + MediaMetadata metadata = (MediaMetadata) msg.obj; + if (metadata == null) { + Log.w(TAG, "media metadata is null!"); + return; + } + extractMetadata(metadata); + if (mCallback != null) { + // it's ok to cancel a callback that has already been called, the downloader + // will just ignore the operation. + mDownloader.cancelDownload(mCallback); + mCallback = null; + } + if (mIcon != null) { + mMonitorListener.onMetadataChanged(mTitle, mSubtitle, mIcon, + color, appName); + } else if (mIconUri != null) { + mCallback = new BitmapCallback(mSeq); + mDownloader.getBitmap( + new BitmapWorkerOptions.Builder(mContext) + .resource(mIconUri).width(mIconSize) + .height(mIconSize).build(), mCallback); + } else { + mMonitorListener.onMetadataChanged(mTitle, mSubtitle, mIcon, + color, appName); + } + // Only set mCurrentMetadata after we have updated the listener (if the + // bitmap is downloaded asynchronously, that is fine too. The stream card will + // be posted, when image is downloaded.) + mCurrentMetadata = metadata; + break; + + case MSG_IMAGE_DOWNLOADED: + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Image downloaded..."); + } + int seq = msg.arg1; + Bitmap bitmap = (Bitmap) msg.obj; + if (seq == mSeq) { + mMonitorListener.onMetadataChanged(mTitle, mSubtitle, bitmap, color, appName); + } + break; + + case MSG_NEW_ALBUM_ART_RECEIVED: + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Received a new album art..."); + } + Bitmap newAlbumArt = (Bitmap) msg.obj; + mMonitorListener.onAlbumArtUpdated(newAlbumArt); + break; + default: + } + } + } +} diff --git a/src/com/android/car/stream/media/MediaStateManager.java b/src/com/android/car/stream/media/MediaStateManager.java new file mode 100644 index 0000000..e574852 --- /dev/null +++ b/src/com/android/car/stream/media/MediaStateManager.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.content.Context; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSessionManager; +import android.media.session.PlaybackState; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; +import android.view.KeyEvent; +import com.android.car.apps.common.util.Assert; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * A class to listen for changes in sessions from {@link MediaSessionManager}. It also notifies + * listeners of changes in the playback state or metadata. + */ +public class MediaStateManager { + private static final String TAG = "MediaStateManager"; + private static final String TELECOM_PACKAGE = "com.android.server.telecom"; + + private final Context mContext; + + private MediaAppInfo mConnectedAppInfo; + private MediaController mController; + private Handler mHandler; + private final Set<Listener> mListeners; + + public interface Listener { + void onMediaSessionConnected(PlaybackState playbackState, MediaMetadata metaData, + MediaAppInfo appInfo); + + void onPlaybackStateChanged(@Nullable PlaybackState state); + + void onMetadataChanged(@Nullable MediaMetadata metadata); + + void onSessionDestroyed(); + } + + public MediaStateManager(@NonNull Context context) { + mContext = context; + mHandler = new Handler(Looper.getMainLooper()); + mListeners = new LinkedHashSet<>(); + } + + public void start() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Starting MediaStateManager"); + } + MediaSessionManager sessionManager + = (MediaSessionManager) mContext.getSystemService(Context.MEDIA_SESSION_SERVICE); + + try { + sessionManager.addOnActiveSessionsChangedListener(mSessionChangedListener, null); + + List<MediaController> controllers = sessionManager.getActiveSessions(null); + updateMediaController(controllers); + } catch (SecurityException e) { + // User hasn't granted the permission so we should just go away silently. + } + } + + @MainThread + public void destroy() { + Assert.isMainThread(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "destroy()"); + } + stop(); + mListeners.clear(); + mHandler = null; + } + + @MainThread + public void stop() { + Assert.isMainThread(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "stop()"); + } + + if (mController != null) { + mController.unregisterCallback(mMediaControllerCallback); + mController = null; + } + // Calling this with null will clear queue of callbacks and message. This needs to be done + // here because prior to the above lines to disconnect and unregister the + // controller a posted runnable to do work maybe have happened and thus we need to clear it + // out to prevent race conditions. + mHandler.removeCallbacksAndMessages(null); + } + + public void dispatchMediaButton(KeyEvent keyEvent) { + if (mController != null) { + MediaController.TransportControls transportControls + = mController.getTransportControls(); + int eventId = keyEvent.getKeyCode(); + + switch (eventId) { + case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD: + transportControls.skipToPrevious(); + break; + case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD: + transportControls.skipToNext(); + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + transportControls.play(); + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + transportControls.pause(); + break; + case KeyEvent.KEYCODE_MEDIA_STOP: + transportControls.stop(); + break; + default: + mController.dispatchMediaButtonEvent(keyEvent); + } + } + } + + public void addListener(@NonNull Listener listener) { + mListeners.add(listener); + } + + public void removeListener(@NonNull Listener listener) { + mListeners.remove(listener); + } + + private void updateMediaController(List<MediaController> controllers) { + if (controllers.size() > 0) { + // If the telecom package is trying to onStart a media session, ignore it + // so that the existing media item continues to appear in the stream. + if (TELECOM_PACKAGE.equals(controllers.get(0).getPackageName())) { + return; + } + + if (mController != null) { + mController.unregisterCallback(mMediaControllerCallback); + } + // Currently the first controller is the active one playing music. + // If this is no longer the case, consider checking notification listener + // for a MediaStyle notification to get currently playing media app. + mController = controllers.get(0); + mController.registerCallback(mMediaControllerCallback); + + mConnectedAppInfo = new MediaAppInfo(mContext, mController.getPackageName()); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "updating media controller"); + } + + for (Listener listener : mListeners) { + listener.onMediaSessionConnected(mController.getPlaybackState(), + mController.getMetadata(), mConnectedAppInfo); + } + } else { + Log.w(TAG, "Updating controllers with an empty list!"); + } + } + + public static boolean isMainThread() { + return Looper.myLooper() == Looper.getMainLooper(); + } + + private final MediaSessionManager.OnActiveSessionsChangedListener + mSessionChangedListener = new MediaSessionManager.OnActiveSessionsChangedListener() { + @Override + public void onActiveSessionsChanged(List<MediaController> controllers) { + updateMediaController(controllers); + } + }; + + private final MediaController.Callback mMediaControllerCallback = + new MediaController.Callback() { + @Override + public void onPlaybackStateChanged(@NonNull final PlaybackState state) { + mHandler.post(new Runnable() { + @Override + public void run() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onPlaybackStateChanged(" + state + ")"); + } + for (Listener listener : mListeners) { + listener.onPlaybackStateChanged(state); + } + } + }); + } + + @Override + public void onMetadataChanged(@Nullable final MediaMetadata metadata) { + mHandler.post(new Runnable() { + @Override + public void run() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onMetadataChanged(" + metadata + ")"); + } + for (Listener listener : mListeners) { + listener.onMetadataChanged(metadata); + } + } + }); + } + + @Override + public void onSessionDestroyed() { + mHandler.post(new Runnable() { + @Override + public void run() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onSessionDestroyed()"); + } + + mConnectedAppInfo = null; + if (mController != null) { + mController.unregisterCallback(mMediaControllerCallback); + mController = null; + } + + for (Listener listener : mListeners) { + listener.onSessionDestroyed(); + } + } + }); + } + }; +} diff --git a/src/com/android/car/stream/media/MediaStreamProducer.java b/src/com/android/car/stream/media/MediaStreamProducer.java new file mode 100644 index 0000000..8808f14 --- /dev/null +++ b/src/com/android/car/stream/media/MediaStreamProducer.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Bitmap; +import android.media.session.PlaybackState; +import android.util.Log; +import android.view.KeyEvent; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamProducer; + +/** + * Produces {@link StreamCard} on media playback or metadata changes. + */ +public class MediaStreamProducer extends StreamProducer + implements MediaPlaybackMonitor.MediaPlaybackMonitorListener { + private static final String TAG = "MediaStreamProducer"; + + private MediaPlaybackMonitor mPlaybackMonitor; + private MediaStateManager mMediaStateManager; + private MediaKeyReceiver mMediaKeyReceiver; + private MediaConverter mConverter; + + private StreamCard mCurrentMediaStreamCard; + + private boolean mHasReceivedPlaybackState; + private boolean mHasReceivedMetadata; + + // Current playback state of the media session. + private boolean mIsPlaying; + private boolean mHasPause; + private boolean mCanSkipToNext; + private boolean mCanSkipToPrevious; + + private String mTitle; + private String mSubtitle; + private Bitmap mAlbumArt; + private int mAppAccentColor; + private String mAppName; + + public MediaStreamProducer(Context context) { + super(context); + mConverter = new MediaConverter(context); + } + + @Override + public void start() { + super.start(); + mPlaybackMonitor = new MediaPlaybackMonitor(mContext, + MediaStreamProducer.this /* MediaPlaybackMonitorListener */); + mPlaybackMonitor.start(); + + mMediaKeyReceiver = new MediaKeyReceiver(); + mContext.registerReceiver(mMediaKeyReceiver, + new IntentFilter(Intent.ACTION_MEDIA_BUTTON)); + + mMediaStateManager = new MediaStateManager(mContext); + mMediaStateManager.addListener(mPlaybackMonitor); + mMediaStateManager.start(); + } + + @Override + public void stop() { + mPlaybackMonitor.stop(); + mMediaStateManager.destroy(); + + mPlaybackMonitor = null; + mMediaStateManager = null; + + mContext.unregisterReceiver(mMediaKeyReceiver); + mMediaKeyReceiver = null; + super.stop(); + } + + private class MediaKeyReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String intentAction = intent.getAction(); + if (Intent.ACTION_MEDIA_BUTTON.equals(intentAction)) { + KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); + if (event == null) { + return; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Received media key " + event.getKeyCode()); + } + mMediaStateManager.dispatchMediaButton(event); + } + } + } + + public void onPlaybackStateChanged(PlaybackState state) { + //Some media apps tend to spam playback state changes. Check if the playback state changes + // are relevant. If it is the same, don't bother updating and posting to the stream. + if (isDuplicatePlaybackState(state)) { + return; + } + + int playbackState = state.getState(); + mHasPause = ((state.getActions() & PlaybackState.ACTION_PAUSE) != 0); + if (!mHasPause) { + mHasPause = ((state.getActions() & PlaybackState.ACTION_PLAY_PAUSE) != 0); + } + mCanSkipToNext = ((state.getActions() & PlaybackState.ACTION_SKIP_TO_NEXT) != 0); + mCanSkipToPrevious = ((state.getActions() & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0); + if (playbackState == PlaybackState.STATE_PLAYING + || playbackState == PlaybackState.STATE_BUFFERING) { + mIsPlaying = true; + } else { + mIsPlaying = false; + } + mHasReceivedPlaybackState = true; + maybeUpdateStreamCard(); + } + + private void maybeUpdateStreamCard() { + if (mHasReceivedPlaybackState && mHasReceivedMetadata) { + mCurrentMediaStreamCard = mConverter.convert(mTitle, mSubtitle, mAlbumArt, + mAppAccentColor, mAppName, mCanSkipToNext, mCanSkipToPrevious, + mHasPause, mIsPlaying); + if (mCurrentMediaStreamCard == null) { + Log.w(TAG, "Media Card was not created"); + return; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Media Card posted"); + } + postCard(mCurrentMediaStreamCard); + } + } + + public void onMetadataChanged(String title, String subtitle, Bitmap albumArt, int color, + String appName) { + //Some media apps tend to spam metadata state changes. Check if the playback state changes + // are relevant. If it is the same, don't bother updating and posting to the stream. + if (isSameString(title, mTitle) + && isSameString(subtitle, mSubtitle) + && isSameBitmap(albumArt, albumArt) + && color == mAppAccentColor + && isSameString(appName, mAppName)) { + return; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Update notification."); + } + + mTitle = title; + mSubtitle = subtitle; + mAlbumArt = albumArt; + mAppAccentColor = color; + mAppName = appName; + + mHasReceivedMetadata = true; + maybeUpdateStreamCard(); + } + + private boolean isDuplicatePlaybackState(PlaybackState state) { + if (!mHasReceivedPlaybackState) { + return false; + } + int playbackState = state.getState(); + + boolean hasPause + = ((state.getActions() & PlaybackState.ACTION_PAUSE) != 0); + boolean canSkipToNext + = ((state.getActions() & PlaybackState.ACTION_SKIP_TO_NEXT) != 0); + boolean canSkipToPrevious + = ((state.getActions() & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0); + + boolean isPlaying = playbackState == PlaybackState.STATE_PLAYING + || playbackState == PlaybackState.STATE_BUFFERING; + + return (hasPause == mHasPause + && canSkipToNext == mCanSkipToNext + && canSkipToPrevious == mCanSkipToPrevious + && isPlaying == mIsPlaying); + } + + @Override + public void onAlbumArtUpdated(Bitmap albumArt) { + mAlbumArt = albumArt; + maybeUpdateStreamCard(); + } + + @Override + public void onNewAppConnected() { + mHasReceivedMetadata = false; + mHasReceivedPlaybackState = false; + removeCard(mCurrentMediaStreamCard); + mCurrentMediaStreamCard = null; + + // clear out all existing values + mTitle = null; + mSubtitle = null; + mAlbumArt = null; + mAppName = null; + mAppAccentColor = 0; + mCanSkipToNext = false; + mCanSkipToPrevious = false; + mHasPause = false; + mIsPlaying = false; + mIsPlaying = false; + } + + @Override + public void removeMediaStreamCard() { + removeCard(mCurrentMediaStreamCard); + mCurrentMediaStreamCard = null; + } + + private boolean isSameBitmap(Bitmap bmp1, Bitmap bmp2) { + return bmp1 == null + ? bmp2 == null : (bmp1 == bmp2 && bmp1.getGenerationId() == bmp2.getGenerationId()); + } + + private boolean isSameString(CharSequence str1, CharSequence str2) { + return str1 == null ? str2 == null : str1.equals(str2); + } +} diff --git a/src/com/android/car/stream/media/MediaUtils.java b/src/com/android/car/stream/media/MediaUtils.java new file mode 100644 index 0000000..b271b2b --- /dev/null +++ b/src/com/android/car/stream/media/MediaUtils.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.media; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.media.MediaDescription; +import android.service.media.MediaBrowserService; + +import java.util.List; +import java.util.Objects; + +/** + * Media related utility functions + */ +public final class MediaUtils { + /** + * @return True if the two media descriptions are the same. + */ + public static boolean isSameMediaDescription(MediaDescription description1, + MediaDescription description2) { + if ((description1 == null) && (description2 == null)) { + return true; + } + + if (description1 != null && description2 != null) { + return Objects.equals(description1.getTitle(), description2.getTitle()) + && Objects.equals(description1.getSubtitle(), description2.getSubtitle()); + } + return false; + } + + /** + * @return The component name of the {@link MediaBrowserService} for the given package name. + */ + public static ComponentName getMediaBrowserService(String packageName, + Context context) { + Intent intent = new Intent(MediaBrowserService.SERVICE_INTERFACE); + List<ResolveInfo> mediaApps = context.getPackageManager() + .queryIntentServices(intent, PackageManager.GET_RESOLVED_FILTER); + + for (int i = 0; i < mediaApps.size(); i++) { + ResolveInfo info = mediaApps.get(i); + if (packageName.equals(info.serviceInfo.packageName)) { + return new ComponentName(packageName, info.serviceInfo.name /* className */); + } + } + return null; + } +} diff --git a/src/com/android/car/stream/notifications/StreamNotificationListenerService.java b/src/com/android/car/stream/notifications/StreamNotificationListenerService.java new file mode 100644 index 0000000..6e0c39b --- /dev/null +++ b/src/com/android/car/stream/notifications/StreamNotificationListenerService.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.notifications; + +import android.service.notification.NotificationListenerService; +import android.service.notification.StatusBarNotification; +import android.util.Log; + +/** + * A listener to intercept notifications for the stream. + */ +public class StreamNotificationListenerService extends NotificationListenerService { + private static final String TAG = "NotificationListener"; + + @Override + public void onNotificationPosted(StatusBarNotification sbn) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Notification received"); + } + } + + @Override + public void onNotificationRemoved(StatusBarNotification sbn) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Notification removed"); + } + } +} diff --git a/src/com/android/car/stream/radio/RadioConverter.java b/src/com/android/car/stream/radio/RadioConverter.java new file mode 100644 index 0000000..f3ef426 --- /dev/null +++ b/src/com/android/car/stream/radio/RadioConverter.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.stream.radio; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.VectorDrawable; +import android.support.annotation.ColorInt; +import com.android.car.radio.service.RadioStation; +import com.android.car.stream.MediaPlaybackExtension; +import com.android.car.stream.R; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamConstants; +import com.android.car.stream.StreamServiceConstants; + +/** + * A converter that is responsible for transforming a {@link RadioStation} into a + * {@link StreamCard}. + */ +public class RadioConverter { + /** + * The separator between the radio channel and band (e.g. between 99.7 and FM). + */ + private static final String CHANNEL_AND_BAND_SEPARATOR = " "; + + private final Context mContext; + + private final PendingIntent mGoToRadioAction; + private final PendingIntent mPauseAction; + private final PendingIntent mForwardSeekAction; + private final PendingIntent mBackwardSeekAction; + private final PendingIntent mPlayAction; + private final PendingIntent mStopAction; + + @ColorInt + private final int mAccentColor; + + private final Bitmap mPlayIcon; + private final Bitmap mPauseIcon; + + public RadioConverter(Context context) { + mContext = context; + + mGoToRadioAction = createGoToRadioIntent(); + mPauseAction = createRadioActionIntent(RadioStreamProducer.ACTION_PAUSE); + mPlayAction = createRadioActionIntent(RadioStreamProducer.ACTION_PLAY); + mStopAction = createRadioActionIntent(RadioStreamProducer.ACTION_STOP); + mForwardSeekAction = createRadioActionIntent(RadioStreamProducer.ACTION_SEEK_FORWARD); + mBackwardSeekAction = createRadioActionIntent(RadioStreamProducer.ACTION_SEEK_BACKWARD); + + mAccentColor = mContext.getColor(R.color.car_radio_accent_color); + + int iconSize = context.getResources() + .getDimensionPixelSize(R.dimen.stream_card_secondary_icon_dimen); + mPlayIcon = getBitmap((VectorDrawable) + mContext.getDrawable(R.drawable.ic_play_arrow), iconSize, iconSize); + mPauseIcon = getBitmap((VectorDrawable) + mContext.getDrawable(R.drawable.ic_pause), iconSize, iconSize); + } + + /** + * Converts the given {@link RadioStation} and play status into a {@link StreamCard} that can + * be used to display a radio card. + */ + public StreamCard convert(RadioStation station, boolean isPlaying) { + StreamCard.Builder builder = new StreamCard.Builder(StreamConstants.CARD_TYPE_MEDIA, + StreamConstants.RADIO_CARD_ID, System.currentTimeMillis()); + + builder.setClickAction(mGoToRadioAction); + + String title = createTitleText(station); + builder.setPrimaryText(title); + + String subtitle = null; + if (station.getRds() != null) { + subtitle = station.getRds().getProgramService(); + builder.setSecondaryText(subtitle); + } + + Bitmap icon = isPlaying ? mPlayIcon : mPauseIcon; + builder.setPrimaryIcon(icon); + + MediaPlaybackExtension extension = new MediaPlaybackExtension(title, subtitle, + null /* albumArt */, mAccentColor, true /* canSkipToNext */, + true /* canSkipToPrevious */, true /* hasPause */, isPlaying, + mContext.getString(R.string.radio_app_name), mStopAction, mPauseAction, mPlayAction, + mForwardSeekAction, mBackwardSeekAction); + + builder.setCardExtension(extension); + return builder.build(); + } + + /** + * Returns the String that represents the title text of the radio card. The title should be + * a combination of the current channel number and radio band. + */ + private String createTitleText(RadioStation station) { + int radioBand = station.getRadioBand(); + String channel = RadioFormatter.formatRadioChannel(radioBand, + station.getChannelNumber()); + String band = RadioFormatter.formatRadioBand(mContext, radioBand); + + return channel + CHANNEL_AND_BAND_SEPARATOR + band; + } + + /** + * Returns an {@link Intent} that will take the user to the radio application. + */ + private PendingIntent createGoToRadioIntent() { + ComponentName radioComponent = new ComponentName( + mContext.getString(R.string.car_radio_component_package), + mContext.getString(R.string.car_radio_component_activity)); + + Intent intent = new Intent(); + intent.setComponent(radioComponent); + intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); + + return PendingIntent.getActivity(mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Returns an {@link Intent} that will perform the given action. + * + * @param action One of the action values in {@link RadioStreamProducer}. e.g. + * {@link RadioStreamProducer#ACTION_PAUSE}. + */ + private PendingIntent createRadioActionIntent(int action) { + Intent intent = new Intent(RadioStreamProducer.RADIO_INTENT_ACTION); + intent.setPackage(mContext.getPackageName()); + intent.putExtra(RadioStreamProducer.RADIO_ACTION_EXTRA, action); + + return PendingIntent.getBroadcast(mContext, action /* requestCode */, + intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + /** + * Returns a {@link Bitmap} that corresponds to the given {@link VectorDrawable}. + */ + private static Bitmap getBitmap(VectorDrawable vectorDrawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + vectorDrawable.draw(canvas); + return bitmap; + } +} diff --git a/src/com/android/car/stream/radio/RadioFormatter.java b/src/com/android/car/stream/radio/RadioFormatter.java new file mode 100644 index 0000000..ad427d2 --- /dev/null +++ b/src/com/android/car/stream/radio/RadioFormatter.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.radio; + +import android.content.Context; +import android.hardware.radio.RadioManager; +import com.android.car.radio.service.RadioStation; +import com.android.car.stream.R; + +import java.text.DecimalFormat; +import java.util.Locale; +/** + * Common formatters for displaying channel numbers for various radio channels and bands. + */ +public final class RadioFormatter { + private static final String FM_CHANNEL_FORMAT = "###.#"; + private static final String AM_CHANNEL_FORMAT = "####"; + + private RadioFormatter() {} + + /** + * The formatter for AM radio stations. + */ + public static final DecimalFormat FM_FORMATTER = new DecimalFormat(FM_CHANNEL_FORMAT); + + /** + * The formatter for FM radio stations. + */ + public static final DecimalFormat AM_FORMATTER = new DecimalFormat(AM_CHANNEL_FORMAT); + + /** + * Convenience method to format a given {@link RadioStation} based on the value in + * {@link RadioStation#getRadioBand()}. If the band is invalid or support for its formatting is + * not available, then an empty String is returned. + * + * @param band One of the band values specified in {@link RadioManager}. For example, + * {@link RadioManager#BAND_FM}. + * @param channelNumber The channel number to format. This value should be in KHz. + * @return A correctly formatted channel number or an empty string if one cannot be formed. + */ + public static String formatRadioChannel(int band, int channelNumber) { + switch (band) { + case RadioManager.BAND_AM: + return AM_FORMATTER.format(channelNumber); + + case RadioManager.BAND_FM: + // FM channels are displayed in KHz, so divide by 1000. + return FM_FORMATTER.format((float) channelNumber / 1000); + + default: + return ""; + } + } + + /** + * Formats the given band value into a readable String. + * + * @param band One of the band values specified in {@link RadioManager}. For example, + * {@link RadioManager#BAND_FM}. + * @return The formatted string or an empty string if the band is invalid. + */ + public static String formatRadioBand(Context context, int band) { + String radioBandText; + + switch (band) { + case RadioManager.BAND_AM: + radioBandText = context.getString(R.string.radio_am_text); + break; + + case RadioManager.BAND_FM: + radioBandText = context.getString(R.string.radio_fm_text); + break; + + default: + radioBandText = ""; + } + + return radioBandText.toUpperCase(Locale.getDefault()); + } +} diff --git a/src/com/android/car/stream/radio/RadioStreamProducer.java b/src/com/android/car/stream/radio/RadioStreamProducer.java new file mode 100644 index 0000000..4c36650 --- /dev/null +++ b/src/com/android/car/stream/radio/RadioStreamProducer.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.stream.radio; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import com.android.car.radio.service.IRadioCallback; +import com.android.car.radio.service.IRadioManager; +import com.android.car.radio.service.RadioRds; +import com.android.car.radio.service.RadioStation; +import com.android.car.stream.R; +import com.android.car.stream.StreamProducer; + +/** + * A {@link StreamProducer} that will connect to the {@link IRadioManager} and produce cards + * corresponding to the currently playing radio station. + */ +public class RadioStreamProducer extends StreamProducer { + private static final String TAG = "RadioStreamProducer"; + + /** + * The amount of time to wait before re-trying to connect to {@link IRadioManager}. + */ + private static final int SERVICE_CONNECTION_RETRY_DELAY_MS = 5000; + + // Radio actions that are used by broadcasts that occur on interaction with the radio card. + static final int ACTION_SEEK_FORWARD = 1; + static final int ACTION_SEEK_BACKWARD = 2; + static final int ACTION_PAUSE = 3; + static final int ACTION_PLAY = 4; + static final int ACTION_STOP = 5; + + /** + * The action in an {@link Intent} that is meant to effect certain radio actions. + */ + static final String RADIO_INTENT_ACTION = + "com.android.car.stream.radio.RADIO_INTENT_ACTION"; + + /** + * The extra within the {@link Intent} that points to the specific action to be taken on the + * radio. + */ + static final String RADIO_ACTION_EXTRA = "radio_action_extra"; + + private final Handler mHandler = new Handler(); + + private IRadioManager mRadioManager; + private RadioActionReceiver mReceiver; + private final RadioConverter mConverter; + + /** + * The number of times that this stream producer has attempted to reconnect to the + * {@link IRadioManager} after a failure to bind. + */ + private int mConnectionRetryCount; + + private int mCurrentChannelNumber; + private int mCurrentBand; + + public RadioStreamProducer(Context context) { + super(context); + mConverter = new RadioConverter(context); + } + + @Override + public void start() { + super.start(); + + mReceiver = new RadioActionReceiver(); + mContext.registerReceiver(mReceiver, new IntentFilter(RADIO_INTENT_ACTION)); + + bindRadioService(); + } + + @Override + public void stop() { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "stop()"); + } + + mHandler.removeCallbacks(mServiceConnectionRetry); + + mContext.unregisterReceiver(mReceiver); + mReceiver = null; + + mContext.unbindService(mServiceConnection); + super.stop(); + } + + /** + * Binds to the RadioService and returns {@code true} if the connection was successful. + */ + private boolean bindRadioService() { + Intent radioService = new Intent(); + radioService.setComponent(new ComponentName( + mContext.getString(R.string.car_radio_component_package), + mContext.getString(R.string.car_radio_component_service))); + + boolean bound = + !mContext.bindService(radioService, mServiceConnection, Context.BIND_AUTO_CREATE); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "bindRadioService(). Connected to radio service: " + bound); + } + + return bound; + } + + /** + * A {@link BroadcastReceiver} that listens for Intents that have the action + * {@link #RADIO_INTENT_ACTION} and corresponding parses the action event within it to effect + * radio playback. + */ + private class RadioActionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (mRadioManager == null || !RADIO_INTENT_ACTION.equals(intent.getAction())) { + return; + } + + int radioAction = intent.getIntExtra(RADIO_ACTION_EXTRA, -1); + if (radioAction == -1) { + return; + } + + switch (radioAction) { + case ACTION_SEEK_FORWARD: + try { + mRadioManager.seekForward(); + } catch (RemoteException e) { + Log.e(TAG, "Seek forward exception: " + e.getMessage()); + } + break; + + case ACTION_SEEK_BACKWARD: + try { + mRadioManager.seekBackward(); + } catch (RemoteException e) { + Log.e(TAG, "Seek backward exception: " + e.getMessage()); + } + break; + + case ACTION_PLAY: + try { + mRadioManager.unMute(); + } catch (RemoteException e) { + Log.e(TAG, "Radio play exception: " + e.getMessage()); + } + break; + + case ACTION_STOP: + case ACTION_PAUSE: + try { + mRadioManager.mute(); + } catch (RemoteException e) { + Log.e(TAG, "Radio pause exception: " + e.getMessage()); + } + break; + + default: + // Do nothing. + } + } + } + + /** + * A {@link IRadioCallback} that will be notified of various state changes in the radio station. + * Upon these changes, it will push a new {@link com.android.car.stream.StreamCard} to the + * Stream service. + */ + private final IRadioCallback.Stub mCallback = new IRadioCallback.Stub() { + @Override + public void onRadioStationChanged(RadioStation station) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onRadioStationChanged: " + station); + } + + mCurrentBand = station.getRadioBand(); + mCurrentChannelNumber = station.getChannelNumber(); + + if (mRadioManager == null) { + return; + } + + try { + boolean isPlaying = !mRadioManager.isMuted(); + postCard(mConverter.convert(station, isPlaying)); + } catch (RemoteException e) { + Log.e(TAG, "Post radio station changed error: " + e.getMessage()); + } + } + + @Override + public void onRadioMetadataChanged(RadioRds rds) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onRadioMetadataChanged: " + rds); + } + + // Ignore metadata changes because this will overwhelm the notifications. Instead, + // Only display the metadata that is retrieved in onRadioStationChanged(). + } + + @Override + public void onRadioBandChanged(int radioBand) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onRadioBandChanged: " + radioBand); + } + + if (mRadioManager == null) { + return; + } + + try { + RadioStation station = new RadioStation(mCurrentChannelNumber, + 0 /* subChannelNumber */, mCurrentBand, null /* rds */); + boolean isPlaying = !mRadioManager.isMuted(); + + postCard(mConverter.convert(station, isPlaying)); + } catch (RemoteException e) { + Log.e(TAG, "Post radio station changed error: " + e.getMessage()); + } + } + + @Override + public void onRadioMuteChanged(boolean isMuted) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onRadioMuteChanged(): " + isMuted); + } + + RadioStation station = new RadioStation(mCurrentChannelNumber, + 0 /* subChannelNumber */, mCurrentBand, null /* rds */); + + postCard(mConverter.convert(station, !isMuted)); + } + + @Override + public void onError(int status) { + Log.e(TAG, "Radio error: " + status); + } + }; + + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + mConnectionRetryCount = 0; + + mRadioManager = IRadioManager.Stub.asInterface(binder); + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onSeviceConnected(): " + mRadioManager); + } + + try { + mRadioManager.addRadioTunerCallback(mCallback); + + if (mRadioManager.isInitialized() && mRadioManager.hasFocus()) { + boolean isPlaying = !mRadioManager.isMuted(); + postCard(mConverter.convert(mRadioManager.getCurrentRadioStation(), isPlaying)); + } + } catch (RemoteException e) { + Log.e(TAG, "addRadioTunerCallback() error: " + e.getMessage()); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onServiceDisconnected(): " + name); + } + mRadioManager = null; + + // If the service has been disconnected, attempt to reconnect. + mHandler.removeCallbacks(mServiceConnectionRetry); + mHandler.postDelayed(mServiceConnectionRetry, SERVICE_CONNECTION_RETRY_DELAY_MS); + } + }; + + /** + * A {@link Runnable} that is responsible for attempting to reconnect to {@link IRadioManager}. + */ + private Runnable mServiceConnectionRetry = new Runnable() { + @Override + public void run() { + if (mRadioManager != null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "RadioService rebound by framework, no need to bind again"); + } + return; + } + + mConnectionRetryCount++; + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Rebinding disconnected RadioService, retry count: " + + mConnectionRetryCount); + } + + if (!bindRadioService()) { + mHandler.postDelayed(mServiceConnectionRetry, + mConnectionRetryCount * SERVICE_CONNECTION_RETRY_DELAY_MS); + } + } + }; +} diff --git a/src/com/android/car/stream/telecom/CurrentCallConverter.java b/src/com/android/car/stream/telecom/CurrentCallConverter.java new file mode 100644 index 0000000..39c07fd --- /dev/null +++ b/src/com/android/car/stream/telecom/CurrentCallConverter.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.telecom.Call; +import com.android.car.stream.CurrentCallExtension; + +import com.android.car.stream.R; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamConstants; + +/** + * A converter that creates a {@link StreamCard} for the current call events. + */ +public class CurrentCallConverter { + private static final int MUTE_BUTTON_REQUEST_CODE = 12; + private static final int CALL_BUTTON_REQUEST_CODE = 13; + + private PendingIntent mMuteAction; + private PendingIntent mUnMuteAction; + private PendingIntent mAcceptCallAction; + private PendingIntent mHangupCallAction; + + public CurrentCallConverter(Context context) { + mMuteAction = getCurrentCallAction(context, + TelecomConstants.ACTION_MUTE, MUTE_BUTTON_REQUEST_CODE); + mUnMuteAction = getCurrentCallAction(context, + TelecomConstants.ACTION_MUTE, MUTE_BUTTON_REQUEST_CODE); + + mAcceptCallAction = getCurrentCallAction(context, + TelecomConstants.ACTION_ACCEPT_CALL, CALL_BUTTON_REQUEST_CODE); + mHangupCallAction = getCurrentCallAction(context, + TelecomConstants.ACTION_HANG_UP_CALL, CALL_BUTTON_REQUEST_CODE); + } + + private PendingIntent getCurrentCallAction(Context context, + String action, int requestcode) { + Intent intent = new Intent(TelecomConstants.INTENT_ACTION_STREAM_CALL_CONTROL); + intent.setPackage(context.getPackageName()); + intent.putExtra(TelecomConstants.EXTRA_STREAM_CALL_ACTION, action); + PendingIntent pendingIntent = + PendingIntent.getBroadcast( + context, + requestcode, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ); + return pendingIntent; + } + + public StreamCard convert(Call call, Context context, boolean isMuted, + long callStartTime, String dialerPackage) { + long timeStamp = System.currentTimeMillis() - call.getDetails().getConnectTimeMillis(); + int callState = call.getState(); + String number = TelecomUtils.getNumber(call); + String displayName = TelecomUtils.getDisplayName(context, call); + long digits = Long.valueOf(number.replaceAll("[^0-9]", "")); + + PendingIntent dialerPendingIntent = + PendingIntent.getActivity( + context, + 0, + context.getPackageManager().getLaunchIntentForPackage(dialerPackage), + PendingIntent.FLAG_UPDATE_CURRENT + ); + + StreamCard.Builder builder = new StreamCard.Builder(StreamConstants.CARD_TYPE_CURRENT_CALL, + digits /* id */, timeStamp); + builder.setPrimaryText(displayName); + builder.setSecondaryText(getCallState(context, callState)); + + Bitmap phoneIcon = BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_phone); + builder.setPrimaryIcon(phoneIcon); + builder.setSecondaryIcon(TelecomUtils.createStreamCardSecondaryIcon(context, number)); + builder.setClickAction(dialerPendingIntent); + builder.setCardExtension(createCurrentCallExtension(context, callStartTime, displayName, + callState, isMuted, number)); + return builder.build(); + } + + private CurrentCallExtension createCurrentCallExtension(Context context, long callStartTime, + String displayName, int callState, boolean isMuted, String number) { + + Bitmap contactPhoto = TelecomUtils + .getContactPhotoFromNumber(context.getContentResolver(), number); + CurrentCallExtension extension + = new CurrentCallExtension(callStartTime, displayName, callState, isMuted, + contactPhoto, mMuteAction, mUnMuteAction, mAcceptCallAction, mHangupCallAction); + return extension; + } + + private String getCallState(Context context, int state) { + switch (state) { + case Call.STATE_ACTIVE: + return context.getString(R.string.ongoing_call); + case Call.STATE_DIALING: + return context.getString(R.string.dialing_call); + case Call.STATE_DISCONNECTING: + return context.getString(R.string.disconnecting_call); + case Call.STATE_RINGING: + return context.getString(R.string.notification_incoming_call); + default: + return context.getString(R.string.unknown); + } + } + +} diff --git a/src/com/android/car/stream/telecom/CurrentCallStreamProducer.java b/src/com/android/car/stream/telecom/CurrentCallStreamProducer.java new file mode 100644 index 0000000..2b774b7 --- /dev/null +++ b/src/com/android/car/stream/telecom/CurrentCallStreamProducer.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.os.AsyncTask; +import android.os.IBinder; +import android.os.SystemClock; +import android.telecom.Call; +import android.telecom.CallAudioState; +import android.telecom.TelecomManager; +import android.util.Log; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamProducer; +import com.android.car.stream.telecom.StreamInCallService.StreamInCallServiceBinder; + +/** + * A {@link StreamProducer} that listens for active call events and produces a {@link StreamCard} + */ +public class CurrentCallStreamProducer extends StreamProducer + implements StreamInCallService.InCallServiceCallback { + private static final String TAG = "CurrentCallProducer"; + + private StreamInCallService mInCallService; + private PhoneCallback mPhoneCallback; + private CurrentCallActionReceiver mCallActionReceiver; + private Call mCurrentCall; + private long mCurrentCallStartTime; + + private CurrentCallConverter mConverter; + private AsyncTask mUpdateStreamItemTask; + + private String mDialerPackage; + private TelecomManager mTelecomManager; + + public CurrentCallStreamProducer(Context context) { + super(context); + } + + @Override + public void start() { + super.start(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "current call producer started"); + } + mTelecomManager = (TelecomManager) mContext.getSystemService(Context.TELECOM_SERVICE); + mDialerPackage = mTelecomManager.getDefaultDialerPackage(); + mConverter = new CurrentCallConverter(mContext); + mPhoneCallback = new PhoneCallback(); + + Intent inCallServiceIntent = new Intent(mContext, StreamInCallService.class); + inCallServiceIntent.setAction(StreamInCallService.LOCAL_INCALL_SERVICE_BIND_ACTION); + mContext.bindService(inCallServiceIntent, mServiceConnection, Context.BIND_AUTO_CREATE); + } + + @Override + public void stop() { + mContext.unbindService(mServiceConnection); + super.stop(); + } + + private void acceptCall() { + synchronized (mTelecomManager) { + if (mCurrentCall != null && mCurrentCall.getState() == Call.STATE_RINGING) { + mCurrentCall.answer(0 /* videoState */); + } + } + } + + private void disconnectCall() { + synchronized (mTelecomManager) { + if (mCurrentCall != null) { + mCurrentCall.disconnect(); + } + } + } + + @Override + public void onCallAdded(Call call) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "on call added, state: " + call.getState()); + } + mCurrentCall = call; + updateStreamCard(mCurrentCall, mContext); + call.registerCallback(mPhoneCallback); + } + + @Override + public void onCallRemoved(Call call) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "on call removed, state: " + call.getState()); + } + call.unregisterCallback(mPhoneCallback); + updateStreamCard(call, mContext); + mCurrentCall = null; + } + + @Override + public void onCallAudioStateChanged(CallAudioState audioState) { + if (mCurrentCall != null && audioState != null) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "audio state changed, is muted? " + audioState.isMuted()); + } + updateStreamCard(mCurrentCall, mContext); + } + } + + private void clearUpdateStreamItemTask() { + if (mUpdateStreamItemTask != null) { + mUpdateStreamItemTask.cancel(false); + mUpdateStreamItemTask = null; + } + } + + private void updateStreamCard(final Call call, final Context context) { + // Only one update may be active at a time. + clearUpdateStreamItemTask(); + + mUpdateStreamItemTask = new AsyncTask<Void, Void, StreamCard>() { + @Override + protected StreamCard doInBackground(Void... voids) { + try { + return mConverter.convert(call, context, mInCallService.isMuted(), + mCurrentCallStartTime, mDialerPackage); + } catch (Exception e) { + Log.e(TAG, "Failed to create StreamItem.", e); + throw e; + } + } + + @Override + protected void onPostExecute(StreamCard card) { + if (call.getState() == Call.STATE_DISCONNECTED) { + removeCard(card); + } else { + postCard(card); + } + } + }.execute(); + } + + private class PhoneCallback extends Call.Callback { + @Override + public void onStateChanged(Call call, int state) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onStateChanged call: " + call + ", state: " + state); + } + + if (state == Call.STATE_ACTIVE) { + mCurrentCallStartTime = SystemClock.elapsedRealtime(); + } else { + mCurrentCallStartTime = 0; + } + + switch (state) { + // TODO: Determine if a HUD or stream card should be displayed. + case Call.STATE_RINGING: // Incoming call is ringing. + case Call.STATE_DIALING: // Outgoing call that is dialing. + case Call.STATE_ACTIVE: // Call is connected + case Call.STATE_DISCONNECTING: // Call is being disconnected + case Call.STATE_DISCONNECTED: // Call has finished. + updateStreamCard(call, mContext); + mCurrentCall = call; + break; + default: + } + } + } + + private class CurrentCallActionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String intentAction = intent.getAction(); + if (!TelecomConstants.INTENT_ACTION_STREAM_CALL_CONTROL.equals(intentAction)) { + return; + } + + String action = intent.getStringExtra(TelecomConstants.EXTRA_STREAM_CALL_ACTION); + switch (action) { + case TelecomConstants.ACTION_MUTE: + mInCallService.setMuted(true); + break; + case TelecomConstants.ACTION_UNMUTE: + mInCallService.setMuted(false); + break; + case TelecomConstants.ACTION_ACCEPT_CALL: + acceptCall(); + break; + case TelecomConstants.ACTION_HANG_UP_CALL: + disconnectCall(); + break; + default: + } + } + } + + private ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + StreamInCallServiceBinder binder = (StreamInCallServiceBinder) service; + mInCallService = binder.getService(); + mInCallService.setCallback(CurrentCallStreamProducer.this); + + if (mCallActionReceiver == null) { + mCallActionReceiver = new CurrentCallActionReceiver(); + mContext.registerReceiver(mCallActionReceiver, + new IntentFilter(TelecomConstants.INTENT_ACTION_STREAM_CALL_CONTROL)); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mInCallService = null; + } + }; +} diff --git a/src/com/android/car/stream/telecom/RecentCallConverter.java b/src/com/android/car/stream/telecom/RecentCallConverter.java new file mode 100644 index 0000000..57c65bc --- /dev/null +++ b/src/com/android/car/stream/telecom/RecentCallConverter.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import com.android.car.stream.R; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamConstants; + +public class RecentCallConverter { + + /** + * Creates a StreamCard of type {@link StreamConstants#CARD_TYPE_RECENT_CALL} + * @return + */ + public StreamCard createStreamCard(Context context, String number, long timestamp) { + StreamCard.Builder builder = new StreamCard.Builder(StreamConstants.CARD_TYPE_RECENT_CALL, + Long.parseLong(number), timestamp); + String displayName = TelecomUtils.getDisplayName(context, number); + + builder.setPrimaryText(displayName); + builder.setSecondaryText(context.getString(R.string.recent_call)); + builder.setDescription(context.getString(R.string.recent_call)); + Bitmap phoneIcon = BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_phone); + + builder.setPrimaryIcon(phoneIcon); + builder.setSecondaryIcon(TelecomUtils.createStreamCardSecondaryIcon(context, number)); + builder.setClickAction(createCallPendingIntent(context, number)); + return builder.build(); + } + + private PendingIntent createCallPendingIntent(Context context, String number) { + Intent callIntent = new Intent(Intent.ACTION_DIAL); + callIntent.setData(Uri.parse("tel: " + number)); + callIntent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); + return PendingIntent.getActivity(context, 0, callIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + } +} diff --git a/src/com/android/car/stream/telecom/RecentCallStreamProducer.java b/src/com/android/car/stream/telecom/RecentCallStreamProducer.java new file mode 100644 index 0000000..9581226 --- /dev/null +++ b/src/com/android/car/stream/telecom/RecentCallStreamProducer.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.provider.CallLog; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.util.Log; +import com.android.car.stream.StreamCard; +import com.android.car.stream.StreamProducer; + +import java.util.ArrayList; +import java.util.List; + +/** + * Loads recent calls from the call log and produces a {@link StreamCard} for each entry. + */ +public class RecentCallStreamProducer extends StreamProducer + implements Loader.OnLoadCompleteListener<Cursor> { + private static final String TAG = "RecentCallProducer"; + private static final long RECENT_CALL_TIME_RANGE = 6 * DateUtils.HOUR_IN_MILLIS; + + /** Number of call log items to query for */ + private static final int CALL_LOG_QUERY_LIMIT = 1; + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private CursorLoader mCursorLoader; + private StreamCard mCurrentStreamCard; + private long mCurrentNumber; + private RecentCallConverter mConverter = new RecentCallConverter(); + + public RecentCallStreamProducer(Context context) { + super(context); + mCursorLoader = createCallLogLoader(); + } + + @Override + public void start() { + super.start(); + if (!hasReadCallLogPermission()) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Could not onStart RecentCallStreamProducer, permissions not granted"); + } + return; + } + + if (!mCursorLoader.isStarted()) { + mCursorLoader.startLoading(); + } + } + + @Override + public void stop() { + if (mCursorLoader.isStarted()) { + mCursorLoader.stopLoading(); + removeCard(mCurrentStreamCard); + mCurrentStreamCard = null; + mCurrentNumber = 0; + } + super.stop(); + } + + @Override + public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + + int column = cursor.getColumnIndex(CallLog.Calls.NUMBER); + String number = cursor.getString(column); + column = cursor.getColumnIndex(CallLog.Calls.DATE); + long callTimeMs = cursor.getLong(column); + // Display if we have a phone number, and the call was within 6hours. + number = number.replaceAll("[^0-9]", ""); + long timestamp = System.currentTimeMillis(); + long digits = Long.parseLong(number); + + if (!TextUtils.isEmpty(number) && + (timestamp - callTimeMs) < RECENT_CALL_TIME_RANGE) { + if (mCurrentStreamCard == null || mCurrentNumber != digits) { + removeCard(mCurrentStreamCard); + mCurrentStreamCard = mConverter.createStreamCard(mContext, number, timestamp); + mCurrentNumber = digits; + postCard(mCurrentStreamCard); + } + } + } + + private boolean hasReadCallLogPermission() { + return mContext.checkSelfPermission(android.Manifest.permission.READ_CALL_LOG) + == PackageManager.PERMISSION_GRANTED; + } + + /** + * Creates a CursorLoader for Call data. + * Note: NOT to be used with LoaderManagers. + */ + private CursorLoader createCallLogLoader() { + // We need to check for NULL explicitly otherwise entries with where READ is NULL + // may not match either the query or its negation. + // We consider the calls that are not yet consumed (i.e. IS_READ = 0) as "new". + StringBuilder where = new StringBuilder(); + List<String> selectionArgs = new ArrayList<String>(); + + String selection = where.length() > 0 ? where.toString() : null; + Uri uri = CallLog.Calls.CONTENT_URI.buildUpon() + .appendQueryParameter(CallLog.Calls.LIMIT_PARAM_KEY, + Integer.toString(CALL_LOG_QUERY_LIMIT)) + .build(); + CursorLoader loader = new CursorLoader(mContext, uri, null, selection, + selectionArgs.toArray(EMPTY_STRING_ARRAY), CallLog.Calls.DEFAULT_SORT_ORDER); + loader.registerListener(0, this /* OnLoadCompleteListener */); + return loader; + } + +} diff --git a/src/com/android/car/stream/telecom/StreamInCallService.java b/src/com/android/car/stream/telecom/StreamInCallService.java new file mode 100644 index 0000000..fef7155 --- /dev/null +++ b/src/com/android/car/stream/telecom/StreamInCallService.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.telecom.Call; +import android.telecom.CallAudioState; +import android.telecom.InCallService; +import android.util.Log; + +/** + * {@link InCallService} to listen for incoming calls and changes in call state. + */ +public class StreamInCallService extends InCallService { + public static final String LOCAL_INCALL_SERVICE_BIND_ACTION = "stream_incall_service_action"; + private static final String TAG = "StreamInCallService"; + private final IBinder mBinder = new StreamInCallServiceBinder(); + + private InCallServiceCallback mCallback; + + /** + * Callback interface to receive changes in the call state. + */ + public interface InCallServiceCallback { + void onCallAdded(Call call); + + void onCallRemoved(Call call); + + void onCallAudioStateChanged(CallAudioState audioState); + } + + public class StreamInCallServiceBinder extends Binder { + StreamInCallService getService() { + return StreamInCallService.this; + } + } + + public void setCallback(InCallServiceCallback callback) { + mCallback = callback; + } + + @Override + public IBinder onBind(Intent intent) { + // This service can be bound by the framework or a local stream producer. + // Check the action and return the appropriate IBinder. + if (LOCAL_INCALL_SERVICE_BIND_ACTION.equals(intent.getAction())) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onBind with action: LOCAL_INCALL_SERVICE_BIND_ACTION," + + " returning StreamInCallServiceBinder"); + } + return mBinder; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "onBind without action specified, returning InCallService"); + } + return super.onBind(intent); + } + + @Override + public void onCallAdded(Call call) { + if (mCallback != null) { + mCallback.onCallAdded(call); + } + } + + @Override + public void onCallRemoved(Call call) { + if (mCallback != null) { + mCallback.onCallRemoved(call); + } + } + + @Override + public void onCallAudioStateChanged(CallAudioState audioState) { + if (mCallback != null) { + mCallback.onCallAudioStateChanged(audioState); + } + super.onCallAudioStateChanged(audioState); + } + + public boolean isMuted() { + CallAudioState audioState = getCallAudioState(); + return audioState != null && audioState.isMuted(); + } +} diff --git a/src/com/android/car/stream/telecom/TelecomConstants.java b/src/com/android/car/stream/telecom/TelecomConstants.java new file mode 100644 index 0000000..c5189fc --- /dev/null +++ b/src/com/android/car/stream/telecom/TelecomConstants.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +public class TelecomConstants { + public static final String INTENT_ACTION_STREAM_CALL_CONTROL + = "com.google.android.car.stream.telecom.CALL_CONTROL"; + public static final String EXTRA_STREAM_CALL_ACTION + = "com.google.android.car.stream.telecom.ACTION"; + + public static final String ACTION_MUTE = "mute"; + public static final String ACTION_UNMUTE = "unmute"; + public static final String ACTION_HANG_UP_CALL = "hang_up_call"; + public static final String ACTION_ACCEPT_CALL = "accept_call"; +} diff --git a/src/com/android/car/stream/telecom/TelecomUtils.java b/src/com/android/car/stream/telecom/TelecomUtils.java new file mode 100644 index 0000000..3d56e70 --- /dev/null +++ b/src/com/android/car/stream/telecom/TelecomUtils.java @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2016, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.stream.telecom; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Rect; +import android.net.Uri; +import android.provider.ContactsContract; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.telecom.Call; +import android.telecom.GatewayInfo; +import android.telephony.PhoneNumberUtils; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.LruCache; +import com.android.car.apps.common.CircleBitmapDrawable; +import com.android.car.apps.common.LetterTileDrawable; +import com.android.car.stream.R; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Locale; + +/** + * Telecom related utility methods. + */ +public class TelecomUtils { + private static final int LRU_CACHE_SIZE = 4194304; /** 4 mb **/ + + private static final String[] CONTACT_ID_PROJECTION = new String[] { + ContactsContract.PhoneLookup.DISPLAY_NAME, + ContactsContract.PhoneLookup.TYPE, + ContactsContract.PhoneLookup.LABEL, + ContactsContract.PhoneLookup._ID + }; + + private static String sVoicemailNumber; + + private static LruCache<String, Bitmap> sContactPhotoNumberCache; + private static LruCache<Long, Bitmap> sContactPhotoIdCache; + private static HashMap<String, String> sContactNameCache; + private static HashMap<String, Integer> sContactIdCache; + private static HashMap<String, String> sFormattedNumberCache; + private static HashMap<String, String> sDisplayNameCache; + + /** + * Create a round bitmap icon to represent the call. If a contact photo does not exist, + * a letter tile will be used instead. + */ + public static Bitmap createStreamCardSecondaryIcon(Context context, String number) { + Resources res = context.getResources(); + Bitmap largeIcon + = TelecomUtils.getContactPhotoFromNumber(context.getContentResolver(), number); + if (largeIcon == null) { + LetterTileDrawable ltd = new LetterTileDrawable(res); + String name = TelecomUtils.getDisplayName(context, number); + ltd.setContactDetails(name, number); + ltd.setIsCircular(true); + int size = res.getDimensionPixelSize(R.dimen.stream_card_secondary_icon_dimen); + largeIcon = ltd.toBitmap(size); + } + + return new CircleBitmapDrawable(res, largeIcon) + .toBitmap(res.getDimensionPixelSize(R.dimen.stream_card_secondary_icon_dimen)); + } + + + /** + * Fetch contact photo by number from local cache. + * + * @param number + * @return Contact photo if it's in the cache, otherwise null. + */ + @Nullable + public static Bitmap getCachedContactPhotoFromNumber(String number) { + if (number == null) { + return null; + } + + if (sContactPhotoNumberCache == null) { + sContactPhotoNumberCache = new LruCache<String, Bitmap>(LRU_CACHE_SIZE) { + @Override + protected int sizeOf(String key, Bitmap value) { + return value.getByteCount(); + } + }; + } + return sContactPhotoNumberCache.get(number); + } + + @WorkerThread + public static Bitmap getContactPhotoFromNumber(ContentResolver contentResolver, String number) { + if (number == null) { + return null; + } + + Bitmap photo = getCachedContactPhotoFromNumber(number); + if (photo != null) { + return photo; + } + + int id = getContactIdFromNumber(contentResolver, number); + if (id == 0) { + return null; + } + photo = getContactPhotoFromId(contentResolver, id); + if (photo != null) { + sContactPhotoNumberCache.put(number, photo); + } + return photo; + } + + /** + * Return the contact id for the given contact id + * @param id the contact id to get the photo for + * @return the contact photo if it is found, null otherwise. + */ + public static Bitmap getContactPhotoFromId(ContentResolver contentResolver, long id) { + if (sContactPhotoIdCache == null) { + sContactPhotoIdCache = new LruCache<Long, Bitmap>(LRU_CACHE_SIZE) { + @Override + protected int sizeOf(Long key, Bitmap value) { + return value.getByteCount(); + } + }; + } else if (sContactPhotoIdCache.get(id) != null) { + return sContactPhotoIdCache.get(id); + } + + Uri photoUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id); + InputStream photoDataStream = ContactsContract.Contacts.openContactPhotoInputStream( + contentResolver, photoUri, true); + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferQualityOverSpeed = true; + // Scaling will be handled by later. We shouldn't scale multiple times to avoid + // quality lost due to multiple potential scaling up and down. + options.inScaled = false; + + Rect nullPadding = null; + Bitmap photo = BitmapFactory.decodeStream(photoDataStream, nullPadding, options); + if (photo != null) { + photo.setDensity(Bitmap.DENSITY_NONE); + sContactPhotoIdCache.put(id, photo); + } + return photo; + } + + /** + * Return the contact id for the given phone number. + * @param number Caller phone number + * @return the contact id if it is found, 0 otherwise. + */ + public static int getContactIdFromNumber(ContentResolver cr, String number) { + if (number == null || number.isEmpty()) { + return 0; + } + if (sContactIdCache == null) { + sContactIdCache = new HashMap<>(); + } else if (sContactIdCache.containsKey(number)) { + return sContactIdCache.get(number); + } + + Uri uri = Uri.withAppendedPath( + ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(number)); + Cursor cursor = cr.query(uri, CONTACT_ID_PROJECTION, null, null, null); + + try { + if (cursor != null && cursor.moveToFirst()) { + int id = cursor.getInt(cursor.getColumnIndex(ContactsContract.PhoneLookup._ID)); + sContactIdCache.put(number, id); + return id; + } + } + finally { + if (cursor != null) { + cursor.close(); + } + } + return 0; + } + + public static String getDisplayName(Context context, String number) { + return getDisplayName(context, number, (Uri)null); + } + + public static String getDisplayName(Context context, Call call) { + // A call might get created before its children are added. In that case, the display name + // would go from "Unknown" to "Conference call" therefore we don't want to cache it. + if (call.getChildren() != null && call.getChildren().size() > 0) { + return context.getString(R.string.conference_call); + } + return getDisplayName(context, getNumber(call), getGatewayInfoOriginalAddress(call)); + } + + private static Uri getGatewayInfoOriginalAddress(Call call) { + if (call == null || call.getDetails() == null) { + return null; + } + GatewayInfo gatewayInfo = call.getDetails().getGatewayInfo(); + + if (gatewayInfo != null && gatewayInfo.getOriginalAddress() != null) { + return gatewayInfo.getGatewayAddress(); + } + return null; + } + + /** + * Return the phone number of the call. This CAN return null under certain circumstances such + * as if the incoming number is hidden. + */ + public static String getNumber(Call call) { + if (call == null || call.getDetails() == null) { + return null; + } + + Uri gatewayInfoOriginalAddress = getGatewayInfoOriginalAddress(call); + if (gatewayInfoOriginalAddress != null) { + return gatewayInfoOriginalAddress.getSchemeSpecificPart(); + } + + if (call.getDetails().getHandle() != null) { + return call.getDetails().getHandle().getSchemeSpecificPart(); + } + return null; + } + + private static String getContactNameFromNumber(ContentResolver cr, String number) { + if (sContactNameCache == null) { + sContactNameCache = new HashMap<>(); + } else if (sContactNameCache.containsKey(number)) { + return sContactNameCache.get(number); + } + + Uri uri = Uri.withAppendedPath( + ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); + + Cursor cursor = null; + String name = null; + try { + cursor = cr.query(uri, + new String[] {ContactsContract.PhoneLookup.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + name = cursor.getString(0); + sContactNameCache.put(number, name); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return name; + } + + private static String getDisplayName( + Context context, String number, Uri gatewayOriginalAddress) { + if (sDisplayNameCache == null) { + sDisplayNameCache = new HashMap<>(); + } else { + if (sDisplayNameCache.containsKey(number)) { + return sDisplayNameCache.get(number); + } + } + + if (TextUtils.isEmpty(number)) { + return context.getString(R.string.unknown); + } + ContentResolver cr = context.getContentResolver(); + String name; + if (number.equals(getVoicemailNumber(context))) { + name = context.getString(R.string.voicemail); + } else { + name = getContactNameFromNumber(cr, number); + } + + if (name == null) { + name = getFormattedNumber(context, number); + } + if (name == null && gatewayOriginalAddress != null) { + name = gatewayOriginalAddress.getSchemeSpecificPart(); + } + if (name == null) { + name = context.getString(R.string.unknown); + } + sDisplayNameCache.put(number, name); + return name; + } + + public static String getVoicemailNumber(Context context) { + if (sVoicemailNumber == null) { + TelephonyManager tm = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + sVoicemailNumber = tm.getVoiceMailNumber(); + } + return sVoicemailNumber; + } + + public static String getFormattedNumber(Context context, @Nullable String number) { + if (TextUtils.isEmpty(number)) { + return ""; + } + + if (sFormattedNumberCache == null) { + sFormattedNumberCache = new HashMap<>(); + } else { + if (sFormattedNumberCache.containsKey(number)) { + return sFormattedNumberCache.get(number); + } + } + + String countryIso = getSimRegionCode(context); + String e164 = PhoneNumberUtils.formatNumberToE164(number, countryIso); + String formattedNumber = PhoneNumberUtils.formatNumber(number, e164, countryIso); + formattedNumber = TextUtils.isEmpty(formattedNumber) ? number : formattedNumber; + sFormattedNumberCache.put(number, formattedNumber); + return formattedNumber; + } + + /** + * Wrapper around TelephonyManager.getSimCountryIso() that will fallback to locale or USA ISOs + * if it finds bogus data. + */ + private static String getSimRegionCode(Context context) { + TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + + // This can be null on some phones (and is null on robolectric default TelephonyManager) + String countryIso = telephonyManager.getSimCountryIso(); + if (TextUtils.isEmpty(countryIso) || countryIso.length() != 2) { + countryIso = Locale.getDefault().getCountry(); + if (countryIso == null || countryIso.length() != 2) { + countryIso = "US"; + } + } + + return countryIso.toUpperCase(Locale.US); + } +}
\ No newline at end of file |