DO NOT MERGE Make Group Conversation Titles RTL friendly
Bug: 159221409
Test: unit test
Change-Id: Ifce6b44ceefe1409bf989b036b68b1a5c16ccc93
diff --git a/car-messenger-common/Android.bp b/car-messenger-common/Android.bp
index 19ed50f..f40efac 100644
--- a/car-messenger-common/Android.bp
+++ b/car-messenger-common/Android.bp
@@ -19,6 +19,8 @@
srcs: ["src/**/*.java"],
+ manifest: "AndroidManifest.xml",
+
optimize: {
enabled: false,
},
@@ -33,5 +35,8 @@
"car-apps-common-bp",
"car-messenger-protos",
"connected-device-protos",
+ "libphonenumber",
],
+
+ platform_apis: true,
}
diff --git a/car-messenger-common/res/values/strings.xml b/car-messenger-common/res/values/strings.xml
index ff604e2..b19ffdb 100644
--- a/car-messenger-common/res/values/strings.xml
+++ b/car-messenger-common/res/values/strings.xml
@@ -41,6 +41,6 @@
<string name="name_not_available">Name not available</string>
<!-- Formats a group conversation's title for a message notification. The format is: <Sender of last message> mdot <Name of the conversation>.-->
- <string name="group_conversation_title_separator" translatable="false">%1$s • %2$s</string>
+ <string name="group_conversation_title_separator" translatable="false"> • </string>
</resources>
diff --git a/car-messenger-common/src/com/android/car/messenger/common/BaseNotificationDelegate.java b/car-messenger-common/src/com/android/car/messenger/common/BaseNotificationDelegate.java
index ed42e98..b0a380c 100644
--- a/car-messenger-common/src/com/android/car/messenger/common/BaseNotificationDelegate.java
+++ b/car-messenger-common/src/com/android/car/messenger/common/BaseNotificationDelegate.java
@@ -230,9 +230,9 @@
}
});
if (notificationInfo.isGroupConvo()) {
- messagingStyle.setConversationTitle(
- mContext.getString(R.string.group_conversation_title_separator,
- lastMessage.getSenderName(), notificationInfo.getConvoTitle()));
+ messagingStyle.setConversationTitle(Utils.constructGroupConversationHeader(
+ lastMessage.getSenderName(), notificationInfo.getConvoTitle(),
+ mContext.getString(R.string.group_conversation_title_separator)));
}
// We are creating this notification for the first time.
diff --git a/car-messenger-common/src/com/android/car/messenger/common/Utils.java b/car-messenger-common/src/com/android/car/messenger/common/Utils.java
index 00e18f8..99ec52f 100644
--- a/car-messenger-common/src/com/android/car/messenger/common/Utils.java
+++ b/car-messenger-common/src/com/android/car/messenger/common/Utils.java
@@ -21,6 +21,8 @@
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
+import android.text.BidiFormatter;
+import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import androidx.annotation.Nullable;
@@ -34,6 +36,15 @@
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Person;
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.Phonenumber;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
/** Utils methods for the car-messenger-common lib. **/
public class Utils {
private static final String TAG = "CMC.Utils";
@@ -246,4 +257,119 @@
return initials.toString();
}
+ /**
+ * Creates a Header for a group conversation, where the senderName and groupName are both shown,
+ * separated by a delimiter.
+ *
+ * @param senderName Sender's name.
+ * @param groupName Group conversation's name.
+ * @param delimiter delimiter that separates each element.
+ */
+ public static String constructGroupConversationHeader(String senderName, String groupName,
+ String delimiter) {
+ return constructGroupConversationHeader(senderName, groupName, delimiter,
+ BidiFormatter.getInstance());
+ }
+
+ /**
+ * Creates a Header for a group conversation, where the senderName and groupName are both shown,
+ * separated by a delimiter.
+ *
+ * @param senderName Sender's name.
+ * @param groupName Group conversation's name.
+ * @param delimiter delimiter that separates each element.
+ * @param bidiFormatter formatter for the context's locale.
+ */
+ public static String constructGroupConversationHeader(String senderName, String groupName,
+ String delimiter, BidiFormatter bidiFormatter) {
+ String formattedSenderName = bidiFormatter.unicodeWrap(senderName,
+ TextDirectionHeuristics.FIRSTSTRONG_LTR);
+ String formattedGroupName = bidiFormatter.unicodeWrap(groupName,
+ TextDirectionHeuristics.LOCALE);
+ String title = String.join(delimiter, formattedSenderName, formattedGroupName);
+ return bidiFormatter.unicodeWrap(title, TextDirectionHeuristics.LOCALE);
+ }
+
+ /**
+ * Given a name of all the participants in a group conversation (some names might be phone
+ * numbers), this function creates the conversation title by putting the names in alphabetical
+ * order first, then adding any phone numbers. This title should not exceed the
+ * conversationTitleLength, so not all participants' names are guaranteed to be
+ * in the conversation title.
+ */
+ public static String constructGroupConversationTitle(List<String> names, String delimiter,
+ int conversationTitleLength) {
+ return constructGroupConversationTitle(names, delimiter, conversationTitleLength,
+ BidiFormatter.getInstance());
+ }
+
+ /**
+ * Given a name of all the participants in a group conversation (some names might be phone
+ * numbers), this function creates the conversation title by putting the names in alphabetical
+ * order first, then adding any phone numbers. This title should not exceed the
+ * conversationTitleLength, so not all participants' names are guaranteed to be
+ * in the conversation title.
+ */
+ public static String constructGroupConversationTitle(List<String> names, String delimiter,
+ int conversationTitleLength, BidiFormatter bidiFormatter) {
+ List<String> sortedNames = getSortedSubsetNames(names, conversationTitleLength,
+ delimiter.length());
+ String formattedDelimiter = bidiFormatter.unicodeWrap(delimiter,
+ TextDirectionHeuristics.LOCALE);
+
+ String conversationName = sortedNames.stream().map(name -> bidiFormatter.unicodeWrap(name,
+ TextDirectionHeuristics.FIRSTSTRONG_LTR))
+ .collect(Collectors.joining(formattedDelimiter));
+ return bidiFormatter.unicodeWrap(conversationName, TextDirectionHeuristics.LOCALE);
+ }
+
+ /**
+ * Sorts the list, and returns the first elements whose total length is less than the given
+ * conversationTitleLength.
+ */
+ private static List<String> getSortedSubsetNames(List<String> names,
+ int conversationTitleLength,
+ int delimiterLength) {
+ Collections.sort(names, Utils.ALPHA_THEN_NUMERIC_COMPARATOR);
+ int namesCounter = 0;
+ int indexCounter = 0;
+ while (namesCounter < conversationTitleLength && indexCounter < names.size()) {
+ namesCounter = namesCounter + names.get(indexCounter).length() + delimiterLength;
+ indexCounter = indexCounter + 1;
+ }
+ return names.subList(0, indexCounter);
+ }
+
+ /** Comparator that sorts names alphabetically first, then phone numbers numerically. **/
+ public static final Comparator<String> ALPHA_THEN_NUMERIC_COMPARATOR =
+ new Comparator<String>() {
+ private boolean isPhoneNumber(String input) {
+ PhoneNumberUtil util = PhoneNumberUtil.getInstance();
+ try {
+ Phonenumber.PhoneNumber phoneNumber = util.parse(input, /* defaultRegion */
+ null);
+ return util.isValidNumber(phoneNumber);
+ } catch (NumberParseException e) {
+ return false;
+ }
+ }
+
+ private boolean isOfSameType(String o1, String o2) {
+ boolean isO1PhoneNumber = isPhoneNumber(o1);
+ boolean isO2PhoneNumber = isPhoneNumber(o2);
+ return isO1PhoneNumber == isO2PhoneNumber;
+ }
+
+ @Override
+ public int compare(String o1, String o2) {
+ // if both are names, sort based on names.
+ // if both are number, sort numerically.
+ // if one is phone number and the other is a name, give name precedence.
+ if (!isOfSameType(o1, o2)) {
+ return isPhoneNumber(o1) ? 1 : -1;
+ } else {
+ return o1.compareTo(o2);
+ }
+ }
+ };
}
diff --git a/car-messenger-common/tests/unit/Android.bp b/car-messenger-common/tests/unit/Android.bp
new file mode 100644
index 0000000..c5d29fa
--- /dev/null
+++ b/car-messenger-common/tests/unit/Android.bp
@@ -0,0 +1,39 @@
+//
+// Copyright (C) 2019 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.
+//
+
+android_test {
+ name: "car-messenger-common-lib-unit-tests",
+
+ srcs: ["src/**/*.java"],
+
+ libs: [
+ "android.test.runner",
+ "android.test.base",
+ "android.test.mock",
+ ],
+
+ static_libs: [
+ "android.car",
+ "androidx.test.core",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "car-messenger-common",
+ "mockito-target-extended-minus-junit4",
+ "truth-prebuilt",
+ ],
+
+ platform_apis: true,
+}
\ No newline at end of file
diff --git a/car-messenger-common/tests/unit/AndroidManifest.xml b/car-messenger-common/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..eefce32
--- /dev/null
+++ b/car-messenger-common/tests/unit/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<!--
+ ~ Copyright (C) 2020 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.common.tests.unit">
+ <uses-permission android:name="android.car.permission.ACCESS_CAR_PROJECTION_STATUS"/>
+ <application android:testOnly="true"
+ android:debuggable="true"
+ xmlns:tools="http://schemas.android.com/tools">
+ <uses-library android:name="android.test.runner" />
+ <!-- Workaround for b/113294940 -->
+ <provider
+ android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer"
+ tools:replace="android:authorities"
+ android:authorities="${applicationId}.lifecycle"
+ android:exported="false"
+ android:multiprocess="true" />
+ </application>
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.car.messenger.common.tests.unit"
+ android:label="Car Messenger Lib Test Cases" />
+</manifest>
\ No newline at end of file
diff --git a/car-messenger-common/tests/unit/README.md b/car-messenger-common/tests/unit/README.md
new file mode 100644
index 0000000..12bb628
--- /dev/null
+++ b/car-messenger-common/tests/unit/README.md
@@ -0,0 +1,24 @@
+# Instructions for running unit tests
+
+### Build unit test module
+
+`m car-messenger-common-lib-unit-tests`
+
+### Install resulting apk on device
+
+`adb install -r -t $OUT/testcases/car-messenger-common-lib-unit-tests/arm64/car-messenger-common-lib-unit-tests.apk`
+
+### Run all tests
+
+`adb shell am instrument -w com.android.car.messenger.common.tests.unit`
+
+### Run tests in a class
+
+`adb shell am instrument -w -e class com.android.car.messenger.common.<classPath> com.android.car.messenger.common.tests.unit`
+
+### Run a specific test
+
+`adb shell am instrument -w -e class com.android.car.messenger.common.<classPath>#<testMethod> com.android.car.messenger.common.tests.unit`
+
+More general information can be found at
+http://developer.android.com/reference/android/support/test/runner/AndroidJUnitRunner.html
\ No newline at end of file
diff --git a/car-messenger-common/tests/unit/src/com.android.car.messenger.common/UtilsTest.java b/car-messenger-common/tests/unit/src/com.android.car.messenger.common/UtilsTest.java
new file mode 100644
index 0000000..acdffbd
--- /dev/null
+++ b/car-messenger-common/tests/unit/src/com.android.car.messenger.common/UtilsTest.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2020 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.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.text.BidiFormatter;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class UtilsTest {
+
+ private static final String ARABIC_NAME = "جﺗﺧ";
+ private static final List<String> NAMES = Arrays.asList("+1-650-900-1234", "Logan.", "Emily",
+ "Christopher", "!Sam", ARABIC_NAME);
+ private static final String NAME_DELIMITER = "، ";
+ private static final String TITLE_DELIMITER = " : ";
+ private static final int TITLE_LENGTH = 30;
+ private static final BidiFormatter RTL_FORMATTER = BidiFormatter.getInstance(/* rtlContext= */
+ true);
+
+ @Test
+ public void testNameWithMultipleNumbers() {
+ // Ensure that a group name with many phone numbers sorts the phone numbers correctly.
+ List<String> senderNames = Arrays.asList("+1-650-900-1234", "+1-650-900-1111",
+ "+1-100-200-1234");
+ String actual = Utils.constructGroupConversationTitle(senderNames, NAME_DELIMITER,
+ TITLE_LENGTH + 20);
+ String expected = "+1-100-200-1234، +1-650-900-1111، +1-650-900-1234";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNameWithInternationalNumbers() {
+ // Ensure that a group name with many phone numbers sorts the phone numbers correctly.
+ List<String> senderNames = Arrays.asList("+44-20-7183-8750", "+1-650-900-1111",
+ "+1-100-200-1234");
+ String actual = Utils.constructGroupConversationTitle(senderNames, NAME_DELIMITER,
+ TITLE_LENGTH + 20);
+ String expected = "+1-100-200-1234، +1-650-900-1111، +44-20-7183-8750";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNameConstructorLtr() {
+ String actual = Utils.constructGroupConversationTitle(NAMES, NAME_DELIMITER, TITLE_LENGTH);
+ assertThat(actual).isEqualTo("!Sam، Christopher، Emily، Logan.");
+ }
+
+ @Test
+ public void testNameConstructorLtr_longerTitle() {
+ String actual = Utils.constructGroupConversationTitle(NAMES, NAME_DELIMITER,
+ TITLE_LENGTH + 5);
+ assertThat(actual).isEqualTo(
+ "!Sam، Christopher، Emily، Logan.، \u200E\u202Bجﺗﺧ\u202C\u200E");
+
+ }
+
+ @Test
+ public void testTitleConstructorLtr() {
+ String actual = Utils.constructGroupConversationHeader("Christopher",
+ "!Sam، Emily، Logan.، +1-650-900-1234", TITLE_DELIMITER);
+ String expected = "Christopher : !Sam، Emily، Logan.، +1-650-900-1234";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testTitleConstructorLtr_with_rtlName() {
+ String actual = Utils.constructGroupConversationHeader(ARABIC_NAME, "!Sam، Logan.، جﺗﺧ",
+ TITLE_DELIMITER);
+ // Note: the Group name doesn't have the RTL tag because in the function we format the
+ // entire group name string, not each name in the string.
+ String expected = "\u200E\u202Bجﺗﺧ\u202C\u200E : !Sam، Logan.، جﺗﺧ\u200E";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testTitleConstructorLtr_with_phoneNumber() {
+ String actual = Utils.constructGroupConversationHeader("+1-650-900-1234",
+ "!Sam، Logan.، جﺗﺧ",
+ TITLE_DELIMITER);
+ // Note: the Group name doesn't have the RTL tag because in the function we format the
+ // entire group name string, not each name in the string.
+ String expected = "+1-650-900-1234 : !Sam، Logan.، جﺗﺧ\u200E";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ /**
+ * NOTE for all the RTL tests done below: When BidiFormatter is unicode-wrapping strings, they
+ * are actually adding invisible Unicode characters to denote whether a section is RTL, LTR,
+ * etc. These invisible characters are NOT visible on the terminal output, or if you copy
+ * paste the string to most HTML pages. They ARE visible when you paste them in certain
+ * text editors like IntelliJ, or there are some online tools that provide this as well.
+ *
+ * Therefore, in most of these RTL tests (and some of the LTR tests) you will see the
+ * invisible characters in the expected strings. Here's a couple of the characters, and what
+ * they're used for:
+ * \u200F is the RTL mark
+ * \u200E is the LTR mark
+ * \u202A marks the start of LTR embedding
+ * \u202B marks the start of RTL embedding
+ * \u202C pops the directional formatting - Must be used to end an embedding
+ */
+ @Test
+ public void testNameWithInternationalNumbers_rtl() {
+ // Ensure that a group name with many phone numbers sorts the phone numbers correctly.
+ List<String> senderNames = Arrays.asList("+44-20-7183-8750", "+1-650-900-1111",
+ "+1-100-200-1234");
+ String actual = Utils.constructGroupConversationTitle(senderNames, NAME_DELIMITER,
+ TITLE_LENGTH + 20, RTL_FORMATTER);
+ String expected = "\u200F\u202A\u200F\u202A+1-100-200-1234\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200F\u200F\u202A+1-650-900-1111\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200F\u200F\u202A+44-20-7183-8750\u202C\u200F\u202C\u200F";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNameConstructorRtl() {
+ String actual = Utils.constructGroupConversationTitle(NAMES, NAME_DELIMITER, TITLE_LENGTH,
+ /* isRtl */ RTL_FORMATTER);
+
+ String expected =
+ "\u200F\u202A\u200F\u202A!Sam\u202C\u200F\u200F\u202A، \u202C\u200F"
+ + "\u200F\u202AChristopher\u202C\u200F\u200F\u202A، \u202C\u200F"
+ + "\u200F\u202AEmily\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200F\u200F\u202ALogan.\u202C\u200F\u202C\u200F";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testNameConstructorRtl_longerTitle() {
+ String actual = Utils.constructGroupConversationTitle(NAMES, NAME_DELIMITER,
+ TITLE_LENGTH + 5, /* isRtl */ RTL_FORMATTER);
+
+ String expected =
+ "\u200F\u202A\u200F\u202A!Sam\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200F\u200F\u202AChristopher\u202C\u200F\u200F"
+ + "\u202A، \u202C\u200F\u200F\u202AEmily\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200F\u200F\u202ALogan.\u202C\u200F\u200F\u202A، "
+ + "\u202C\u200Fجﺗﺧ\u202C\u200F";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testTitleConstructorRtl_with_rtlName() {
+ String actual = Utils.constructGroupConversationHeader(ARABIC_NAME, "!Sam، Logan.، جﺗﺧ",
+ TITLE_DELIMITER, RTL_FORMATTER);
+ // Note: the Group name doesn't have the RTL tag because in the function we format the
+ // entire group name string, not each name in the string.
+ // Also, note that the sender's name, which is RTL still has LTR embedded because we wrap
+ // it with FIRSTSTRONG_LTR.
+ String expected = "\u200F\u202Aجﺗﺧ : \u200F\u202A!Sam، Logan.، جﺗﺧ\u202C\u200F\u202C"
+ + "\u200F";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+
+ @Test
+ public void testTitleConstructorRtl_with_phoneNumber() {
+ String actual = Utils.constructGroupConversationHeader("+1-650-900-1234",
+ "!Sam، Logan.، جﺗﺧ",
+ TITLE_DELIMITER, RTL_FORMATTER);
+ // Note: the Group name doesn't have the RTL tag because in the function we format the
+ // entire group name string, not each name in the string.
+ String expected = "\u200F\u202A\u200F\u202A+1-650-900-1234\u202C\u200F : "
+ + "\u200F\u202A!Sam، Logan.، جﺗﺧ\u202C\u200F\u202C\u200F";
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void testTitleConstructorRtl() {
+ String actual = Utils.constructGroupConversationHeader("Christopher",
+ "+1-650-900-1234، Logan.، Emily، Christopher، !Sam", TITLE_DELIMITER, /* isRtl */
+ RTL_FORMATTER).trim();
+
+ String expected =
+ "\u200F\u202A\u200F\u202AChristopher\u202C\u200F : \u200F\u202A+1-650-900-1234، "
+ + "Logan.، Emily، Christopher، !Sam\u202C\u200F\u202C\u200F";
+
+ assertThat(actual).isEqualTo(expected);
+ }
+}