summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRakesh Iyer <rni@google.com>2016-10-19 23:53:15 -0700
committerRakesh Iyer <rni@google.com>2016-10-20 00:27:29 -0700
commit214c10ceef4ba736d8a7b3cbef06c27826822946 (patch)
treee77f8cca27a528e62e270e2ba491b330949a9f9a
parent5ff2120e0fb1f7f38cfe4209f9864ed3c9b1bc6b (diff)
downloadStream-214c10ceef4ba736d8a7b3cbef06c27826822946.tar.gz
Move stream.
Original sha1: f802a6f645c66e914ecfe2c1fd06e4dd1aadc6ef Credits: victorchan@ Bug: 32118797 Test: Manual. Change-Id: I18d5e2a239947b0d6390598bb6a48d8c69cc2d3a
-rw-r--r--Android.mk65
-rw-r--r--AndroidManifest.xml80
-rw-r--r--res/drawable/ic_call_black.xml16
-rw-r--r--res/drawable/ic_call_missed.xml13
-rw-r--r--res/drawable/ic_mic.xml15
-rw-r--r--res/drawable/ic_mic_muted.xml18
-rw-r--r--res/drawable/ic_pause.xml13
-rw-r--r--res/drawable/ic_pause_light.xml13
-rw-r--r--res/drawable/ic_phone.xml9
-rw-r--r--res/drawable/ic_phone_hangup.xml9
-rw-r--r--res/drawable/ic_play_arrow.xml15
-rw-r--r--res/drawable/ic_play_arrow_light.xml15
-rw-r--r--res/drawable/ic_skip_next.xml12
-rw-r--r--res/drawable/ic_skip_previous.xml12
-rw-r--r--res/drawable/ic_stop.xml13
-rw-r--r--res/values/colors.xml19
-rw-r--r--res/values/dimens.xml6
-rw-r--r--res/values/strings.xml57
-rw-r--r--src/com/android/car/stream/PermissionsActivity.java171
-rw-r--r--src/com/android/car/stream/StreamApplication.java83
-rw-r--r--src/com/android/car/stream/StreamProducer.java99
-rw-r--r--src/com/android/car/stream/StreamService.java197
-rw-r--r--src/com/android/car/stream/StreamServiceConstants.java50
-rw-r--r--src/com/android/car/stream/media/MediaAppInfo.java161
-rw-r--r--src/com/android/car/stream/media/MediaConverter.java143
-rw-r--r--src/com/android/car/stream/media/MediaPlaybackMonitor.java342
-rw-r--r--src/com/android/car/stream/media/MediaStateManager.java248
-rw-r--r--src/com/android/car/stream/media/MediaStreamProducer.java238
-rw-r--r--src/com/android/car/stream/media/MediaUtils.java66
-rw-r--r--src/com/android/car/stream/notifications/StreamNotificationListenerService.java41
-rw-r--r--src/com/android/car/stream/radio/RadioConverter.java164
-rw-r--r--src/com/android/car/stream/radio/RadioFormatter.java93
-rw-r--r--src/com/android/car/stream/radio/RadioStreamProducer.java326
-rw-r--r--src/com/android/car/stream/telecom/CurrentCallConverter.java126
-rw-r--r--src/com/android/car/stream/telecom/CurrentCallStreamProducer.java234
-rw-r--r--src/com/android/car/stream/telecom/RecentCallConverter.java58
-rw-r--r--src/com/android/car/stream/telecom/RecentCallStreamProducer.java135
-rw-r--r--src/com/android/car/stream/telecom/StreamInCallService.java100
-rw-r--r--src/com/android/car/stream/telecom/TelecomConstants.java28
-rw-r--r--src/com/android/car/stream/telecom/TelecomUtils.java359
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 &#8226; </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