summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2022-03-23 19:30:23 +0000
committerAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2022-03-23 19:30:23 +0000
commit299675ea8501e14b4bfa7a9b4fb0715c92409c68 (patch)
treeeec36c9282186f4987f317e2599973bf09c84358
parent7cf3a16d0675915e691029ba1932935396ed8057 (diff)
parent977ef6ef432b9f93197a353e112a02fce00cba50 (diff)
downloadMessenger-simpleperf-release.tar.gz
Snap for 8346178 from 977ef6ef432b9f93197a353e112a02fce00cba50 to simpleperf-releasesimpleperf-release
Change-Id: I58dfcbfad83ef2bbda6323e124a32426dc82f670
-rw-r--r--Android.bp71
-rw-r--r--AndroidManifest.xml10
-rw-r--r--build.gradle98
-rw-r--r--res/drawable/car_ui_icon_toggle_mute.xml2
-rw-r--r--res/drawable/ic_launcher_icon.xml35
-rw-r--r--res/drawable/ic_mute.xml28
-rw-r--r--res/drawable/ic_play.xml17
-rw-r--r--res/drawable/ic_reply.xml27
-rw-r--r--res/drawable/ic_subtitle_play.xml25
-rw-r--r--res/drawable/ic_unmute.xml28
-rw-r--r--res/drawable/ui_icon_edit.xml25
-rw-r--r--res/layout/conversation_list_item.xml127
-rw-r--r--res/values/colors.xml2
-rw-r--r--res/values/config.xml4
-rw-r--r--res/values/dimens.xml8
-rw-r--r--res/values/strings.xml136
-rw-r--r--res/values/styles.xml21
-rw-r--r--src/com/android/car/messenger/core/interfaces/AppFactory.java14
-rw-r--r--src/com/android/car/messenger/core/interfaces/DataModel.java9
-rw-r--r--src/com/android/car/messenger/core/service/MessengerService.java2
-rw-r--r--src/com/android/car/messenger/core/shared/MessageConstants.java6
-rw-r--r--src/com/android/car/messenger/core/shared/NotificationHandler.java37
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java2
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java86
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java29
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java37
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java40
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java87
-rw-r--r--src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java6
-rw-r--r--src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java6
-rw-r--r--src/com/android/car/messenger/core/ui/shared/DateTimeView.java537
-rw-r--r--src/com/android/car/messenger/core/util/CarStateListener.java (renamed from src/com/android/car/messenger/impl/common/ProjectionStateListener.java)40
-rw-r--r--src/com/android/car/messenger/core/util/ConversationUtil.java52
-rw-r--r--src/com/android/car/messenger/core/util/VoiceUtil.java20
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java2
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java2
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java6
-rw-r--r--src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java11
-rw-r--r--src/com/android/car/messenger/impl/datamodels/RefreshLiveData.java47
-rw-r--r--src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java8
-rw-r--r--src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java4
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java54
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java22
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java135
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java1
-rw-r--r--tests/Android.bp43
-rw-r--r--tests/AndroidManifest.xml30
-rw-r--r--tests/src/com/android/car/messenger/BuildTest.java33
48 files changed, 1768 insertions, 304 deletions
diff --git a/Android.bp b/Android.bp
index d671cef..1d9412d 100644
--- a/Android.bp
+++ b/Android.bp
@@ -25,10 +25,16 @@ android_app {
resource_dirs: ["res"],
+ min_sdk_version: "30",
+
+ target_sdk_version: "31",
+
sdk_version: "system_current",
required: ["allowed_privapp_com.android.car.messenger"],
+ certificate: "platform",
+
overrides: ["messaging"],
optimize: {
@@ -48,7 +54,7 @@ android_app {
"androidx.preference_preference",
"androidx.recyclerview_recyclerview",
"car-assist-lib",
- "car-messaging-models",
+ "car-messenger-common",
"car-telephony-common",
"car-ui-lib",
"androidx.annotation_annotation",
@@ -64,3 +70,66 @@ android_app {
},
},
}
+
+android_test_helper_app {
+ name: "CarMessengerAppForTesting",
+
+ srcs: ["src/**/*.java"],
+
+ resource_dirs: ["res"],
+
+ min_sdk_version: "30",
+
+ target_sdk_version: "31",
+
+ sdk_version: "system_current",
+
+ required: ["allowed_privapp_com.android.car.messenger"],
+
+ certificate: "platform",
+
+ overrides: ["messaging"],
+
+ optimize: {
+ enabled: false,
+ },
+
+ privileged: true,
+
+ libs: ["android.car-system-stubs"],
+
+ // must be unbundled dependencies
+ static_libs: [
+ "androidx-constraintlayout_constraintlayout",
+ "androidx.lifecycle_lifecycle-common-java8",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.legacy_legacy-support-v4",
+ "androidx.preference_preference",
+ "androidx.recyclerview_recyclerview",
+ "car-assist-lib",
+ "car-messenger-common",
+ "car-telephony-common",
+ "car-ui-lib",
+ "androidx.annotation_annotation",
+ ],
+
+ dex_preopt: {
+ enabled: false,
+ },
+
+ product_variables: {
+ pdk: {
+ enabled: false,
+ },
+ },
+
+ // runtime cc library which is used by mockito-target-extended.
+ jni_libs: [
+ // For mockito extended
+ "libdexmakerjvmtiagent",
+ "libstaticjvmtiagent",
+ ],
+
+ // Enforce the jni libraries to be grouped into the APK file.
+ use_embedded_native_libs: true,
+}
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index b24137a..11014dd 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -16,7 +16,9 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="com.android.car.messenger">
+ package="com.android.car.messenger"
+ android:versionCode="10000"
+ android:versionName="1.0.0">
<application
android:name="com.android.car.messenger.impl.CarMessengerApp"
@@ -29,7 +31,8 @@
<activity
android:name=".core.ui.launcher.MessageLauncherActivity"
android:exported="true"
- android:screenOrientation="landscape">
+ android:screenOrientation="landscape"
+ android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -130,7 +133,4 @@
-->
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
- <uses-sdk
- android:minSdkVersion="30"
- android:targetSdkVersion="30" />
</manifest>
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..88e8ced
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion gradle.ext.aaosLatestSDK
+ defaultConfig {
+ applicationId "com.android.car.messenger"
+ minSdkVersion 30
+ targetSdkVersion gradle.ext.aaosLatestSDK
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ lintOptions {
+ abortOnError false
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ aidl.srcDirs = ['src']
+ renderscript.srcDirs = ['src']
+ res.srcDirs = ['res']
+ }
+
+ androidTest {
+ java.srcDirs = ['tests/src']
+ setRoot('tests')
+ }
+ }
+
+ testOptions {
+ animationsDisabled = true
+ }
+
+ buildTypes {
+ debug {
+ testCoverageEnabled = true
+ }
+ }
+
+ signingConfigs {
+ debug {
+ // The following signs the apk with platform key.
+ storeFile file('../../../../vendor/google/certs/devkeys/platform.keystore')
+ storePassword 'android'
+ }
+ }
+}
+
+dependencies {
+ implementation files(gradle.ext.lib_car_system_stubs)
+
+ implementation "androidx.constraintlayout:constraintlayout:2.0.0-beta8"
+
+ def lifecycle_version = "2.2.0"
+ implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
+ // Not available in 2.3+
+ implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
+
+ implementation "androidx.legacy:legacy-support-v4:1.0.0"
+ implementation "androidx.preference:preference:1.1.1"
+ implementation "androidx.annotation:annotation:1.0.1"
+
+ implementation project(":car-assist-lib")
+ implementation project(":car-messenger-common:model")
+ implementation project(":car-telephony-common")
+ implementation project(":car-ui-lib")
+
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test:core:1.4.0'
+ androidTestImplementation 'androidx.test:rules:1.4.0'
+ androidTestImplementation 'androidx.test:runner:1.4.0'
+ androidTestImplementation 'com.google.truth:truth:1.1.3'
+ androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito-inline:2.28.1'
+}
diff --git a/res/drawable/car_ui_icon_toggle_mute.xml b/res/drawable/car_ui_icon_toggle_mute.xml
index faa8cd5..9b87dc1 100644
--- a/res/drawable/car_ui_icon_toggle_mute.xml
+++ b/res/drawable/car_ui_icon_toggle_mute.xml
@@ -20,7 +20,7 @@
android:viewportHeight="32"
android:viewportWidth="32">
<path
- android:fillColor="@color/car_ui_toolbar_menu_item_icon_color"
+ android:fillColor="@color/secondary_text_color"
android:pathData="M2.295,0L0,2.2788L7.7252,10.004H2.602V19.701H9.0667L17.1475,
27.7818V19.4263L22.5131,24.7919C21.8343,25.1636 21.1394,25.4707
20.3798,25.697V29.0263C21.996,28.6545 23.499,27.9919 24.8566,27.1354L29.7212,32L32,
diff --git a/res/drawable/ic_launcher_icon.xml b/res/drawable/ic_launcher_icon.xml
index bf04507..caac05a 100644
--- a/res/drawable/ic_launcher_icon.xml
+++ b/res/drawable/ic_launcher_icon.xml
@@ -14,28 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="44dp"
- android:height="44dp"
- android:viewportHeight="44"
- android:viewportWidth="44">
- <path
- android:fillColor="#1A73E8"
- android:pathData="M22,44C34.1503,44 44,34.1503 44,22C44,9.8497 34.1503,0 22,0C9.8497,
- 0 0,9.8497 0,22C0,34.1503 9.8497,44 22,44Z" />
- <path
- android:fillColor="#ffffff"
- android:pathData="M30,13H8.995C8.145,13 7.685,13.73 8.245,14.5L11,19.25V27.75C11,29.9225
- 12.59,31.75 14.75,31.75H30C32.16,31.75 34,29.9225 34,27.75V17C34,14.8275 32.16,13 30,13Z" />
- <path
- android:fillColor="#8AB4F8"
- android:pathData="M29.75,19H15.25C14.615,19 14,18.6225 14,18C14,17.3775 14.615,17
- 15.25,17H29.75C30.385,17 31,17.3775 31,18C31,18.6225 30.385,19 29.75,19Z" />
- <path
- android:fillColor="#8AB4F8"
- android:pathData="M29.75,23H15.25C14.615,23 14,22.6225 14,22C14,21.3775 14.615,21
- 15.25,21H29.75C30.385,21 31,21.3775 31,22C31,22.6225 30.385,23 29.75,23Z" />
- <path
- android:fillColor="#8AB4F8"
- android:pathData="M25.75,27H15.25C14.62,27 14,26.6225 14,26C14,25.3775 14.62,25
- 15.25,25H25.75C26.38,25 27,25.3775 27,26C27,26.6225 26.38,27 25.75,27Z" />
+ android:width="80dp"
+ android:height="80dp"
+ android:viewportWidth="80"
+ android:viewportHeight="80">
+ <path
+ android:pathData="M40,40m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"
+ android:fillColor="#1E63BD"/>
+ <path
+ android:pathData="M56.6667,21.1667H23.3333C21.0417,21.1667 19.1667,23.0417 19.1667,25.3333V62.8333L27.5,54.5H56.6667C58.9583,54.5 60.8333,52.625 60.8333,50.3333V25.3333C60.8333,23.0417 58.9583,21.1667 56.6667,21.1667ZM56.6667,50.3333H23.3333V25.3333H56.6667V50.3333ZM29.5833,35.75H33.75V39.9167H29.5833V35.75ZM37.9167,35.75H42.0833V39.9167H37.9167V35.75ZM46.25,35.75H50.4167V39.9167H46.25V35.75Z"
+ android:fillColor="#ffffff"/>
</vector>
+
diff --git a/res/drawable/ic_mute.xml b/res/drawable/ic_mute.xml
new file mode 100644
index 0000000..8e5825e
--- /dev/null
+++ b/res/drawable/ic_mute.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="72dp"
+ android:height="72dp"
+ android:viewportWidth="72"
+ android:viewportHeight="72">
+ <path
+ android:pathData="M16,0L56,0A16,16 0,0 1,72 16L72,56A16,16 0,0 1,56 72L16,72A16,16 0,0 1,0 56L0,16A16,16 0,0 1,16 0z"
+ android:fillColor="#3C4043"/>
+ <path
+ android:pathData="M36,54.3333C38.0259,54.3333 39.6667,52.6924 39.6667,50.6666H32.3334C32.3334,52.6924 33.9742,54.3333 36,54.3333ZM47,43.3333V34.1666C47,28.5291 44.0025,23.8266 38.75,22.5799V21.3333C38.75,19.8116 37.5217,18.5833 36,18.5833C34.4784,18.5833 33.25,19.8116 33.25,21.3333V22.5799C27.9975,23.8266 25,28.5291 25,34.1666V43.3333L21.3334,46.9999V48.8333H50.6667V46.9999L47,43.3333ZM43.3334,45.1666H28.6667V34.1666C28.6667,29.6108 31.4442,25.9166 36,25.9166C40.5559,25.9166 43.3334,29.6108 43.3334,34.1666V45.1666Z"
+ android:fillColor="#BDC1C6"/>
+</vector>
diff --git a/res/drawable/ic_play.xml b/res/drawable/ic_play.xml
index 5e47e70..3c1e820 100644
--- a/res/drawable/ic_play.xml
+++ b/res/drawable/ic_play.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2020 The Android Open Source Project
+ ~ Copyright (C) 2021 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.
@@ -15,11 +15,12 @@
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="11dp"
- android:height="14dp"
- android:viewportHeight="14"
- android:viewportWidth="11">
- <path
- android:fillColor="@color/secondary_text_color"
- android:pathData="M2,3.64L7.27,7L2,10.36V3.64ZM0,0V14L11,7L0,0Z" />
+ android:width="21dp"
+ android:height="26dp"
+ android:viewportWidth="21"
+ android:viewportHeight="26">
+ <path
+ android:fillColor="@color/secondary_text_color"
+ android:pathData="M0.6667,0.1667V25.8334L20.8333,13.0001L0.6667,0.1667Z"
+ />
</vector>
diff --git a/res/drawable/ic_reply.xml b/res/drawable/ic_reply.xml
new file mode 100644
index 0000000..33dcc9c
--- /dev/null
+++ b/res/drawable/ic_reply.xml
@@ -0,0 +1,27 @@
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="76dp"
+ android:height="76dp"
+ android:viewportWidth="76"
+ android:viewportHeight="76">
+ <path
+ android:pathData="M16,0L60,0A16,16 0,0 1,76 16L76,60A16,16 0,0 1,60 76L16,76A16,16 0,0 1,0 60L0,16A16,16 0,0 1,16 0z"
+ android:fillColor="#3C4043"/>
+ <path
+ android:pathData="M45.3333,34.3333H28.5217L32.5,30.355L35.085,27.77L32.5,25.1667L21.5,36.1667L32.5,47.1667L35.085,44.5817L32.5,41.9783L28.5217,38H45.3333C48.3583,38 50.8333,40.475 50.8333,43.5V50.8333H54.5V43.5C54.5,38.44 50.3933,34.3333 45.3333,34.3333Z"
+ android:fillColor="#ffffff"/>
+</vector>
diff --git a/res/drawable/ic_subtitle_play.xml b/res/drawable/ic_subtitle_play.xml
new file mode 100644
index 0000000..b14bfb9
--- /dev/null
+++ b/res/drawable/ic_subtitle_play.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="14.67dp"
+ android:height="18.67dp"
+ android:viewportHeight="14"
+ android:viewportWidth="11">
+ <path
+ android:fillColor="@color/secondary_text_color"
+ android:pathData="M2,3.64L7.27,7L2,10.36V3.64ZM0,0V14L11,7L0,0Z" />
+</vector>
diff --git a/res/drawable/ic_unmute.xml b/res/drawable/ic_unmute.xml
new file mode 100644
index 0000000..af90346
--- /dev/null
+++ b/res/drawable/ic_unmute.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="72dp"
+ android:height="72dp"
+ android:viewportWidth="72"
+ android:viewportHeight="72">
+ <path
+ android:pathData="M16,0L56,0A16,16 0,0 1,72 16L72,56A16,16 0,0 1,56 72L16,72A16,16 0,0 1,0 56L0,16A16,16 0,0 1,16 0z"
+ android:fillColor="#66B5FF"/>
+ <path
+ android:pathData="M50.6667,48.2741L28.3733,25.2749L23.6708,20.4258L21.3333,22.7541L26.4667,27.8874L26.4758,27.8966C25.5133,29.7116 25,31.8566 25,34.1666V43.3333L21.3333,46.9999V48.8333H46.505L50.1717,52.4999L52.5,50.1624L50.6667,48.2741ZM36,54.3333C38.0258,54.3333 39.6667,52.6924 39.6667,50.6666H32.3333C32.3333,52.6924 33.9742,54.3333 36,54.3333ZM47,40.9133V34.1666C47,28.5291 44.0025,23.8266 38.75,22.5799V21.3333C38.75,19.8116 37.5217,18.5833 36,18.5833C34.4783,18.5833 33.25,19.8116 33.25,21.3333V22.5799C32.9842,22.6441 32.7275,22.7174 32.4708,22.7999C32.2783,22.8641 32.095,22.9283 31.9117,23.0016C31.9117,23.0016 31.9025,23.0016 31.9025,23.0108C31.8933,23.0108 31.8842,23.0199 31.875,23.0199C31.4533,23.1849 31.0408,23.3774 30.6375,23.5883C30.6283,23.5883 30.6192,23.5974 30.61,23.5974L47,40.9133Z"
+ android:fillColor="#000000"/>
+</vector>
diff --git a/res/drawable/ui_icon_edit.xml b/res/drawable/ui_icon_edit.xml
new file mode 100644
index 0000000..aee80ca
--- /dev/null
+++ b/res/drawable/ui_icon_edit.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="48dp"
+ android:height="48dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?android:attr/colorPrimaryDark"
+ android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
+</vector>
diff --git a/res/layout/conversation_list_item.xml b/res/layout/conversation_list_item.xml
index 248184f..09e106a 100644
--- a/res/layout/conversation_list_item.xml
+++ b/res/layout/conversation_list_item.xml
@@ -28,7 +28,7 @@ limitations under the License.
android:layout_marginEnd="@dimen/unread_icon_marginEnd"
android:contentDescription="@string/cd_unread"
android:scaleType="centerCrop"
- android:src="@color/unread_dot_color"
+ android:src="@color/unread_color"
app:layout_constraintBottom_toBottomOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/icon"
app:layout_constraintTop_toTopOf="@id/icon" />
@@ -46,41 +46,44 @@ limitations under the License.
tools:src="@color/car_red_500a" />
<ImageView
- android:id="@+id/last_action_icon_view"
- android:layout_width="@dimen/subtitle_icon_width"
- android:layout_height="0dp"
- android:contentDescription="@string/cd_icon_indicating_the_last_action"
- android:scaleType="centerInside"
- android:src="@drawable/car_ui_icon_reply"
- app:layout_constraintBottom_toBottomOf="@id/time_text"
- app:layout_constraintStart_toStartOf="@id/guideline_begin"
- app:layout_constraintTop_toBottomOf="@id/title"
- app:layout_constraintTop_toTopOf="@id/time_text" />
-
- <ImageView
android:id="@+id/reply_action_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/cd_reply_action_button"
android:scaleType="center"
- android:src="@drawable/car_ui_icon_reply"
+ android:src="@drawable/ic_reply"
app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toStartOf="@id/mute_action_button"
- app:layout_constraintStart_toEndOf="@id/guideline_end"
+ app:layout_constraintEnd_toEndOf="parent"
+ android:visibility="visible"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <ImageView
+ android:id="@+id/play_action_button"
+ android:layout_width="100dp"
+ android:layout_height="match_parent"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/cd_play_action_button"
+ android:scaleX=".4"
+ android:scaleY=".4"
+ android:src="@drawable/ic_play"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ tools:visibility="gone"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/mute_action_button"
android:layout_width="0dp"
android:layout_height="match_parent"
+ android:visibility="gone"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/cd_mute_button"
android:scaleType="center"
android:src="@drawable/car_ui_icon_toggle_mute"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toEndOf="@id/reply_action_button"
+ app:layout_constraintStart_toEndOf="@id/guideline_end"
app:layout_constraintTop_toTopOf="parent" />
<TextView
@@ -89,48 +92,102 @@ limitations under the License.
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/message_history_text_margin_end"
android:singleLine="true"
+ tools:text="Ashley Bae"
+ app:layout_constraintVertical_chainStyle="packed"
android:theme="@style/Theme.Messaging.BidiText"
- app:layout_constraintBottom_toTopOf="@+id/text"
+ android:ellipsize="end"
+ app:layout_constraintBottom_toTopOf="@+id/preview"
app:layout_constraintEnd_toEndOf="@id/guideline_end"
app:layout_constraintStart_toStartOf="@id/guideline_begin"
app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintVertical_chainStyle="packed" />
+ app:layout_constraintBottom_toBottomOf="parent" />
+
+ <ImageView
+ android:id="@+id/last_action_icon_view"
+ android:layout_width="@dimen/subtitle_icon_width"
+ android:layout_height="0dp"
+ android:contentDescription="@string/cd_icon_indicating_the_last_action"
+ android:scaleType="centerInside"
+ android:src="@drawable/car_ui_icon_reply"
+ android:visibility="gone"
+ app:layout_constraintHorizontal_chainStyle="packed"
+ android:layout_marginEnd="@dimen/message_history_icons_margin"
+ app:layout_constraintBottom_toBottomOf="@id/preview"
+ app:layout_constraintStart_toStartOf="@id/title"
+ app:layout_constraintTop_toBottomOf="@id/title"
+ app:layout_constraintEnd_toStartOf="@id/preview"
+ app:layout_constraintTop_toTopOf="@id/preview" />
<TextView
- android:id="@+id/time_text"
+ android:id="@+id/preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/message_history_icons_margin"
android:singleLine="true"
+ app:layout_constraintVertical_bias="0.0"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/last_action_icon_view"
app:layout_constraintTop_toBottomOf="@id/title"
- tools:text="14:02 PM" />
+ app:layout_constraintEnd_toStartOf="@id/preview_dot"
+ tools:visibility="visible"
+ android:ellipsize="end"
+ android:maxLength="40"
+ tools:text="Let this be the preview. Lots of preview with
+ a whole lot of various texts, one that is quite long in every way.
+ To verify that it still fits in the end" />
<TextView
- android:id="@+id/dot"
+ android:id="@+id/preview_dot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_history_icons_margin"
+ android:layout_marginEnd="@dimen/message_history_icons_margin"
+ app:layout_goneMarginStart="0dp"
+ app:layout_goneMarginEnd="0dp"
android:singleLine="true"
android:text="@string/dot"
- android:visibility="gone"
- app:layout_constraintBottom_toBottomOf="@id/text"
- app:layout_constraintStart_toEndOf="@id/time_text"
- app:layout_constraintTop_toTopOf="@id/text"
- tools:visibility="visible" />
+ app:layout_constraintEnd_toStartOf="@id/text_metadata"
+ app:layout_constraintBottom_toBottomOf="@id/preview"
+ app:layout_constraintStart_toEndOf="@id/preview"
+ app:layout_constraintTop_toTopOf="@id/preview"
+ tools:visibility="gone" />
+
+ <TextView
+ android:id="@+id/text_metadata"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:singleLine="true"
+ android:maxLength="20"
+ app:layout_constraintStart_toEndOf="@id/preview_dot"
+ app:layout_constraintTop_toBottomOf="@id/title"
+ app:layout_constraintEnd_toStartOf="@id/text_metadata_dot"
+ tools:visibility="visible"
+ tools:text="2 more messages" />
<TextView
- android:id="@id/text"
+ android:id="@+id/text_metadata_dot"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:singleLine="true"
+ android:text="@string/dot"
+ android:visibility="visible"
+ android:layout_marginStart="6dp"
+ tools:visibility="visible"
+ app:layout_constraintBottom_toBottomOf="@id/text_metadata"
+ app:layout_constraintStart_toEndOf="@id/text_metadata"
+ app:layout_constraintTop_toTopOf="@id/text_metadata"
+ app:layout_constraintEnd_toStartOf="@id/date_time_view" />
+
+ <com.android.car.messenger.core.ui.shared.DateTimeView
+ android:id="@+id/date_time_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginEnd="@dimen/message_history_text_margin_end"
- android:layout_marginStart="@dimen/message_history_icons_margin"
+ android:layout_marginStart="6dp"
android:singleLine="true"
- app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintBottom_toBottomOf="@id/text_metadata"
app:layout_constraintEnd_toEndOf="@id/guideline_end"
- app:layout_constraintStart_toEndOf="@id/dot"
+ app:layout_constraintStart_toEndOf="@id/text_metadata_dot"
app:layout_constraintTop_toBottomOf="@id/title"
- tools:text="Replied" />
+ tools:text="6 min" />
<View
android:id="@+id/play_action_touch_view"
@@ -165,5 +222,5 @@ limitations under the License.
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
- app:layout_constraintGuide_end="200dp" />
+ app:layout_constraintGuide_end="100dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 17d5cb2..1d8527e 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -34,7 +34,7 @@
<color name="divider_color_light">#38FFFFFF</color>
<color name="primary_icon_color">@color/icon_tint</color>
<color name="letter_tile_default_color">#cccccc</color>
- <color name="unread_dot_color">#66B5FF</color>
+ <color name="unread_color">#66B5FF</color>
<color name="letter_tile_font_color">#ffffff</color>
diff --git a/res/values/config.xml b/res/values/config.xml
index 644c7ff..60283a4 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -16,11 +16,11 @@
-->
<resources>
<bool name="group_avatar_fill_background">false</bool>
- <bool name="direct_send_supported">false</bool>
+ <bool name="direct_send_supported">true</bool>
+ <bool name="direct_reply_supported">true</bool>
<bool name="ttr_conversation_supported">false</bool>
<!--
- The maximum number of individual avatars used for group avatar.
A number between 1 and 4 is required.
When the value is 1, the first avatar is used for the group avatar.
When the value is 2-4, the first nth avatars make up the group avatar,
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 42d721b..863fd2d 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -64,7 +64,9 @@
<!-- Contact Avatar -->
<item name="letter_spacing_display3" format="float" type="dimen">0.0</item>
- <item name="letter_spacing_body1" format="float" type="dimen">0.0</item>
- <item name="letter_spacing_body2" format="float" type="dimen">0.0</item>
- <item name="letter_spacing_body3" format="float" type="dimen">0.0</item>
+ <item name="letter_spacing_body1" format="float" type="dimen">0.03</item>
+ <item name="letter_spacing_body2" format="float" type="dimen">0.045</item>
+ <item name="letter_spacing_body3" format="float" type="dimen">0.06</item>
+
+ <dimen name="dot_size">32sp</dimen>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 837ad3e..17c90fe 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -15,27 +15,46 @@
~ limitations under the License.
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
- <!-- Application name [CHAR LIMIT=30] -->
+ <!-- Phrase describing that there is a new message [CHAR LIMIT=20] -->
<plurals name="new_message">
- <item quantity="one" translatable="false">New message</item>
- <item quantity="other" translatable="false">
+ <item quantity="one">New message</item>
+ <item quantity="other">
<xliff:g example="2" id="count">%d</xliff:g> messages</item>
</plurals>
+ <!-- Phrase describing the number of messages [CHAR LIMIT=20] -->
+ <plurals name="no_of_message">
+ <item quantity="zero">No message</item>
+ <item quantity="one">1 message</item>
+ <item quantity="other">
+ <xliff:g example="2" id="count">%d</xliff:g> messages</item>
+ </plurals>
+
+ <!-- Phrase describing a case where there are more messages other than the one shown the user. [CHAR LIMIT=30] -->
+ <plurals name="more_message">
+ <item quantity="one">One more message</item>
+ <item quantity="other">
+ <xliff:g example="2" id="count">%d</xliff:g> more messages</item>
+ </plurals>
+
<!-- Button text for when disconnected from Bluetooth [CHAR LIMIT=40] -->
- <string name="app_name" translatable="false">Car Messenger</string>
+ <string name="app_name" translatable="false">SMS</string>
<!-- Button text for connecting to Bluetooth [CHAR LIMIT=40] -->
<string name="bluetooth_disconnected" translatable="false">Bluetooth disconnected</string>
<!-- Status when no new messages[CHAR LIMIT=40] -->
+ <string name="no_messages" translatable="false">No messages</string>
+
<string name="connect_bluetooth_button_text" translatable="false">Connect to Bluetooth</string>
<!-- Status when replied [CHAR LIMIT=40] -->
- <string name="no_new_messages" translatable="false">No new messages</string>
+ <string name="replied" translatable="false">Replied</string>
+
+ <!-- Status - Tap To Read Aloud [CHAR LIMIT=40] -->
+ <string name="tap_to_read_aloud">Tap to read aloud</string>
<!-- Dot separator [CHAR LIMIT=1] -->
- <string name="replied" translatable="false">Replied</string>
<string name="dot" translatable="false">·</string>
<string name="action_reply" translatable="false">Reply</string>
<string name="action_mute" translatable="false">Mute</string>
@@ -67,4 +86,109 @@
<string name="cd_loading_info_icon">Loading Info Icon</string>
<!-- Mute button [CHAR LIMIT=40] -->
<string name="cd_reply_action_button">Reply Action Button</string>
+
+ <!-- Mute button [CHAR LIMIT=40] -->
+ <string name="cd_play_action_button">Play Action Button</string>
+
+ <!-- A string denoting the current point in time that should be as short as possible. Abbreviations are preferred to full strings as this might be shown repetitively. It is used in the header of notifications. [CHAR LIMIT=8]-->
+ <string name="now_string_shortest">now</string>
+
+ <!-- Overflow title text for too many participants in a conversation [CHAR LIMIT=40] -->
+ <string name="participant_overflow_text">, and %d more</string>
+
+ <!-- Phrase describing a time duration using minutes that is as short as possible, preferrably one character. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=6] -->
+ <plurals name="duration_minutes_shortest">
+ <item quantity="one"><xliff:g example="1" id="count" translatable="false">%d</xliff:g>m</item>
+ <item quantity="other"><xliff:g example="2" id="count" translatable="false">%d</xliff:g>m</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using hours that is as short as possible, preferrably one character. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=6] -->
+ <plurals name="duration_hours_shortest">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g>h</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g>h</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using days that is as short as possible, preferrably one character. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=6] -->
+ <plurals name="duration_days_shortest">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g>d</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g>d</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using years that is as short as possible, preferrably one character. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=6] -->
+ <plurals name="duration_years_shortest">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g>y</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g>y</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using minutes that is as short as possible, preferrably one character. This version should be a future point in time. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=14] -->
+ <plurals name="duration_minutes_shortest_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g>m</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g>m</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using hours that is as short as possible, preferrably one character. This version should be a future point in time. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=14] -->
+ <plurals name="duration_hours_shortest_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g>h</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g>h</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using days that is as short as possible, preferrably one character. This version should be a future point in time. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=14] -->
+ <plurals name="duration_days_shortest_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g>d</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g>d</item>
+ </plurals>
+
+ <!-- Phrase describing a time duration using years that is as short as possible, preferrably one character. This version should be a future point in time. If the language needs a space in between the integer and the unit, please also integrate it in the string, but preferably it should not have a space in between.[CHAR LIMIT=14] -->
+ <plurals name="duration_years_shortest_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g>y</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g>y</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using minutes in the past that is not shown on the screen but used for accessibility. [CHAR LIMIT=40] -->
+ <plurals name="duration_minutes_relative">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g> minute ago</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g> minutes ago</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using hours in the past that is not shown on the screen but used for accessibility. [CHAR LIMIT=40] -->
+ <plurals name="duration_hours_relative">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g> hour ago</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g> hours ago</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using days in the past that is not shown on the screen but used for accessibility. [CHAR LIMIT=40] -->
+ <plurals name="duration_days_relative">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g> day ago</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g> days ago</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using years in the past that is not shown on the screen but used for accessibility. [CHAR LIMIT=40] -->
+ <plurals name="duration_years_relative">
+ <item quantity="one"><xliff:g example="1" id="count">%d</xliff:g> year ago</item>
+ <item quantity="other"><xliff:g example="2" id="count">%d</xliff:g> years ago</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using minutes that is not shown on the screen but used for accessibility. This version should be a future point in time. [CHAR LIMIT=NONE] -->
+ <plurals name="duration_minutes_relative_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g> minute</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g> minutes</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using hours that is not shown on the screen but used for accessibility. This version should be a future point in time. [CHAR LIMIT=NONE] -->
+ <plurals name="duration_hours_relative_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g> hour</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g> hours</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using days that is not shown on the screen but used for accessibility. This version should be a future point in time. [CHAR LIMIT=NONE] -->
+ <plurals name="duration_days_relative_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g> day</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g> days</item>
+ </plurals>
+
+ <!-- Phrase describing a relative time using years that is not shown on the screen but used for accessibility. This version should be a future point in time. [CHAR LIMIT=NONE] -->
+ <plurals name="duration_years_relative_future">
+ <item quantity="one">in <xliff:g example="1" id="count">%d</xliff:g> year</item>
+ <item quantity="other">in <xliff:g example="2" id="count">%d</xliff:g> years</item>
+ </plurals>
</resources>
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 2568a05..f332ebb 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -15,20 +15,29 @@
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Message history -->
- <style name="TextAppearance.MessageHistoryTitle" parent="TextAppearance.Body1" />
+ <style name="TextAppearance.MessageHistoryTitle" parent="TextAppearance.Body1" >
+ </style>
+
+ <style name="TextAppearance.MessageHistoryTextPreviewUnread" parent="TextAppearance.Body3">
+ <item name="android:textColor">@color/unread_color</item>
+ <item name="android:alpha">0.72</item>
+ </style>
- <style name="TextAppearance.MessageHistorySubtitle" parent="TextAppearance.Body3">
+ <style name="TextAppearance.MessageHistoryTextPreview" parent="TextAppearance.Body3">
<item name="android:textColor">@color/secondary_text_color</item>
+ <item name="android:alpha">0.72</item>
</style>
+
<!-- Customized text color for unread messages can be added here -->
<style name="TextAppearance.MessageHistoryUnreadTitle"
parent="TextAppearance.MessageHistoryTitle">
<item name="android:textStyle">bold</item>
</style>
- <style name="TextAppearance.MessageHistoryUnreadSubtitle"
- parent="TextAppearance.MessageHistorySubtitle">
+ <style name="TextAppearance.MessageHistoryUnreadMetadata"
+ parent="TextAppearance.MessageHistoryTextPreview">
<item name="android:textStyle">bold</item>
+ <item name="android:textColor">@color/unread_color</item>
</style>
<style name="Widget.Button" parent="android:Widget.DeviceDefault.Button">
@@ -57,6 +66,7 @@
<item name="android:fontFamily">roboto-regular</item>
<item name="android:textColor">@color/primary_text_color</item>
<item name="android:textAlignment">viewStart</item>
+ <item name="android:textFontWeight">400</item>
</style>
<style name="TextAppearance.Display3" parent="TextAppearance">
@@ -67,16 +77,19 @@
<style name="TextAppearance.Body1" parent="TextAppearance">
<item name="android:textSize">32sp</item>
<item name="android:letterSpacing">@dimen/letter_spacing_body1</item>
+ <item name="android:lineHeight">40sp</item>
</style>
<style name="TextAppearance.Body2" parent="TextAppearance">
<item name="android:textSize">28sp</item>
<item name="android:letterSpacing">@dimen/letter_spacing_body2</item>
+ <item name="android:lineHeight">36sp</item>
</style>
<style name="TextAppearance.Body3" parent="TextAppearance">
<item name="android:textSize">24sp</item>
<item name="android:letterSpacing">@dimen/letter_spacing_body3</item>
+ <item name="android:lineHeight">32sp</item>
</style>
<!-- Styles for ControlBar -->
diff --git a/src/com/android/car/messenger/core/interfaces/AppFactory.java b/src/com/android/car/messenger/core/interfaces/AppFactory.java
index a88078d..683098b 100644
--- a/src/com/android/car/messenger/core/interfaces/AppFactory.java
+++ b/src/com/android/car/messenger/core/interfaces/AppFactory.java
@@ -20,6 +20,9 @@ import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.car.messenger.core.util.CarStateListener;
/**
* The AppFactory provides singleton instances to be used throughout the app.
@@ -34,6 +37,9 @@ public abstract class AppFactory {
protected static boolean sRegistered;
protected static boolean sInitialized;
+ // Context is required to initialize
+ @Nullable protected CarStateListener mCarStateListener;
+
/** Returns the Factory instance for the Application. */
@NonNull
public static AppFactory get() {
@@ -53,6 +59,14 @@ public abstract class AppFactory {
sInstance = factory;
}
+ /** Gets the Car State Listener */
+ public final CarStateListener getCarStateListener() {
+ if (mCarStateListener == null) {
+ mCarStateListener = new CarStateListener(AppFactory.get().getContext());
+ }
+ return mCarStateListener;
+ }
+
/** Returns context most appropriate for UI context-requiring tasks. */
@NonNull
public abstract Context getContext();
diff --git a/src/com/android/car/messenger/core/interfaces/DataModel.java b/src/com/android/car/messenger/core/interfaces/DataModel.java
index a2bc91b..b634d4c 100644
--- a/src/com/android/car/messenger/core/interfaces/DataModel.java
+++ b/src/com/android/car/messenger/core/interfaces/DataModel.java
@@ -16,9 +16,8 @@
package com.android.car.messenger.core.interfaces;
-import androidx.lifecycle.LiveData;
-
import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.models.UserAccount;
@@ -42,10 +41,10 @@ public interface DataModel {
LiveData<Collection<UserAccount>> getAccounts();
/**
- * Call this to reload user account live data. This is useful when resuming an activity, to
- * ensure no account changes was missed.
+ * Call this to reload data. This is useful when resuming an activity, to ensure no account
+ * changes was missed or other changes were missed.
*/
- void refreshUserAccounts();
+ void refresh();
/**
* Get collection of conversations for the given account.
diff --git a/src/com/android/car/messenger/core/service/MessengerService.java b/src/com/android/car/messenger/core/service/MessengerService.java
index aef64d6..9d70039 100644
--- a/src/com/android/car/messenger/core/service/MessengerService.java
+++ b/src/com/android/car/messenger/core/service/MessengerService.java
@@ -92,7 +92,7 @@ public class MessengerService extends Service {
private void subscribeToNotificationUpdates() {
DataModel dataModel = AppFactory.get().getDataModel();
- dataModel.getUnreadMessages().observeForever(NotificationHandler::postOrRemoveNotification);
+ dataModel.getUnreadMessages().observeForever(NotificationHandler::postNotification);
dataModel.onConversationRemoved().observeForever(NotificationHandler::removeNotification);
}
diff --git a/src/com/android/car/messenger/core/shared/MessageConstants.java b/src/com/android/car/messenger/core/shared/MessageConstants.java
index 5ba2779..40c34e8 100644
--- a/src/com/android/car/messenger/core/shared/MessageConstants.java
+++ b/src/com/android/car/messenger/core/shared/MessageConstants.java
@@ -37,6 +37,12 @@ public final class MessageConstants {
*/
@NonNull public static final String LAST_REPLY_TIMESTAMP_EXTRA = "LAST_REPLY_TIMESTAMP_EXTRA";
+ /**
+ * This is added as an extra in the {@link com.android.car.messenger.common.Conversation} to
+ * indicate what the last reply is, if any
+ */
+ @NonNull public static final String LAST_REPLY_TEXT_EXTRA = "LAST_REPLY_TEXT_EXTRA";
+
/** Used to reply to message. */
@NonNull public static final String ACTION_REPLY = "ACTION_REPLY";
diff --git a/src/com/android/car/messenger/core/shared/NotificationHandler.java b/src/com/android/car/messenger/core/shared/NotificationHandler.java
index 0087c58..1371b00 100644
--- a/src/com/android/car/messenger/core/shared/NotificationHandler.java
+++ b/src/com/android/car/messenger/core/shared/NotificationHandler.java
@@ -20,7 +20,9 @@ import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_ACCOU
import android.app.Notification;
import android.app.NotificationManager;
+import android.app.PendingIntent;
import android.content.Context;
+import android.content.Intent;
import android.service.notification.StatusBarNotification;
import androidx.annotation.NonNull;
@@ -31,6 +33,7 @@ import com.android.car.messenger.R;
import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.service.MessengerService;
+import com.android.car.messenger.core.ui.launcher.MessageLauncherActivity;
import com.android.car.messenger.core.util.L;
import com.android.car.messenger.core.util.VoiceUtil;
@@ -44,17 +47,8 @@ public class NotificationHandler {
private NotificationHandler() {}
- /** Posts, removes or updates a notification based on a conversation */
- public static void postOrRemoveNotification(@NonNull Conversation conversation) {
- if (conversation.isMuted()) {
- removeNotification(conversation.getId());
- } else {
- postNotification(conversation);
- }
- }
-
- /* Posts or updates a notification based on a conversation */
- private static void postNotification(Conversation conversation) {
+ /** Posts or updates a notification based on a conversation */
+ public static void postNotification(Conversation conversation) {
int userAccountId = conversation.getExtras().getInt(EXTRA_ACCOUNT_ID, 0);
if (userAccountId == 0) {
L.w(
@@ -66,14 +60,31 @@ public class NotificationHandler {
Context context = AppFactory.get().getContext();
NotificationManager notificationManager =
context.getSystemService(NotificationManager.class);
- String channelId = MessengerService.MESSAGE_CHANNEL_ID;
+
+ String channelId =
+ conversation.isMuted()
+ ? MessengerService.SILENT_MESSAGE_CHANNEL_ID
+ : MessengerService.MESSAGE_CHANNEL_ID;
Notification notification =
ConversationPayloadHandler.createNotificationFromConversation(
context, channelId, tapToReadConversation, R.drawable.ic_message, null);
-
+ notification.contentIntent = createContentIntent();
notificationManager.notify(tapToReadConversation.getId().hashCode(), notification);
}
+ private static PendingIntent createContentIntent() {
+ Context context = AppFactory.get().getContext();
+ Intent intent =
+ new Intent(context, MessageLauncherActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ return PendingIntent.getActivity(
+ context,
+ /* requestCode= */ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
/**
* Posts a notification in the foreground for Tap To Read
*
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java
index b54c82c..eeb6786 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java
@@ -38,6 +38,8 @@ public class ConversationItemAdapter extends RecyclerView.Adapter<ConversationIt
void onConversationItemClicked(@NonNull Conversation conversation);
/** Callback to start tap to reply voice interaction for conversation item */
void onReplyIconClicked(@NonNull Conversation conversation);
+ /** Callback to start tap to read voice interaction for conversation item */
+ void onPlayIconClicked(@NonNull Conversation conversation);
}
@NonNull private final List<UIConversationItem> mUIConversationItems = new ArrayList<>();
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java
index 67900c0..ecfcfb9 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java
@@ -16,9 +16,10 @@
package com.android.car.messenger.core.ui.conversationlist;
-import android.graphics.Color;
+import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
+import android.graphics.drawable.Drawable;
import androidx.recyclerview.widget.RecyclerView;
import android.view.View;
import android.widget.ImageView;
@@ -33,6 +34,7 @@ import com.android.car.messenger.core.interfaces.DataModel;
import com.android.car.messenger.core.shared.NotificationHandler;
import com.android.car.messenger.core.ui.conversationlist.ConversationItemAdapter.OnConversationItemClickListener;
import com.android.car.messenger.core.ui.shared.CircularOutputlineProvider;
+import com.android.car.messenger.core.ui.shared.DateTimeView;
import com.android.car.messenger.core.ui.shared.ViewUtils;
/**
@@ -49,13 +51,16 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
@NonNull private final View mPlayMessageTouchView;
@NonNull private final ImageView mAvatarView;
@NonNull private final TextView mTitleView;
- @NonNull private final TextView mTimeTextView;
- @NonNull private final TextView mTextView;
+ @NonNull private final TextView mPreviewTextView;
+ @NonNull private final TextView mTextMetadataView;
@NonNull private final TextView mDotSeparatorView;
+ @NonNull private final TextView mTextMetadataDotView;
@NonNull private final ImageView mSubtitleIconView;
@NonNull private final ImageView mMuteActionButton;
@NonNull private final View mReplyActionButton;
+ @NonNull private final View mPlayActionButton;
@NonNull private final View mUnreadIconIndicator;
+ @NonNull private final DateTimeView mDateTimeView;
@NonNull private final View mDivider;
/** Conversation Item View Holder constructor */
@@ -67,13 +72,17 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
mPlayMessageTouchView = itemView.findViewById(R.id.play_action_touch_view);
mAvatarView = itemView.findViewById(R.id.icon);
mTitleView = itemView.findViewById(R.id.title);
- mTimeTextView = itemView.findViewById(R.id.time_text);
- mTextView = itemView.findViewById(R.id.text);
- mDotSeparatorView = itemView.findViewById(R.id.dot);
+ mPreviewTextView = itemView.findViewById(R.id.preview);
+ mTextMetadataView = itemView.findViewById(R.id.text_metadata);
+ mTextMetadataDotView = itemView.findViewById(R.id.text_metadata_dot);
+ mDateTimeView = itemView.findViewById(R.id.date_time_view);
+ mDateTimeView.setShowRelativeTime(true);
+ mDotSeparatorView = itemView.findViewById(R.id.preview_dot);
mUnreadIconIndicator = itemView.findViewById(R.id.unread_indicator);
mSubtitleIconView = itemView.findViewById(R.id.last_action_icon_view);
mMuteActionButton = itemView.findViewById(R.id.mute_action_button);
mReplyActionButton = itemView.findViewById(R.id.reply_action_button);
+ mPlayActionButton = itemView.findViewById(R.id.play_action_button);
mDivider = itemView.findViewById(R.id.divider);
mAvatarView.setOutlineProvider(CircularOutputlineProvider.get());
mUnreadIconIndicator.setOutlineProvider(CircularOutputlineProvider.get());
@@ -83,14 +92,16 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
/** Binds the view holder with relevant data. */
public void bind(@NonNull UIConversationItem uiData) {
mTitleView.setText(uiData.getTitle());
- mTimeTextView.setText(uiData.getReadableTime());
- mTextView.setText(uiData.getSubtitle());
+ mPreviewTextView.setText(uiData.getTextPreview());
+ mTextMetadataView.setText(uiData.getTextMetadata());
+ mDateTimeView.setTime(uiData.mLastMessageTimestamp);
mAvatarView.setImageDrawable(uiData.getAvatar());
mPlayMessageTouchView.setOnClickListener(null);
mSubtitleIconView.setImageDrawable(uiData.getSubtitleIcon());
- boolean showDotSeparatorSubtitle =
- !uiData.getReadableTime().isEmpty() && !uiData.getSubtitle().isEmpty();
- ViewUtils.setVisible(mDotSeparatorView, showDotSeparatorSubtitle);
+ boolean showPreviewSeparator = !uiData.getTextPreview().isEmpty();
+ boolean showMetadataSeparator = !uiData.getTextMetadata().isEmpty();
+ ViewUtils.setVisible(mDotSeparatorView, showPreviewSeparator);
+ ViewUtils.setVisible(mTextMetadataDotView, showMetadataSeparator);
ViewUtils.setVisible(mSubtitleIconView, uiData.getSubtitleIcon() != null);
setUpActionButton(uiData);
setUpTextAppearance(uiData);
@@ -99,27 +110,45 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
}
private void setUpTextAppearance(@NonNull UIConversationItem uiData) {
+ Context context = AppFactory.get().getContext();
if (uiData.shouldUseUnreadTheme()) {
mTitleView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadTitle);
- mTimeTextView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadSubtitle);
- mTextView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadSubtitle);
+ mPreviewTextView.setTextAppearance(
+ R.style.TextAppearance_MessageHistoryTextPreviewUnread);
+ mTextMetadataView.setTextAppearance(
+ R.style.TextAppearance_MessageHistoryUnreadMetadata);
+ mTextMetadataDotView.setTextAppearance(
+ R.style.TextAppearance_MessageHistoryUnreadMetadata);
+ mDateTimeView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadMetadata);
mDotSeparatorView.setTextAppearance(
- R.style.TextAppearance_MessageHistoryUnreadSubtitle);
+ R.style.TextAppearance_MessageHistoryTextPreviewUnread);
+ updateSubtitleIcon(context.getColor(R.color.unread_color));
ViewUtils.setVisible(mUnreadIconIndicator, /* visible= */ true);
} else {
mTitleView.setTextAppearance(R.style.TextAppearance_MessageHistoryTitle);
- mTimeTextView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle);
- mTextView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle);
- mDotSeparatorView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle);
+ mPreviewTextView.setTextAppearance(R.style.TextAppearance_MessageHistoryTextPreview);
+ mTextMetadataView.setTextAppearance(R.style.TextAppearance_MessageHistoryTextPreview);
+ mTextMetadataDotView.setTextAppearance(
+ R.style.TextAppearance_MessageHistoryTextPreview);
+ mDateTimeView.setTextAppearance(R.style.TextAppearance_MessageHistoryTextPreview);
+ mDotSeparatorView.setTextAppearance(R.style.TextAppearance_MessageHistoryTextPreview);
+ updateSubtitleIcon(context.getColor(R.color.secondary_text_color));
ViewUtils.setVisible(mUnreadIconIndicator, /* visible= */ false);
}
+ mDotSeparatorView.setTextSize(context.getResources().getDimension(R.dimen.dot_size));
+ mTextMetadataDotView.setTextSize(context.getResources().getDimension(R.dimen.dot_size));
}
- private void updateMuteButton(boolean isMuted) {
- @ColorInt int color = isMuted ? Color.RED : Color.WHITE;
+ private void updateSubtitleIcon(@ColorInt int color) {
PorterDuffColorFilter porterDuffColorFilter =
new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP);
- mMuteActionButton.getDrawable().setColorFilter(porterDuffColorFilter);
+ mSubtitleIconView.getDrawable().setColorFilter(porterDuffColorFilter);
+ }
+
+ private void updateMuteButton(boolean isMuted) {
+ int drawableRes = isMuted ? R.drawable.ic_unmute : R.drawable.ic_mute;
+ Drawable drawable = AppFactory.get().getContext().getDrawable(drawableRes);
+ mMuteActionButton.setImageDrawable(drawable);
}
/** Recycles views. */
@@ -128,15 +157,23 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
}
private void setUpActionButton(@NonNull UIConversationItem uiData) {
- ViewUtils.setVisible(mDivider, uiData.shouldShowReplyIcon() || uiData.shouldShowMuteIcon());
+ ViewUtils.setVisible(
+ mDivider,
+ uiData.shouldShowReplyIcon()
+ || uiData.shouldShowMuteIcon()
+ || uiData.shouldShowPlayIcon());
ViewUtils.setVisible(mMuteActionButton, uiData.shouldShowMuteIcon());
ViewUtils.setVisible(mReplyActionButton, uiData.shouldShowReplyIcon());
+ ViewUtils.setVisible(mPlayActionButton, uiData.shouldShowPlayIcon());
if (uiData.shouldShowReplyIcon()) {
mReplyActionButton.setEnabled(true);
}
- if (uiData.shouldShowReplyIcon()) {
+ if (uiData.shouldShowMuteIcon()) {
mMuteActionButton.setEnabled(true);
}
+ if (uiData.shouldShowPlayIcon()) {
+ mPlayActionButton.setEnabled(true);
+ }
mPlayMessageTouchView.setOnClickListener(
view ->
@@ -147,6 +184,11 @@ public class ConversationItemViewHolder extends RecyclerView.ViewHolder {
view ->
mOnConversationItemClickListener.onReplyIconClicked(
uiData.getConversation()));
+
+ mPlayActionButton.setOnClickListener(
+ view ->
+ mOnConversationItemClickListener.onPlayIconClicked(
+ uiData.getConversation()));
mMuteActionButton.setOnClickListener(
view -> {
boolean mute = !uiData.isMuted();
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java
index ccc0396..78e6608 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java
@@ -16,13 +16,13 @@
package com.android.car.messenger.core.ui.conversationlist;
import android.app.Activity;
-import androidx.lifecycle.ViewModelProvider;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.lifecycle.ViewModelProvider;
import com.android.car.messenger.R;
import com.android.car.messenger.common.Conversation;
@@ -87,7 +87,7 @@ public class ConversationListFragment extends MessageListBaseFragment
|| conversationLog.getData().isEmpty()) {
mLoadingFrameLayout.showEmpty(
MessageConstants.INVALID_RES_ID,
- R.string.no_new_messages,
+ R.string.no_messages,
MessageConstants.INVALID_RES_ID);
setMenuItems();
} else {
@@ -110,6 +110,15 @@ public class ConversationListFragment extends MessageListBaseFragment
R.string.connect_bluetooth_button_text,
v -> startActivity(launchIntent),
true);
+ removeMenuItems();
+ }
+
+ private void removeMenuItems() {
+ Activity activity = getActivity();
+ if (activity == null || mToolbar == null) {
+ return;
+ }
+ mToolbar.setMenuItems(new ArrayList<>());
}
private void setMenuItems() {
@@ -117,15 +126,19 @@ public class ConversationListFragment extends MessageListBaseFragment
if (activity == null || mUserAccount == null || mToolbar == null) {
return;
}
+ if (!mToolbar.getMenuItems().isEmpty()) {
+ return;
+ }
if (!getResources().getBoolean(R.bool.direct_send_supported)) {
return;
}
MenuItem newMessageButton =
new MenuItem.Builder(activity)
- .setIcon(R.drawable.car_ui_icon_edit)
- .setTinted(true)
+ .setIcon(R.drawable.ui_icon_edit)
+ .setTinted(false)
.setShowIconAndTitle(true)
.setTitle(R.string.new_message)
+ .setPrimary(true)
.setOnClickListener(
item ->
VoiceUtil.voiceRequestGenericCompose(
@@ -152,6 +165,14 @@ public class ConversationListFragment extends MessageListBaseFragment
VoiceUtil.voiceRequestReplyConversation(requireActivity(), mUserAccount, conversation);
}
+ @Override
+ public void onPlayIconClicked(@NonNull Conversation conversation) {
+ if (mUserAccount == null) {
+ return;
+ }
+ VoiceUtil.voiceRequestReadConversation(requireActivity(), mUserAccount, conversation);
+ }
+
/**
* Get instance of Conversation Log fragment
*
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java
index 8ac0851..e16bff5 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java
@@ -18,17 +18,23 @@ package com.android.car.messenger.core.ui.conversationlist;
import android.annotation.SuppressLint;
import android.app.Application;
-import androidx.lifecycle.AndroidViewModel;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.MediatorLiveData;
+import android.car.drivingstate.CarUxRestrictions;
+import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MediatorLiveData;
+import androidx.lifecycle.Transformations;
+import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.interfaces.DataModel;
import com.android.car.messenger.core.models.UserAccount;
+import com.android.car.messenger.core.util.L;
+import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@@ -75,15 +81,34 @@ public class ConversationListViewModel extends AndroidViewModel {
MediatorLiveData<UIConversationLog> mutableLiveData = new MediatorLiveData<>();
mutableLiveData.postValue(UIConversationLog.getLoadingState());
mutableLiveData.addSource(
- mDataModel.getConversations(userAccount),
- list -> {
+ subscribeToConversations(userAccount),
+ pair -> {
+ CarUxRestrictions uxRestrictions = pair.first;
+ Collection<Conversation> list = pair.second;
List<UIConversationItem> data =
list.stream()
- .map(UIConversationItemConverter::convertToUIConversationItem)
+ .map(
+ conversation ->
+ UIConversationItemConverter
+ .convertToUIConversationItem(
+ conversation, uxRestrictions))
.collect(Collectors.toList());
UIConversationLog log = UIConversationLog.getLoadedState(data);
mutableLiveData.postValue(log);
});
return mutableLiveData;
}
+
+ private LiveData<Pair<CarUxRestrictions, Collection<Conversation>>> subscribeToConversations(
+ @NonNull UserAccount userAccount) {
+ final LiveData<Collection<Conversation>> liveData =
+ mDataModel.getConversations(userAccount);
+ return Transformations.switchMap(
+ AppFactory.get().getCarStateListener().getUxrRestrictions(),
+ uxRestrictions -> {
+ L.d("Got new ux restrictions: " + uxRestrictions);
+ return Transformations.map(
+ liveData, conversations -> new Pair<>(uxRestrictions, conversations));
+ });
+ }
}
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java
index bdd71e7..7381e91 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java
@@ -28,36 +28,42 @@ public class UIConversationItem {
@NonNull String mConversationId;
@NonNull String mTitle;
- @NonNull String mSubtitle;
+ @NonNull String mTextPreview;
@Nullable Drawable mSubtitleIcon;
- @NonNull String mReadableTime;
+ @NonNull String mTextMetadata;
+ long mLastMessageTimestamp;
@Nullable Drawable mAvatar;
boolean mIsMuted;
boolean mShowMuteIcon;
boolean mShowReplyIcon;
+ boolean mShowPlayIcon;
boolean mUseUnreadTheme;
@NonNull Conversation mConversation;
public UIConversationItem(
@NonNull String conversationId,
@NonNull String title,
- @NonNull String subtitle,
+ @NonNull String textPreview,
@Nullable Drawable subtitleIcon,
- @NonNull String readableTime,
+ @NonNull String textMetadata,
+ long lastMessageTimestamp,
@Nullable Drawable avatar,
boolean showMuteIcon,
boolean showReplyIcon,
+ boolean showPlayIcon,
boolean useUnreadTheme,
boolean isMuted,
@NonNull Conversation conversation) {
this.mConversationId = conversationId;
this.mTitle = title;
- this.mSubtitle = subtitle;
+ this.mTextPreview = textPreview;
this.mSubtitleIcon = subtitleIcon;
- this.mReadableTime = readableTime;
+ this.mTextMetadata = textMetadata;
+ this.mLastMessageTimestamp = lastMessageTimestamp;
this.mAvatar = avatar;
this.mShowMuteIcon = showMuteIcon;
this.mShowReplyIcon = showReplyIcon;
+ this.mShowPlayIcon = showPlayIcon;
this.mUseUnreadTheme = useUnreadTheme;
this.mIsMuted = isMuted;
this.mConversation = conversation;
@@ -75,10 +81,10 @@ public class UIConversationItem {
return mTitle;
}
- /** Returns subtitle for the conversation */
+ /** Returns text preview for the conversation */
@NonNull
- public String getSubtitle() {
- return mSubtitle;
+ public String getTextPreview() {
+ return mTextPreview;
}
/**
@@ -90,10 +96,15 @@ public class UIConversationItem {
return mSubtitleIcon;
}
- /** Gets the human readable time in hh::mm */
+ /** Gets text metadata */
@NonNull
- public String getReadableTime() {
- return mReadableTime;
+ public String getTextMetadata() {
+ return mTextMetadata;
+ }
+
+ /** Gets last message timestamp */
+ public long getLastMessageTimestamp() {
+ return mLastMessageTimestamp;
}
/** Returns the avatar for the conversation */
@@ -117,6 +128,11 @@ public class UIConversationItem {
return mShowReplyIcon;
}
+ /** Returns true, if play icon should be shown, false otherwise */
+ public boolean shouldShowPlayIcon() {
+ return mShowPlayIcon;
+ }
+
/** Returns true, if unread theme should be used, false otherwise */
public boolean shouldUseUnreadTheme() {
return mUseUnreadTheme;
diff --git a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java
index 94f597d..baa7056 100644
--- a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java
+++ b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java
@@ -16,12 +16,11 @@
package com.android.car.messenger.core.ui.conversationlist;
+import android.car.drivingstate.CarUxRestrictions;
import android.content.Context;
import android.graphics.drawable.Drawable;
-import android.text.format.DateFormat;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import com.android.car.messenger.R;
import com.android.car.messenger.common.Conversation;
@@ -36,55 +35,99 @@ public class UIConversationItemConverter {
private UIConversationItemConverter() {}
/** Converts Conversation Item to UIConversationItem */
- public static UIConversationItem convertToUIConversationItem(Conversation conversation) {
+ public static UIConversationItem convertToUIConversationItem(
+ Conversation conversation, CarUxRestrictions carUxRestrictions) {
Context context = AppFactory.get().getContext();
boolean isUnread = conversation.getUnreadCount() > 0;
long timestamp = ConversationUtil.getConversationTimestamp(conversation);
boolean isReplied = ConversationUtil.isReplied(conversation);
- String subtitle = "";
- Drawable subtitleIcon = null;
- if (isReplied) {
- subtitle = context.getString(R.string.replied);
- subtitleIcon = context.getDrawable(R.drawable.car_ui_icon_reply);
- } else if (isUnread) {
- subtitle = getNumberOfUnreadMessages(context, conversation.getUnreadCount());
- subtitleIcon = context.getDrawable(R.drawable.ic_play);
+ Drawable subtitleIcon =
+ isReplied
+ ? context.getDrawable(R.drawable.car_ui_icon_reply)
+ : context.getDrawable(R.drawable.ic_subtitle_play);
+
+ boolean showTextPreview =
+ (carUxRestrictions.getActiveRestrictions()
+ & CarUxRestrictions.UX_RESTRICTIONS_NO_TEXT_MESSAGE)
+ == 0;
+ String textPreview = "";
+ String textMetadata = "";
+
+ // show a preview when parked
+ if (showTextPreview) {
+ textPreview = ConversationUtil.getLastMessagePreview(conversation);
+ if (isUnread) {
+ textMetadata = getNumberOfMoreMessages(context, conversation.getUnreadCount());
+ }
+ } else {
+ if (isUnread) {
+ // in place of text preview, we show "tap to read aloud" when unread
+ textPreview = context.getString(R.string.tap_to_read_aloud);
+ textMetadata = getNumberOfUnreadMessages(context, conversation.getUnreadCount());
+ } else if (isReplied) {
+ textMetadata = context.getString(R.string.replied);
+ } else {
+ textMetadata = getNumberOfMessages(context, conversation.getMessages().size());
+ }
}
return new UIConversationItem(
conversation.getId(),
Objects.requireNonNull(conversation.getConversationTitle()),
- subtitle,
+ textPreview,
subtitleIcon,
- toHumanDisplay(timestamp),
+ textMetadata,
+ timestamp,
getConversationAvatar(context, conversation),
- /* showMuteIcon= */ true,
+ /* showMuteIcon= */ false,
/* showReplyIcon= */ true,
+ /* showPlayIcon= */ false,
isUnread,
conversation.isMuted(),
conversation);
}
+ /**
+ * For the text "More Unread Messages", indicates the number of messages remaining after the
+ * preview.
+ */
+ @NonNull
+ private static String getNumberOfMoreMessages(
+ @NonNull Context context, int noOfUnreadMessages) {
+ int remainingMessagesAfterPreview = noOfUnreadMessages - 1;
+ if (remainingMessagesAfterPreview == 0) {
+ return "";
+ }
+ if (remainingMessagesAfterPreview == 1) {
+ return context.getResources().getQuantityString(R.plurals.more_message, 1);
+ }
+ return context.getResources()
+ .getQuantityString(
+ R.plurals.more_message,
+ remainingMessagesAfterPreview,
+ remainingMessagesAfterPreview);
+ }
+
@NonNull
private static String getNumberOfUnreadMessages(
@NonNull Context context, int noOfUnreadMessages) {
if (noOfUnreadMessages == 1) {
return context.getResources().getQuantityString(R.plurals.new_message, 1);
- } else {
- return context.getResources()
- .getQuantityString(
- R.plurals.new_message, noOfUnreadMessages, noOfUnreadMessages);
}
+ return context.getResources()
+ .getQuantityString(R.plurals.new_message, noOfUnreadMessages, noOfUnreadMessages);
}
@NonNull
- private static String toHumanDisplay(long timeInMillis) {
- String delegate = "hh:mm aaa";
- return (String) DateFormat.format(delegate, timeInMillis);
+ private static String getNumberOfMessages(@NonNull Context context, int noOfMessages) {
+ if (noOfMessages < 2) {
+ return context.getResources().getQuantityString(R.plurals.no_of_message, noOfMessages);
+ }
+ return context.getResources()
+ .getQuantityString(R.plurals.no_of_message, noOfMessages, noOfMessages);
}
- @Nullable
private static Drawable getConversationAvatar(
@NonNull Context context, @NonNull Conversation conversation) {
return (conversation.getConversationIcon() != null)
diff --git a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java
index ddd227c..beed82c 100644
--- a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java
+++ b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java
@@ -16,12 +16,12 @@
package com.android.car.messenger.core.ui.launcher;
-import androidx.lifecycle.ViewModelProvider;
import android.os.Bundle;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.annotation.NonNull;
+import androidx.lifecycle.ViewModelProvider;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.models.UserAccount;
@@ -74,9 +74,9 @@ public class MessageLauncherActivity extends FragmentActivity implements InsetsC
@Override
protected void onResume() {
- super.onResume();
L.d("On Resume of Message Activity.");
- AppFactory.get().getDataModel().refreshUserAccounts();
+ AppFactory.get().getDataModel().refresh();
+ super.onResume();
}
private void pushContentFragment(
diff --git a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java
index 4b4d96a..47503e6 100644
--- a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java
+++ b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java
@@ -17,12 +17,12 @@
package com.android.car.messenger.core.ui.launcher;
import android.app.Application;
-import androidx.lifecycle.AndroidViewModel;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.Transformations;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Transformations;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.interfaces.DataModel;
diff --git a/src/com/android/car/messenger/core/ui/shared/DateTimeView.java b/src/com/android/car/messenger/core/ui/shared/DateTimeView.java
new file mode 100644
index 0000000..6947fcd
--- /dev/null
+++ b/src/com/android/car/messenger/core/ui/shared/DateTimeView.java
@@ -0,0 +1,537 @@
+/*
+ * Copyright (C) 2021 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.messenger.core.ui.shared;
+
+import static android.text.format.DateUtils.DAY_IN_MILLIS;
+import static android.text.format.DateUtils.HOUR_IN_MILLIS;
+import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
+import static android.text.format.DateUtils.YEAR_IN_MILLIS;
+
+import static java.lang.Math.max;
+import static java.lang.Math.min;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.os.Handler;
+import android.util.AttributeSet;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.TextView;
+
+import com.android.car.messenger.R;
+import com.android.car.messenger.core.interfaces.AppFactory;
+
+import java.text.DateFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.temporal.JulianFields;
+import java.util.ArrayList;
+
+/**
+ * Class to provide an updatable custom view with the relative time, based on the time set and the
+ * current time. Example: 1 min ago, 2 min ago etc. This is a copy of the Android Source Hidden
+ * File:
+ * https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/DateTimeView.java
+ */
+@SuppressLint("AppCompatCustomView")
+public class DateTimeView extends TextView {
+ private static final int SHOW_TIME = 0;
+ private static final int SHOW_MONTH_DAY_YEAR = 1;
+
+ private long mTimeMillis;
+ // The LocalDateTime equivalent of mTimeMillis but truncated to minute, i.e. no seconds / nanos.
+ private LocalDateTime mLocalTime;
+
+ private static final int LAST_DISPLAY = -1;
+ private DateFormat mLastFormat;
+
+ private long mUpdateTimeMillis;
+ private static final ThreadLocal<ReceiverInfo> sReceiverInfo = new ThreadLocal<>();
+ private String mNowText;
+ private boolean mShowRelativeTime;
+
+ public DateTimeView(Context context) {
+ this(context, null);
+ }
+
+ public DateTimeView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ ReceiverInfo ri = sReceiverInfo.get();
+ if (ri == null) {
+ ri = new ReceiverInfo();
+ sReceiverInfo.set(ri);
+ }
+ ri.addView(this);
+ // The view may not be added to the view hierarchy immediately right after setTime()
+ // is called which means it won't get any update from intents before being added.
+ // In such case, the view might show the incorrect relative time after being added to the
+ // view hierarchy until the next update intent comes.
+ // So we update the time here if mShowRelativeTime is enabled to prevent this case.
+ if (mShowRelativeTime) {
+ update();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ final ReceiverInfo ri = sReceiverInfo.get();
+ if (ri != null) {
+ ri.removeView(this);
+ }
+ }
+
+ /** Set the time when the event occurred to compare against the current time. */
+ public void setTime(long timeMillis) {
+ mTimeMillis = timeMillis;
+ LocalDateTime dateTime = toLocalDateTime(timeMillis, ZoneId.systemDefault());
+ mLocalTime = dateTime.withSecond(0);
+ update();
+ }
+
+ /**
+ * Show Relative Time, allows the view to show the current time against the time set in {@link
+ * #setTime(long)}
+ */
+ public void setShowRelativeTime(boolean showRelativeTime) {
+ mShowRelativeTime = showRelativeTime;
+ updateNowText();
+ update();
+ }
+
+ /**
+ * Returns whether this view shows relative time
+ *
+ * @return True if it shows relative time, false otherwise
+ */
+ public boolean isShowRelativeTime() {
+ return mShowRelativeTime;
+ }
+
+ @Override
+ public void setVisibility(int visibility) {
+ boolean gotVisible = visibility != GONE && getVisibility() == GONE;
+ super.setVisibility(visibility);
+ if (gotVisible) {
+ update();
+ }
+ }
+
+ void update() {
+ if (mLocalTime == null || getVisibility() == GONE) {
+ return;
+ }
+ if (mShowRelativeTime) {
+ updateRelativeTime();
+ return;
+ }
+
+ int display;
+ ZoneId zoneId = ZoneId.systemDefault();
+
+ // localTime is the local time for mTimeMillis but at zero seconds past the minute.
+ LocalDateTime localTime = mLocalTime;
+ LocalDateTime localStartOfDay = localTime.toLocalDate().atStartOfDay();
+ LocalDateTime localTomorrowStartOfDay = localStartOfDay.plusDays(1);
+ // now is current local time but at zero seconds past the minute.
+ LocalDateTime localNow = LocalDateTime.now(zoneId).withSecond(0);
+
+ long twelveHoursBefore = toEpochMillis(localTime.minusHours(12), zoneId);
+ long twelveHoursAfter = toEpochMillis(localTime.plusHours(12), zoneId);
+ long midnightBefore = toEpochMillis(localStartOfDay, zoneId);
+ long midnightAfter = toEpochMillis(localTomorrowStartOfDay, zoneId);
+ long time = toEpochMillis(localTime, zoneId);
+ long now = toEpochMillis(localNow, zoneId);
+
+ // Choose the display mode
+ choose_display:
+ {
+ if ((now >= midnightBefore && now < midnightAfter)
+ || (now >= twelveHoursBefore && now < twelveHoursAfter)) {
+ display = SHOW_TIME;
+ break choose_display;
+ }
+ // Else, show month day and year.
+ display = SHOW_MONTH_DAY_YEAR;
+ break choose_display;
+ }
+
+ // Choose the format
+ DateFormat format;
+ if (display == LAST_DISPLAY && mLastFormat != null) {
+ // use cached format
+ format = mLastFormat;
+ } else {
+ switch (display) {
+ case SHOW_TIME:
+ format = getTimeFormat();
+ break;
+ case SHOW_MONTH_DAY_YEAR:
+ format = DateFormat.getDateInstance(DateFormat.SHORT);
+ break;
+ default:
+ throw new IllegalArgumentException("unknown display value: " + display);
+ }
+ mLastFormat = format;
+ }
+
+ // Set the text
+ String text = format.format(Instant.ofEpochMilli(time));
+ setText(text);
+
+ // Schedule the next update
+ if (display == SHOW_TIME) {
+ // Currently showing the time, update at the later of twelve hours after or midnight.
+ mUpdateTimeMillis = max(twelveHoursAfter, midnightAfter);
+ } else {
+ // Currently showing the date
+ // If hte time is in the future, schedule one at the earlier of twelve hours
+ // before or midnight before.
+ if (mTimeMillis < now) {
+ // If the time is in the past, don't schedule an update
+ mUpdateTimeMillis = 0;
+ } else {
+ mUpdateTimeMillis = min(twelveHoursBefore, midnightBefore);
+ }
+ }
+ }
+
+ private void updateRelativeTime() {
+ long now = System.currentTimeMillis();
+ long duration = Math.abs(now - mTimeMillis);
+ int count;
+ long millisIncrease;
+ boolean past = (now >= mTimeMillis);
+ String result;
+ if (duration < MINUTE_IN_MILLIS) {
+ setText(mNowText);
+ mUpdateTimeMillis = mTimeMillis + MINUTE_IN_MILLIS + 1;
+ return;
+ } else if (duration < HOUR_IN_MILLIS) {
+ count = (int) (duration / MINUTE_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_minutes_shortest
+ : R.plurals.duration_minutes_shortest_future,
+ count),
+ count);
+ millisIncrease = MINUTE_IN_MILLIS;
+ } else if (duration < DAY_IN_MILLIS) {
+ count = (int) (duration / HOUR_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_hours_shortest
+ : R.plurals.duration_hours_shortest_future,
+ count),
+ count);
+ millisIncrease = HOUR_IN_MILLIS;
+ } else if (duration < YEAR_IN_MILLIS) {
+ // In weird cases it can become 0 because of daylight savings
+ LocalDateTime localDateTime = mLocalTime;
+ ZoneId zoneId = ZoneId.systemDefault();
+ LocalDateTime localNow = toLocalDateTime(now, zoneId);
+
+ count = max(Math.abs(dayDistance(localDateTime, localNow)), 1);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_days_shortest
+ : R.plurals.duration_days_shortest_future,
+ count),
+ count);
+ if (past || count != 1) {
+ mUpdateTimeMillis = computeNextMidnight(localNow, zoneId);
+ millisIncrease = -1;
+ } else {
+ millisIncrease = DAY_IN_MILLIS;
+ }
+
+ } else {
+ count = (int) (duration / YEAR_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_years_shortest
+ : R.plurals.duration_years_shortest_future,
+ count),
+ count);
+ millisIncrease = YEAR_IN_MILLIS;
+ }
+ if (millisIncrease != -1) {
+ if (past) {
+ mUpdateTimeMillis = mTimeMillis + millisIncrease * (count + 1) + 1;
+ } else {
+ mUpdateTimeMillis = mTimeMillis - millisIncrease * count + 1;
+ }
+ }
+ setText(result);
+ }
+
+ /** Returns the epoch millis for the next midnight in the specified timezone. */
+ private static long computeNextMidnight(LocalDateTime time, ZoneId zoneId) {
+ // This ignores the chance of overflow: it should never happen.
+ LocalDate tomorrow = time.toLocalDate().plusDays(1);
+ LocalDateTime nextMidnight = LocalDateTime.of(tomorrow, LocalTime.MIDNIGHT);
+ return toEpochMillis(nextMidnight, zoneId);
+ }
+
+ @Override
+ protected void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ updateNowText();
+ update();
+ }
+
+ private void updateNowText() {
+ if (!mShowRelativeTime) {
+ return;
+ }
+ mNowText = getContext().getResources().getString(R.string.now_string_shortest);
+ }
+
+ // Return the number of days between the two dates.
+ private static int dayDistance(LocalDateTime start, LocalDateTime end) {
+ return (int)
+ (end.getLong(JulianFields.JULIAN_DAY) - start.getLong(JulianFields.JULIAN_DAY));
+ }
+
+ private DateFormat getTimeFormat() {
+ return android.text.format.DateFormat.getTimeFormat(getContext());
+ }
+
+ void clearFormatAndUpdate() {
+ mLastFormat = null;
+ update();
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ if (mShowRelativeTime) {
+ // The short version of the time might not be completely understandable and for
+ // accessibility we rather have a longer version.
+ long now = System.currentTimeMillis();
+ long duration = Math.abs(now - mTimeMillis);
+ int count;
+ boolean past = (now >= mTimeMillis);
+ String result;
+ if (duration < MINUTE_IN_MILLIS) {
+ result = mNowText;
+ } else if (duration < HOUR_IN_MILLIS) {
+ count = (int) (duration / MINUTE_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_minutes_relative
+ : R.plurals
+ .duration_minutes_relative_future,
+ count),
+ count);
+ } else if (duration < DAY_IN_MILLIS) {
+ count = (int) (duration / HOUR_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_hours_relative
+ : R.plurals.duration_hours_relative_future,
+ count),
+ count);
+ } else if (duration < YEAR_IN_MILLIS) {
+ // In weird cases it can become 0 because of daylight savings
+ LocalDateTime localDateTime = mLocalTime;
+ ZoneId zoneId = ZoneId.systemDefault();
+ LocalDateTime localNow = toLocalDateTime(now, zoneId);
+
+ count = max(Math.abs(dayDistance(localDateTime, localNow)), 1);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_days_relative
+ : R.plurals.duration_days_relative_future,
+ count),
+ count);
+
+ } else {
+ count = (int) (duration / YEAR_IN_MILLIS);
+ result =
+ String.format(
+ getContext()
+ .getResources()
+ .getQuantityString(
+ past
+ ? R.plurals.duration_years_relative
+ : R.plurals.duration_years_relative_future,
+ count),
+ count);
+ }
+ info.setText(result);
+ }
+ }
+
+ /** Set Receiver Handler */
+ public static void setReceiverHandler(Handler handler) {
+ ReceiverInfo ri = sReceiverInfo.get();
+ if (ri == null) {
+ ri = new ReceiverInfo();
+ sReceiverInfo.set(ri);
+ }
+ ri.setHandler(handler);
+ }
+
+ private static class ReceiverInfo {
+ private final ArrayList<DateTimeView> mAttachedViews = new ArrayList<DateTimeView>();
+ private final BroadcastReceiver mReceiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (Intent.ACTION_TIME_TICK.equals(action)) {
+ if (System.currentTimeMillis() < getSoonestUpdateTime()) {
+ // The update() function takes a few milliseconds to run because of
+ // all of the time conversions it needs to do, so we can't do that
+ // every minute.
+ return;
+ }
+ }
+ // ACTION_TIME_CHANGED can also signal a change of 12/24 hr. format.
+ updateAll();
+ }
+ };
+
+ private Handler mHandler = new Handler();
+
+ public void addView(DateTimeView v) {
+ synchronized (mAttachedViews) {
+ final boolean register = mAttachedViews.isEmpty();
+ mAttachedViews.add(v);
+ if (register) {
+ register(getApplicationContextIfAvailable(v.getContext()));
+ }
+ }
+ }
+
+ public void removeView(DateTimeView v) {
+ synchronized (mAttachedViews) {
+ final boolean removed = mAttachedViews.remove(v);
+ // Only unregister once when we remove the last view in the list otherwise we risk
+ // trying to unregister a receiver that is no longer registered.
+ if (removed && mAttachedViews.isEmpty()) {
+ unregister(getApplicationContextIfAvailable(v.getContext()));
+ }
+ }
+ }
+
+ void updateAll() {
+ synchronized (mAttachedViews) {
+ final int count = mAttachedViews.size();
+ for (int i = 0; i < count; i++) {
+ DateTimeView view = mAttachedViews.get(i);
+ view.post(() -> view.clearFormatAndUpdate());
+ }
+ }
+ }
+
+ long getSoonestUpdateTime() {
+ long result = Long.MAX_VALUE;
+ synchronized (mAttachedViews) {
+ final int count = mAttachedViews.size();
+ for (int i = 0; i < count; i++) {
+ final long time = mAttachedViews.get(i).mUpdateTimeMillis;
+ if (time < result) {
+ result = time;
+ }
+ }
+ }
+ return result;
+ }
+
+ static final Context getApplicationContextIfAvailable(Context context) {
+ final Context ac = context.getApplicationContext();
+ return ac != null ? ac : AppFactory.get().getContext();
+ }
+
+ void register(Context context) {
+ final IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_TIME_TICK);
+ filter.addAction(Intent.ACTION_TIME_CHANGED);
+ filter.addAction(Intent.ACTION_CONFIGURATION_CHANGED);
+ filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
+ context.registerReceiver(mReceiver, filter, null, mHandler);
+ }
+
+ void unregister(Context context) {
+ context.unregisterReceiver(mReceiver);
+ }
+
+ public void setHandler(Handler handler) {
+ mHandler = handler;
+ synchronized (mAttachedViews) {
+ if (!mAttachedViews.isEmpty()) {
+ unregister(mAttachedViews.get(0).getContext());
+ register(mAttachedViews.get(0).getContext());
+ }
+ }
+ }
+ }
+
+ private static LocalDateTime toLocalDateTime(long timeMillis, ZoneId zoneId) {
+ // java.time types like LocalDateTime / Instant can support the full range of "long millis"
+ // with room to spare so we do not need to worry about overflow / underflow and the rsulting
+ // exceptions while the input to this class is a long.
+ Instant instant = Instant.ofEpochMilli(timeMillis);
+ return LocalDateTime.ofInstant(instant, zoneId);
+ }
+
+ private static long toEpochMillis(LocalDateTime time, ZoneId zoneId) {
+ Instant instant = time.toInstant(zoneId.getRules().getOffset(time));
+ return instant.toEpochMilli();
+ }
+}
diff --git a/src/com/android/car/messenger/impl/common/ProjectionStateListener.java b/src/com/android/car/messenger/core/util/CarStateListener.java
index cb3b26d..3e54288 100644
--- a/src/com/android/car/messenger/impl/common/ProjectionStateListener.java
+++ b/src/com/android/car/messenger/core/util/CarStateListener.java
@@ -14,11 +14,13 @@
* limitations under the License.
*/
-package com.android.car.messenger.impl.common;
+package com.android.car.messenger.core.util;
import android.bluetooth.BluetoothDevice;
import android.car.Car;
import android.car.CarProjectionManager;
+import android.car.drivingstate.CarUxRestrictions;
+import android.car.drivingstate.CarUxRestrictionsManager;
import android.car.projection.ProjectionStatus;
import android.content.Context;
import android.os.Bundle;
@@ -26,8 +28,8 @@ import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-
-import com.android.car.messenger.core.util.L;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
import java.util.ArrayList;
import java.util.Collections;
@@ -37,12 +39,20 @@ import java.util.List;
* {@link ProjectionStatus} listener that exposes APIs to detect whether a projection application is
* active.
*/
-public class ProjectionStateListener {
+public class CarStateListener {
@NonNull
static final String PROJECTION_STATUS_EXTRA_DEVICE_STATE =
"android.car.projection.DEVICE_STATE";
@Nullable private CarProjectionManager mCarProjectionManager = null;
+ @Nullable private CarUxRestrictionsManager mCarUxRestrictionsManager = null;
+
+ @NonNull
+ private final MutableLiveData<CarUxRestrictions> mUxRestrictions = new MutableLiveData<>();
+
+ @NonNull
+ private final CarUxRestrictionsManager.OnUxRestrictionsChangedListener
+ mCarUxRestrictionListener = mUxRestrictions::postValue;
@NonNull
private final CarProjectionManager.ProjectionStatusListener mListener =
@@ -56,7 +66,7 @@ public class ProjectionStateListener {
private int mProjectionState = ProjectionStatus.PROJECTION_STATE_INACTIVE;
@NonNull private List<ProjectionStatus> mProjectionDetails = new ArrayList<>();
- public ProjectionStateListener(@NonNull Context context) {
+ public CarStateListener(@NonNull Context context) {
Car.createCar(
context,
/* handler= */ null,
@@ -65,9 +75,17 @@ public class ProjectionStateListener {
mCar = car;
mCarProjectionManager =
(CarProjectionManager) mCar.getCarManager(Car.PROJECTION_SERVICE);
+ mCarUxRestrictionsManager =
+ (CarUxRestrictionsManager)
+ mCar.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE);
if (mCarProjectionManager != null) {
mCarProjectionManager.registerProjectionStatusListener(mListener);
}
+ if (mCarUxRestrictionsManager != null) {
+ mCarUxRestrictionsManager.registerListener(mCarUxRestrictionListener);
+ mCarUxRestrictionListener.onUxRestrictionsChanged(
+ mCarUxRestrictionsManager.getCurrentCarUxRestrictions());
+ }
});
}
@@ -76,6 +94,9 @@ public class ProjectionStateListener {
if (mCarProjectionManager != null) {
mCarProjectionManager.unregisterProjectionStatusListener(mListener);
}
+ if (mCarUxRestrictionsManager != null) {
+ mCarUxRestrictionsManager.unregisterListener();
+ }
if (mCar != null) {
mCar.disconnect();
mCar = null;
@@ -84,6 +105,15 @@ public class ProjectionStateListener {
mProjectionDetails = Collections.emptyList();
}
+ /** Gets the UxrRestrictions */
+ @NonNull
+ public final LiveData<CarUxRestrictions> getUxrRestrictions() {
+ if (mUxRestrictions.getValue() == null && mCarUxRestrictionsManager != null) {
+ mUxRestrictions.postValue(mCarUxRestrictionsManager.getCurrentCarUxRestrictions());
+ }
+ return mUxRestrictions;
+ }
+
/**
* Returns {@code true} if the input device currently has a projection app running in the
* foreground.
diff --git a/src/com/android/car/messenger/core/util/ConversationUtil.java b/src/com/android/car/messenger/core/util/ConversationUtil.java
index b56cbce..750e21e 100644
--- a/src/com/android/car/messenger/core/util/ConversationUtil.java
+++ b/src/com/android/car/messenger/core/util/ConversationUtil.java
@@ -16,6 +16,7 @@
package com.android.car.messenger.core.util;
+import static com.android.car.messenger.core.shared.MessageConstants.LAST_REPLY_TEXT_EXTRA;
import static com.android.car.messenger.core.shared.MessageConstants.LAST_REPLY_TIMESTAMP_EXTRA;
import static java.lang.Math.max;
@@ -25,9 +26,11 @@ import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import com.android.car.messenger.R;
import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.common.Conversation.Message;
import com.android.car.messenger.common.Conversation.Message.MessageStatus;
+import com.android.car.messenger.core.interfaces.AppFactory;
/** Conversation Util class for the {@link Conversation} DAO */
public class ConversationUtil {
@@ -42,7 +45,7 @@ public class ConversationUtil {
return 0L;
}
long replyTimestamp = conversation.getExtras().getLong(LAST_REPLY_TIMESTAMP_EXTRA, 0L);
- Message lastMessage = getLastMessage(conversation);
+ Message lastMessage = getLastIncomingMessage(conversation);
long lastMessageTimestamp = lastMessage == null ? 0L : lastMessage.getTimestamp();
return max(replyTimestamp, lastMessageTimestamp);
}
@@ -54,7 +57,7 @@ public class ConversationUtil {
}
long lastReplyTimestamp = getReplyTimestamp(conversation);
long lastMessageTimestamp = 0L;
- Message lastMessageGroup = ConversationUtil.getLastMessage(conversation);
+ Message lastMessageGroup = ConversationUtil.getLastIncomingMessage(conversation);
if (lastMessageGroup != null) {
lastMessageTimestamp = lastMessageGroup.getTimestamp();
}
@@ -62,11 +65,11 @@ public class ConversationUtil {
}
/**
- * Returns the last message in the conversation, or null if {@link Conversation#getMessages} is
- * empty
+ * Returns the last incoming message in the conversation, or null if {@link
+ * Conversation#getMessages} is empty
*/
@Nullable
- public static Message getLastMessage(@Nullable Conversation conversation) {
+ public static Message getLastIncomingMessage(@Nullable Conversation conversation) {
if (conversation == null || conversation.getMessages().isEmpty()) {
return null;
}
@@ -75,12 +78,31 @@ public class ConversationUtil {
}
/**
+ * Returns the last incoming message in the conversation, or null if {@link
+ * Conversation#getMessages} is empty
+ */
+ @NonNull
+ public static String getLastMessagePreview(@Nullable Conversation conversation) {
+ Message lastIncomingMessage = getLastIncomingMessage(conversation);
+ if (isReplied(conversation)) {
+ String lastReply = getLastReply(conversation);
+ if (lastReply == null) {
+ return AppFactory.get().getContext().getString(R.string.replied);
+ }
+ return lastReply;
+ } else if (lastIncomingMessage != null) {
+ return lastIncomingMessage.getText();
+ }
+ return "";
+ }
+
+ /**
* Gets the conversation status of the last messages Returns {@link
* MessageStatus#MESSAGE_STATUS_NONE} when no known message status or last message is a reply
*/
@MessageStatus
public static int getConversationStatus(@Nullable Conversation conversation) {
- Message lastMessage = getLastMessage(conversation);
+ Message lastMessage = getLastIncomingMessage(conversation);
return isReplied(conversation) || lastMessage == null
? MessageStatus.MESSAGE_STATUS_NONE
: lastMessage.getMessageStatus();
@@ -93,15 +115,16 @@ public class ConversationUtil {
* to. If no extra is passed, a new one will be created. The final extras will be added to
* the {@link Conversation#getExtras()}
*/
- public static void setReplyTimestampAsAnExtra(
+ public static void setReplyAsAnExtra(
@NonNull Conversation.Builder conversationBuilder,
@Nullable Bundle extras,
- long lastReplyTimestamp) {
- if (lastReplyTimestamp > 0L) {
+ @Nullable Message lastReply) {
+ if (lastReply != null) {
if (extras == null) {
extras = new Bundle();
}
- extras.putLong(LAST_REPLY_TIMESTAMP_EXTRA, lastReplyTimestamp);
+ extras.putLong(LAST_REPLY_TIMESTAMP_EXTRA, lastReply.getTimestamp());
+ extras.putString(LAST_REPLY_TEXT_EXTRA, lastReply.getText());
conversationBuilder.setExtras(extras);
}
}
@@ -113,4 +136,13 @@ public class ConversationUtil {
}
return conversation.getExtras().getLong(LAST_REPLY_TIMESTAMP_EXTRA, 0L);
}
+
+ /** Gets last reply, if any */
+ @Nullable
+ private static String getLastReply(@Nullable Conversation conversation) {
+ if (conversation == null) {
+ return null;
+ }
+ return conversation.getExtras().getString(LAST_REPLY_TEXT_EXTRA, "");
+ }
}
diff --git a/src/com/android/car/messenger/core/util/VoiceUtil.java b/src/com/android/car/messenger/core/util/VoiceUtil.java
index 6a7a049..7791b1b 100644
--- a/src/com/android/car/messenger/core/util/VoiceUtil.java
+++ b/src/com/android/car/messenger/core/util/VoiceUtil.java
@@ -29,7 +29,6 @@ import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_REP
import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_SEND_SMS;
import static com.android.car.messenger.core.shared.MessageConstants.ACTION_DIRECT_SEND;
import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MARK_AS_READ;
-import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MUTE;
import static com.android.car.messenger.core.shared.MessageConstants.ACTION_REPLY;
import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_ACCOUNT_ID;
import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_CONVERSATION_KEY;
@@ -189,23 +188,9 @@ public class VoiceUtil {
markAsReadIntent),
null);
- final int muteIcon = R.drawable.car_ui_icon_toggle_mute;
- final String muteString = context.getString(R.string.action_mute);
- PendingIntent muteIntent = createServiceIntent(ACTION_MUTE, conversationKey, userAccountId);
- ConversationAction muteAction =
- new ConversationAction(
- ActionType.ACTION_TYPE_MUTE,
- new RemoteAction(
- Icon.createWithResource(context, muteIcon),
- muteString,
- muteString,
- muteIntent),
- null);
-
List<ConversationAction> actions = new ArrayList<>();
actions.add(replyAction);
actions.add(markAsReadAction);
- actions.add(muteAction);
builder.setActions(actions);
return builder.build();
}
@@ -228,10 +213,7 @@ public class VoiceUtil {
int requestCode =
(conversationKey == null) ? action.hashCode() : conversationKey.hashCode();
return PendingIntent.getForegroundService(
- context,
- requestCode,
- intent,
- PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_ONE_SHOT);
+ context, requestCode, intent, PendingIntent.FLAG_MUTABLE);
}
/** Sends a reply, meant to be used from a caller originating from voice input. */
diff --git a/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java b/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java
index 59e7849..bf3ebae 100644
--- a/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java
+++ b/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java
@@ -16,12 +16,12 @@
package com.android.car.messenger.impl.datamodels;
-import androidx.lifecycle.MediatorLiveData;
import android.content.Context;
import android.database.ContentObserver;
import android.net.Uri;
import androidx.annotation.NonNull;
+import androidx.lifecycle.MediatorLiveData;
import com.android.car.messenger.core.interfaces.AppFactory;
diff --git a/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java b/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java
index 5e71513..fa91f65 100644
--- a/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java
+++ b/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java
@@ -59,6 +59,8 @@ class ConversationListLiveData extends ContentProviderLiveData<Collection<Conver
ConversationListLiveData(@NonNull UserAccount userAccount) {
super(Telephony.MmsSms.CONTENT_URI);
mUserAccount = userAccount;
+ // source to refresh the data to avoid stale data when resuming from background
+ addSource(RefreshLiveData.getInstance(), it -> onDataChange());
}
@Override
diff --git a/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java b/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java
index 7618b15..19689de 100644
--- a/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java
+++ b/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java
@@ -20,9 +20,6 @@ import static android.provider.Telephony.MmsSms.CONTENT_CONVERSATIONS_URI;
import static android.provider.Telephony.TextBasedSmsColumns.SUBSCRIPTION_ID;
import static android.provider.Telephony.TextBasedSmsColumns.THREAD_ID;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.MediatorLiveData;
-import androidx.lifecycle.Observer;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
@@ -31,6 +28,9 @@ import android.telephony.SubscriptionInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MediatorLiveData;
+import androidx.lifecycle.Observer;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.models.UserAccount;
diff --git a/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java b/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java
index 8f6fb64..a5e0922 100644
--- a/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java
+++ b/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java
@@ -32,9 +32,9 @@ import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.models.UserAccount;
import com.android.car.messenger.core.shared.MessageConstants;
+import com.android.car.messenger.core.util.CarStateListener;
import com.android.car.messenger.core.util.ConversationUtil;
import com.android.car.messenger.core.util.L;
-import com.android.car.messenger.impl.common.ProjectionStateListener;
import java.time.Instant;
import java.util.ArrayList;
@@ -62,11 +62,10 @@ public class NewMessageLiveData extends ContentProviderLiveData<Conversation> {
+ " = %d";
@NonNull
- private final ProjectionStateListener mProjectionStateListener =
- new ProjectionStateListener(AppFactory.get().getContext());
+ private final CarStateListener mCarStateListener = AppFactory.get().getCarStateListener();
NewMessageLiveData() {
- super(Telephony.Sms.CONTENT_URI, Telephony.Mms.CONTENT_URI, Telephony.MmsSms.CONTENT_URI);
+ super(Telephony.MmsSms.CONTENT_URI);
}
@Override
@@ -126,11 +125,11 @@ public class NewMessageLiveData extends ContentProviderLiveData<Conversation> {
Conversation conversation;
try {
conversation = fetchConversation(conversationId);
- conversation.getExtras().putInt(MessageConstants.EXTRA_ACCOUNT_ID, userAccount.getId());
} catch (CursorIndexOutOfBoundsException e) {
L.w("Error occurred fetching conversation Id " + conversationId);
return false;
}
+ conversation.getExtras().putInt(MessageConstants.EXTRA_ACCOUNT_ID, userAccount.getId());
Instant offset =
Instant.ofEpochMilli(ConversationUtil.getConversationTimestamp(conversation));
mOffsetMap.put(userAccount.getId(), offset);
@@ -164,6 +163,6 @@ public class NewMessageLiveData extends ContentProviderLiveData<Conversation> {
}
private boolean hasProjectionInForeground(@NonNull UserAccount userAccount) {
- return mProjectionStateListener.isProjectionInActiveForeground(userAccount.getIccId());
+ return mCarStateListener.isProjectionInActiveForeground(userAccount.getIccId());
}
}
diff --git a/src/com/android/car/messenger/impl/datamodels/RefreshLiveData.java b/src/com/android/car/messenger/impl/datamodels/RefreshLiveData.java
new file mode 100644
index 0000000..867870d
--- /dev/null
+++ b/src/com/android/car/messenger/impl/datamodels/RefreshLiveData.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2021 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.messenger.impl.datamodels;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
+
+/**
+ * Refresh live data for other live data to force refresh. When a live data listens for updates, it
+ * can refresh its data and repost as needed. This typically occurs when the user revisits the
+ * activity from the background. The background has a number of states that leads to live datas not
+ * being updated. The content observer from the telephony database does not always update when in
+ * the background if something changes. Same thing occurs with shared preferences. By checking the
+ * data again for any stale data when we resume, we can ensure the latest data is provided.
+ */
+public class RefreshLiveData extends LiveData<Boolean> {
+
+ @Nullable private static RefreshLiveData sInstance;
+
+ /** Gets the instance of {@link RefreshLiveData} */
+ @NonNull
+ public static RefreshLiveData getInstance() {
+ if (sInstance == null) {
+ sInstance = new RefreshLiveData();
+ }
+ return sInstance;
+ }
+
+ /** Posts value to listeners to refresh */
+ public void refresh() {
+ postValue(true);
+ }
+}
diff --git a/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java b/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java
index a1816e2..4de3433 100644
--- a/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java
+++ b/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java
@@ -18,8 +18,6 @@ package com.android.car.messenger.impl.datamodels;
import static com.android.car.messenger.core.shared.MessageConstants.KEY_MUTED_CONVERSATIONS;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.Transformations;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
@@ -28,6 +26,8 @@ import android.provider.Telephony;
import android.telephony.SmsManager;
import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Transformations;
import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.interfaces.AppFactory;
@@ -43,7 +43,6 @@ import java.util.Set;
/** Queries the telephony data model to retrieve the SMS/MMS messages */
public class TelephonyDataModel implements DataModel {
-
@NonNull
@Override
public LiveData<Collection<UserAccount>> getAccounts() {
@@ -52,8 +51,9 @@ public class TelephonyDataModel implements DataModel {
}
@Override
- public void refreshUserAccounts() {
+ public void refresh() {
UserAccountLiveData.getInstance().refresh();
+ RefreshLiveData.getInstance().refresh();
}
@NonNull
diff --git a/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java b/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java
index 1a95a2f..b5314dd 100644
--- a/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java
+++ b/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java
@@ -15,7 +15,6 @@
*/
package com.android.car.messenger.impl.datamodels;
-import androidx.lifecycle.LiveData;
import android.content.Context;
import android.telephony.SubscriptionInfo;
import android.telephony.SubscriptionManager;
@@ -24,6 +23,7 @@ import android.telephony.TelephonyManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.lifecycle.LiveData;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.models.UserAccount;
@@ -82,7 +82,7 @@ public class UserAccountLiveData extends LiveData<UserAccountChangeList> {
/**
* Refresh the user accounts. Updates listeners if a change is found. Useful to call when
* something occurs that indicates a change in accounts, such as empty messages. This is useful
- * as there are occasions when the subscription on change listener is not called after a
+ * as t here are occasions when the subscription on change listener is not called after a
* subscription is deleted.
*/
public void refresh() {
diff --git a/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java b/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java
index 42b97e4..b066417 100644
--- a/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java
+++ b/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java
@@ -16,6 +16,8 @@
package com.android.car.messenger.impl.datamodels.util;
+import static java.lang.Math.min;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
@@ -27,6 +29,7 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.core.app.Person;
+import com.android.car.messenger.R;
import com.android.car.messenger.common.Conversation;
import com.android.car.messenger.core.interfaces.AppFactory;
import com.android.car.messenger.core.shared.MessageConstants;
@@ -44,6 +47,7 @@ public class ConversationFetchUtil {
private static final int MESSAGE_LIMIT = 10;
private static final String COMMA_DELIMITER = ", ";
+ private static final int MAX_TITLE_NAMES = 3;
private ConversationFetchUtil() {}
@@ -51,24 +55,31 @@ public class ConversationFetchUtil {
public static Conversation fetchConversation(@NonNull String conversationId) {
L.d("Fetching latest data for Conversation " + conversationId);
Conversation.Builder conversationBuilder = initConversationBuilder(conversationId);
- Cursor messagesCursor =
- CursorUtils.getMessagesCursor(conversationId, MESSAGE_LIMIT, /* offset= */ 0);
+ Cursor mmsCursor = getMmsCursor(conversationId);
+ Cursor smsCursor = getSmsCursor(conversationId);
+
+ // message list sorted by date desc
+ List<Conversation.Message> messages =
+ MessageUtils.getMessages(MESSAGE_LIMIT, mmsCursor, smsCursor);
+
// messages to read: first get unread messages
- List<Conversation.Message> messagesToRead = MessageUtils.getUnreadMessages(messagesCursor);
+ // List should truncate at the latest reply or read message since reading a recent message
+ // does not mark all previous messages read.
+ List<Conversation.Message> messagesToRead = MessageUtils.getUnreadMessages(messages);
+
int unreadCount = messagesToRead.size();
- long lastReplyTimestamp = 0L;
+ Conversation.Message lastReply = null;
// if no unread messages, get read messages
if (messagesToRead.isEmpty()) {
- Pair<List<Conversation.Message>, Long> readMessagesAndReplyTimestamp =
- MessageUtils.getReadMessagesAndReplyTimestamp(messagesCursor);
+ Pair<List<Conversation.Message>, Conversation.Message> readMessagesAndReplyTimestamp =
+ MessageUtils.getReadMessagesAndReplyTimestamp(messages);
messagesToRead = readMessagesAndReplyTimestamp.first;
- lastReplyTimestamp = readMessagesAndReplyTimestamp.second;
+ lastReply = readMessagesAndReplyTimestamp.second;
}
conversationBuilder.setMessages(messagesToRead).setUnreadCount(unreadCount);
- ConversationUtil.setReplyTimestampAsAnExtra(
- conversationBuilder, /* extras= */ null, lastReplyTimestamp);
+ ConversationUtil.setReplyAsAnExtra(conversationBuilder, /* extras= */ null, lastReply);
return conversationBuilder.build();
}
@@ -83,7 +94,7 @@ public class ConversationFetchUtil {
fetchParticipants(
conversationId,
(names, icons) -> {
- builder.setConversationTitle(TextUtils.join(COMMA_DELIMITER, names));
+ builder.setConversationTitle(formatConversationTitle(names));
Bitmap bitmap = AvatarUtil.createGroupAvatar(context, icons);
if (bitmap != null) {
builder.setConversationIcon(IconCompat.createWithBitmap(bitmap));
@@ -94,6 +105,19 @@ public class ConversationFetchUtil {
return builder;
}
+ private static String formatConversationTitle(List<CharSequence> names) {
+ Context context = AppFactory.get().getContext();
+ String title =
+ TextUtils.join(
+ COMMA_DELIMITER, names.subList(0, min(MAX_TITLE_NAMES, names.size())));
+ if (names.size() > MAX_TITLE_NAMES) {
+ title +=
+ context.getString(
+ R.string.participant_overflow_text, names.size() - MAX_TITLE_NAMES);
+ }
+ return title;
+ }
+
/**
* Fetches participants and allows caller to process names and icons before returning.
*
@@ -136,4 +160,14 @@ public class ConversationFetchUtil {
return sharedPreferences.getStringSet(
MessageConstants.KEY_MUTED_CONVERSATIONS, new HashSet<>());
}
+
+ private static Cursor getMmsCursor(@NonNull String conversationId) {
+ return CursorUtils.getMessagesCursor(
+ conversationId, MESSAGE_LIMIT, /* offset= */ 0, CursorUtils.ContentType.MMS);
+ }
+
+ private static Cursor getSmsCursor(@NonNull String conversationId) {
+ return CursorUtils.getMessagesCursor(
+ conversationId, MESSAGE_LIMIT, /* offset= */ 0, CursorUtils.ContentType.SMS);
+ }
}
diff --git a/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java b/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java
index 5ce2302..93b6a85 100644
--- a/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java
+++ b/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java
@@ -28,6 +28,8 @@ import static android.provider.Telephony.ThreadsColumns.DATE;
import static android.provider.Telephony.ThreadsColumns.READ;
import static android.provider.Telephony.ThreadsColumns.RECIPIENT_IDS;
+import static com.android.car.messenger.impl.datamodels.util.MmsUtils.MMS_CONTENT_TYPE;
+
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
@@ -59,6 +61,16 @@ public class CursorUtils {
@NonNull
public static final String DEFAULT_SORT_ORDER = Telephony.TextBasedSmsColumns.DATE + " DESC";
+ private static final String MMS_QUERY =
+ CONTENT_TYPE + " = '" + MMS_CONTENT_TYPE + "' AND " + DATE + " > ";
+ private static final String SMS_QUERY = CONTENT_TYPE + " IS NULL AND " + DATE + " > ";
+
+ /** This enum is used for describing the type of message being fetched by a cursor */
+ public enum ContentType {
+ SMS,
+ MMS
+ }
+
/**
* Get simplified thread cursor with metadata information on the thread, such as recipient ids
*/
@@ -82,13 +94,19 @@ public class CursorUtils {
* @param offset The starting point in timestamp in millisecond to fetch for data
*/
@Nullable
- public static Cursor getMessagesCursor(@NonNull String conversationId, int limit, long offset) {
+ public static Cursor getMessagesCursor(@NonNull String conversationId, int limit, long offset,
+ @NonNull ContentType contentType) {
Context context = AppFactory.get().getContext();
ContentResolver contentResolver = context.getContentResolver();
+
+ String query = contentType == ContentType.MMS
+ ? MMS_QUERY + offset / 1000
+ : SMS_QUERY + offset;
+
return contentResolver.query(
getConversationUri(conversationId),
CONTENT_CONVERSATION_PROJECTION,
- DATE + " > " + offset,
+ query,
/* selectionArgs= */ null,
DEFAULT_SORT_ORDER + " LIMIT " + limit);
}
diff --git a/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java b/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java
index 08dc1b3..0bb454b 100644
--- a/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java
+++ b/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java
@@ -20,6 +20,7 @@ import static com.android.car.messenger.common.Conversation.Message.MessageStatu
import static com.android.car.messenger.common.Conversation.Message.MessageStatus.MESSAGE_STATUS_READ;
import static com.android.car.messenger.common.Conversation.Message.MessageStatus.MESSAGE_STATUS_UNREAD;
+import static java.lang.Math.min;
import static java.util.Comparator.comparingLong;
import android.content.Context;
@@ -47,61 +48,82 @@ import java.util.function.Function;
public final class MessageUtils {
/**
- * Gets all unread messages in cursor
+ * Returns all messages in the given cursors.
*
- * @param messagesCursor The messageCursor in descending order
+ * @param limit The maximum number of messages
+ * @param messageCursors The messageCursors of messages in descending order
*/
@NonNull
- public static List<Message> getUnreadMessages(@Nullable Cursor messagesCursor) {
- List<Message> unreadMessages = new ArrayList<>();
- MessageUtils.forEachDesc(
- messagesCursor,
- message -> {
- if (message.getMessageStatus() == MessageStatus.MESSAGE_STATUS_UNREAD) {
- unreadMessages.add(message);
+ public static List<Message> getMessages(int limit, @Nullable Cursor... messageCursors) {
+ List<Message> messages = new ArrayList<>();
+ for (Cursor cursor : messageCursors) {
+ MessageUtils.forEachDesc(
+ cursor,
+ message -> {
+ messages.add(message);
return true;
- }
- return false;
- });
- unreadMessages.sort(comparingLong(Message::getTimestamp));
+ });
+ }
+ messages.sort(comparingLong(Message::getTimestamp).reversed());
+ return messages.subList(0, min(limit, messages.size()));
+ }
+
+ /**
+ * Returns unread messages from a conversation, in ascending order.
+ *
+ * @param messages The messages in descending order
+ */
+ @NonNull
+ public static List<Message> getUnreadMessages(@NonNull List<Message> messages) {
+ int i = 0;
+ for (Conversation.Message message : messages) {
+ if (message.getMessageStatus() != MessageStatus.MESSAGE_STATUS_UNREAD) {
+ break;
+ }
+ i++;
+ }
+ List<Message> unreadMessages = messages.subList(0, i);
+ unreadMessages.sort(comparingLong(Conversation.Message::getTimestamp));
return unreadMessages;
}
+
/**
- * Gets Read Messages and Reply Timestamp.
+ * Gets Read Messages and Last Reply
*
- * @param messagesCursor MessageCursor in descending order
+ * @param messages List of messages in descending order
*/
@NonNull
- public static Pair<List<Message>, Long> getReadMessagesAndReplyTimestamp(
- @Nullable Cursor messagesCursor) {
+ public static Pair<List<Message>, Message> getReadMessagesAndReplyTimestamp(
+ @Nullable List<Message> messages) {
List<Message> readMessages = new ArrayList<>();
+ AtomicReference<Message> replyMessage = new AtomicReference<>();
AtomicReference<Long> lastReply = new AtomicReference<>(0L);
- MessageUtils.forEachDesc(
- messagesCursor,
- message -> {
- // Desired impact: 4. Reply -> 3. Messages -> 2. Reply -> 1 Messages (stop
- // parsing at 2.)
- // lastReply references 4., messages references 3.
- // Desired impact: 3. Messages -> 2. Reply -> 1. Messages (stop parsing at 2.)
- // lastReply references 2., messages references 3.
- int messageStatus = message.getMessageStatus();
- if (message.getMessageType() == MessageType.MESSAGE_TYPE_SENT) {
- if (lastReply.get() < message.getTimestamp()) {
- lastReply.set(message.getTimestamp());
- }
- return readMessages.isEmpty();
- }
-
- if (messageStatus == MessageStatus.MESSAGE_STATUS_READ
- || messageStatus == MessageStatus.MESSAGE_STATUS_NONE) {
- readMessages.add(message);
- return true;
- }
- return false;
- });
+
+ for (Message message : messages) {
+ // Desired impact: 4. Reply -> 3. Messages -> 2. Reply -> 1 Messages (stop
+ // parsing at 2.)
+ // lastReply references 4., messages references 3.
+ // Desired impact: 3. Messages -> 2. Reply -> 1. Messages (stop parsing at 2.)
+ // lastReply references 2., messages references 3.
+ int messageStatus = message.getMessageStatus();
+ if (message.getMessageType() == MessageType.MESSAGE_TYPE_SENT) {
+ if (lastReply.get() < message.getTimestamp()) {
+ lastReply.set(message.getTimestamp());
+ replyMessage.set(message);
+ }
+ if (!readMessages.isEmpty()) {
+ break;
+ }
+ } else if (messageStatus == MessageStatus.MESSAGE_STATUS_READ
+ || messageStatus == MessageStatus.MESSAGE_STATUS_NONE) {
+ readMessages.add(message);
+ } else {
+ break;
+ }
+ }
readMessages.sort(comparingLong(Message::getTimestamp));
- return new Pair<>(readMessages, lastReply.get());
+ return new Pair<>(readMessages, replyMessage.get());
}
/**
@@ -111,7 +133,7 @@ public final class MessageUtils {
* @param processor A consumer that takes in the {@link Message} and returns true for the method
* to continue parsing the cursor or false to return.
*/
- public static void forEachDesc(
+ private static void forEachDesc(
@Nullable Cursor messageCursor, @NonNull Function<Message, Boolean> processor) {
if (messageCursor == null || !messageCursor.moveToFirst()) {
return;
@@ -128,6 +150,16 @@ public final class MessageUtils {
L.d("Message was not able to be parsed. Skipping.");
continue;
}
+ if (message.getText().trim().isEmpty()) {
+ // There are occasions where a user may send
+ // a text message plus an image or audio and
+ // bluetooth will post two messages to the database (b/182834412),
+ // one with a text and one blank
+ // This leads to boomerang notifications, one with text and one that is empty.
+ // Validating or removing messages when blank is a mitigation on our end.
+ L.d("Message is blank. Skipped. ");
+ continue;
+ }
if (message.getMessageType() == MessageType.MESSAGE_TYPE_SENT) {
hasBeenRepliedTo = true;
}
@@ -136,25 +168,6 @@ public final class MessageUtils {
}
/**
- * Parses each message in the cursor and returns the item for further processing
- *
- * @param messageCursor The message cursor to be parsed for SMS and MMS messages and returns
- * true for the method to continue parsing the cursor or false to return.
- */
- @Nullable
- public static Message parseCurrentMessage(@NonNull Cursor messageCursor) {
- Message message = null;
- Context context = AppFactory.get().getContext();
- try {
- message = parseMessageAtPoint(context, messageCursor, false);
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
- L.d("Message was not able to be parsed. Skipping.");
- }
- return message;
- }
-
- /**
* Parses message at the point in cursor.
*
* @throws IllegalArgumentException if desired columns are missing.
diff --git a/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java b/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java
index 808ce86..7f87e69 100644
--- a/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java
+++ b/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java
@@ -82,6 +82,7 @@ class MmsUtils {
stringBuilder.append(cursor.getString(cursor.getColumnIndex(Part.TEXT)));
stringBuilder.append(" ");
}
+
return stringBuilder.toString().replace(REPLACE_CHARS, "");
}
diff --git a/tests/Android.bp b/tests/Android.bp
new file mode 100644
index 0000000..8dac782
--- /dev/null
+++ b/tests/Android.bp
@@ -0,0 +1,43 @@
+//
+// Copyright (C) 2021 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "CarMessengerUnitTests",
+
+ min_sdk_version: "30",
+
+ target_sdk_version: "31",
+
+ srcs: ["src/**/*.java"],
+
+ libs: ["android.car-system-stubs"],
+
+ static_libs: [
+ "androidx.test.core",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "androidx.test.ext.junit",
+ "androidx.test.ext.truth",
+ "mockito-target-extended-minus-junit4",
+ "truth-prebuilt",
+ ],
+
+ instrumentation_for: "CarMessengerAppForTesting",
+}
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 0000000..e59c7c4
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.messenger.unittests">
+
+ <application android:testOnly="false"
+ android:debuggable="true">
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.car.messenger"
+ android:label="Car Messenger Unit Tests" />
+</manifest>
diff --git a/tests/src/com/android/car/messenger/BuildTest.java b/tests/src/com/android/car/messenger/BuildTest.java
new file mode 100644
index 0000000..3d35ddb
--- /dev/null
+++ b/tests/src/com/android/car/messenger/BuildTest.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2021 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.messenger;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class BuildTest {
+
+ @Test
+ public void test() {
+ assertThat(true).isEqualTo(true);
+ }
+}