Merge "Support two line text in AllApps/OnDeviceSearch w/ feature flag" into tm-qpr-dev
diff --git a/res/layout/all_apps_icon_twoline.xml b/res/layout/all_apps_icon_twoline.xml
index 54c7147..b0d02c1 100644
--- a/res/layout/all_apps_icon_twoline.xml
+++ b/res/layout/all_apps_icon_twoline.xml
@@ -19,7 +19,6 @@
     android:id="@+id/icon"
     android:singleLine="false"
     android:lines="2"
-    android:inputType="textMultiLine"
     launcher:iconDisplay="all_apps"
     launcher:centerVertically="true" />
 
diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java
index 80a8193..801b740 100644
--- a/src/com/android/launcher3/BubbleTextView.java
+++ b/src/com/android/launcher3/BubbleTextView.java
@@ -52,8 +52,10 @@
 
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.launcher3.accessibility.BaseAccessibilityDelegate;
+import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.dot.DotInfo;
 import com.android.launcher3.dragndrop.DragOptions.PreDragCondition;
 import com.android.launcher3.dragndrop.DraggableView;
@@ -71,6 +73,8 @@
 import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.popup.PopupContainerWithArrow;
 import com.android.launcher3.util.MultiTranslateDelegate;
+import com.android.launcher3.search.StringMatcherUtility;
+import com.android.launcher3.util.IntArray;
 import com.android.launcher3.util.SafeCloseable;
 import com.android.launcher3.util.ShortcutUtil;
 import com.android.launcher3.views.ActivityContext;
@@ -97,11 +101,19 @@
 
     private static final float MIN_LETTER_SPACING = -0.05f;
     private static final int MAX_SEARCH_LOOP_COUNT = 20;
+    private static final Character NEW_LINE = '\n';
+    private static final String EMPTY = "";
+    private static final StringMatcherUtility.StringMatcher MATCHER =
+            StringMatcherUtility.StringMatcher.getInstance();
 
     private static final int[] STATE_PRESSED = new int[]{android.R.attr.state_pressed};
 
     private float mScaleForReorderBounce = 1f;
 
+    private IntArray mBreakPointsIntArray;
+    private CharSequence mLastOriginalText;
+    private CharSequence mLastModifiedText;
+
     private static final Property<BubbleTextView, Float> DOT_SCALE_PROPERTY
             = new Property<BubbleTextView, Float>(Float.TYPE, "dotScale") {
         @Override
@@ -134,7 +146,7 @@
     private FastBitmapDrawable mIcon;
     private boolean mCenterVertically;
 
-    protected final int mDisplay;
+    protected int mDisplay;
 
     private final CheckLongPressHelper mLongPressHelper;
 
@@ -255,6 +267,8 @@
         mDotParams.scale = 0f;
         mForceHideDot = false;
         setBackground(null);
+        setSingleLine(true);
+        setMaxLines(1);
 
         setTag(null);
         if (mIconLoadRequest != null) {
@@ -382,8 +396,15 @@
     }
 
     @UiThread
-    private void applyLabel(ItemInfoWithIcon info) {
-        setText(info.title);
+    @VisibleForTesting
+    public void applyLabel(ItemInfoWithIcon info) {
+        CharSequence label = info.title;
+        if (label != null) {
+            mLastOriginalText = label;
+            mLastModifiedText = mLastOriginalText;
+            mBreakPointsIntArray = StringMatcherUtility.getListOfBreakpoints(label, MATCHER);
+            setText(label);
+        }
         if (info.contentDescription != null) {
             setContentDescription(info.isDisabled()
                     ? getContext().getString(R.string.disabled_app_label, info.contentDescription)
@@ -391,6 +412,12 @@
         }
     }
 
+    /** This is used for testing to forcefully set the display to ALL_APPS */
+    @VisibleForTesting
+    public void setDisplayAllApps() {
+        mDisplay = DISPLAY_ALL_APPS;
+    }
+
     /**
      * Overrides the default long press timeout.
      */
@@ -637,6 +664,27 @@
             setPadding(getPaddingLeft(), (height - cellHeightPx) / 2, getPaddingRight(),
                     getPaddingBottom());
         }
+        // only apply two line for all_apps
+        if (FeatureFlags.ENABLE_TWOLINE_ALLAPPS.get() && (mLastOriginalText != null)
+                && (mDisplay == DISPLAY_ALL_APPS)) {
+            CharSequence modifiedString = modifyTitleToSupportMultiLine(
+                    MeasureSpec.getSize(widthMeasureSpec) - getCompoundPaddingLeft()
+                            - getCompoundPaddingRight(),
+                    mLastOriginalText,
+                    getPaint(), mBreakPointsIntArray);
+            if (!TextUtils.equals(modifiedString, mLastModifiedText)) {
+                mLastModifiedText = modifiedString;
+                setText(modifiedString);
+                // if text contains NEW_LINE, set max lines to 2
+                if (TextUtils.indexOf(modifiedString, NEW_LINE) != -1) {
+                    setSingleLine(false);
+                    setMaxLines(2);
+                } else {
+                    setSingleLine(true);
+                    setMaxLines(1);
+                }
+            }
+        }
         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
     }
 
@@ -697,6 +745,73 @@
         return ObjectAnimator.ofFloat(this, TEXT_ALPHA_PROPERTY, toAlpha);
     }
 
+    /**
+     * Generate a new string that will support two line text depending on the current string.
+     * This method calculates the limited width of a text view and creates a string to fit as
+     * many words as it can until the limit is reached. Once the limit is reached, we decide to
+     * either return the original title or continue on a new line. How to get the new string is by
+     * iterating through the list of break points and determining if the strings between the break
+     * points can fit within the line it is in.
+     *  Example assuming each character takes up one spot:
+     *  title = "Battery Stats", breakpoint = [6], stringPtr = 0, limitedWidth = 7
+     *  We get the current word -> from sublist(0, breakpoint[i]+1) so sublist (0,7) -> Battery,
+     *  now stringPtr = 7 then from sublist(7) the current string is " Stats" and the runningWidth
+     *  at this point exceeds limitedWidth and so we put " Stats" onto the next line (after checking
+     *  if the first char is a SPACE, we trim to append "Stats". So resulting string would be
+     *  "Battery\nStats"
+     */
+    public static CharSequence modifyTitleToSupportMultiLine(int limitedWidth, CharSequence title,
+            TextPaint paint, IntArray breakPoints) {
+        // current title is less than the width allowed so we can just skip
+        if (title == null || paint.measureText(title, 0, title.length()) <= limitedWidth) {
+            return title;
+        }
+        float currentWordWidth, runningWidth = 0;
+        CharSequence currentWord;
+        StringBuilder newString = new StringBuilder();
+        int stringPtr = 0;
+        for (int i = 0; i < breakPoints.size()+1; i++) {
+            if (i < breakPoints.size()) {
+                currentWord = title.subSequence(stringPtr, breakPoints.get(i)+1);
+            } else {
+                // last word from recent breakpoint until the end of the string
+                currentWord = title.subSequence(stringPtr, title.length());
+            }
+            currentWordWidth = paint.measureText(currentWord,0, currentWord.length());
+            runningWidth += currentWordWidth;
+            if (runningWidth <= limitedWidth) {
+                newString.append(currentWord);
+            } else {
+                // there is no more space
+                if (i == 0) {
+                    // if the first words exceeds width, just return as the first line will ellipse
+                    return title;
+                } else {
+                    // If putting word onto a new line, make sure there is no space or new line
+                    // character in the beginning of the current word and just put in the rest of
+                    // the characters.
+                    CharSequence lastCharacters = title.subSequence(stringPtr, title.length());
+                    int beginningLetterType =
+                            Character.getType(Character.codePointAt(lastCharacters,0));
+                    if (beginningLetterType == Character.SPACE_SEPARATOR
+                            || beginningLetterType == Character.LINE_SEPARATOR) {
+                        lastCharacters = lastCharacters.length() > 1
+                                ? lastCharacters.subSequence(1, lastCharacters.length())
+                                : EMPTY;
+                    }
+                    newString.append(NEW_LINE).append(lastCharacters);
+                    return newString.toString();
+                }
+            }
+            if (i >= breakPoints.size()) {
+                // no need to look forward into the string if we've already finished processing
+                break;
+            }
+            stringPtr = breakPoints.get(i)+1;
+        }
+        return newString.toString();
+    }
+
     @Override
     public void cancelLongPress() {
         super.cancelLongPress();
diff --git a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
index 7040de5..8fa4276 100644
--- a/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
+++ b/src/com/android/launcher3/allapps/BaseAllAppsAdapter.java
@@ -33,6 +33,7 @@
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.R;
 import com.android.launcher3.allapps.search.SearchAdapterProvider;
+import com.android.launcher3.Utilities;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.model.data.AppInfo;
 import com.android.launcher3.views.ActivityContext;
@@ -140,7 +141,7 @@
     protected final OnClickListener mOnIconClickListener;
     protected OnLongClickListener mOnIconLongClickListener = INSTANCE_ALL_APPS;
     protected OnFocusChangeListener mIconFocusListener;
-    private final int mExtraHeight;
+    private final int mExtraTextHeight;
 
     public BaseAllAppsAdapter(T activityContext, LayoutInflater inflater,
             AlphabeticalAppsList<T> apps, SearchAdapterProvider<?> adapterProvider) {
@@ -152,7 +153,8 @@
         mOnIconClickListener = mActivityContext.getItemOnClickListener();
 
         mAdapterProvider = adapterProvider;
-        mExtraHeight = res.getDimensionPixelSize(R.dimen.all_apps_height_extra);
+        mExtraTextHeight = Utilities.calculateTextHeight(
+                mActivityContext.getDeviceProfile().allAppsIconTextSizePx);
     }
 
     /**
@@ -197,7 +199,7 @@
                 icon.getLayoutParams().height =
                         mActivityContext.getDeviceProfile().allAppsCellHeightPx;
                 if (FeatureFlags.ENABLE_TWOLINE_ALLAPPS.get()) {
-                    icon.getLayoutParams().height += mExtraHeight;
+                    icon.getLayoutParams().height += mExtraTextHeight;
                 }
                 return new ViewHolder(icon);
             case VIEW_TYPE_EMPTY_SEARCH:
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index 6bb2a0f..d1aaef1 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -113,6 +113,10 @@
     public static final BooleanFlag ENABLE_TWOLINE_ALLAPPS = getDebugFlag(270390937,
             "ENABLE_TWOLINE_ALLAPPS", false, "Enables two line label inside all apps.");
 
+    public static final BooleanFlag ENABLE_TWOLINE_DEVICESEARCH = getDebugFlag(201388851,
+            "ENABLE_TWOLINE_DEVICESEARCH", false,
+            "Enable two line label for icons with labels on device search.");
+
     public static final BooleanFlag ENABLE_DEVICE_SEARCH_PERFORMANCE_LOGGING = getReleaseFlag(
             270391397, "ENABLE_DEVICE_SEARCH_PERFORMANCE_LOGGING", false,
             "Allows on device search in all apps logging");
diff --git a/src/com/android/launcher3/search/StringMatcherUtility.java b/src/com/android/launcher3/search/StringMatcherUtility.java
index c66f3a1..28fc4f0 100644
--- a/src/com/android/launcher3/search/StringMatcherUtility.java
+++ b/src/com/android/launcher3/search/StringMatcherUtility.java
@@ -16,13 +16,20 @@
 
 package com.android.launcher3.search;
 
+import android.text.TextUtils;
+
+import com.android.launcher3.util.IntArray;
+
 import java.text.Collator;
+import java.util.stream.IntStream;
 
 /**
  * Utilities for matching query string to target string.
  */
 public class StringMatcherUtility {
 
+    private static final Character SPACE = ' ';
+
     /**
      * Returns {@code true} if {@code query} is a prefix of a substring in {@code target}. How to
      * break target to valid substring is defined in the given {@code matcher}.
@@ -59,6 +66,41 @@
     }
 
     /**
+     * Returns a list of breakpoints wherever the string contains a break. For example:
+     * "t-mobile" would have breakpoints at [0, 1]
+     * "Agar.io" would have breakpoints at [3, 4]
+     * "LEGO®Builder" would have a breakpoint at [4]
+     */
+    public static IntArray getListOfBreakpoints(CharSequence input, StringMatcher matcher) {
+        int inputLength = input.length();
+        if ((inputLength <= 2) || TextUtils.indexOf(input, SPACE) != -1) {
+            // when there is a space in the string, return a list where the elements are the
+            // position of the spaces - 1. This is to make the logic consistent where breakpoints
+            // are placed
+            return IntArray.wrap(IntStream.range(0, inputLength)
+                    .filter(i -> input.charAt(i) == SPACE)
+                    .map(i -> i - 1)
+                    .toArray());
+        }
+        IntArray listOfBreakPoints = new IntArray();
+        int prevType;
+        int thisType = Character.getType(Character.codePointAt(input, 0));
+        int nextType = Character.getType(Character.codePointAt(input, 1));
+        for (int i = 1; i < inputLength; i++) {
+            prevType = thisType;
+            thisType = nextType;
+            nextType = i < (inputLength - 1)
+                    ? Character.getType(Character.codePointAt(input, i + 1))
+                    : Character.UNASSIGNED;
+            if (matcher.isBreak(thisType, prevType, nextType)) {
+                // breakpoint is at previous
+                listOfBreakPoints.add(i-1);
+            }
+        }
+        return listOfBreakPoints;
+    }
+
+    /**
      * Performs locale sensitive string comparison using {@link Collator}.
      */
     public static class StringMatcher {
@@ -118,7 +160,11 @@
             }
             switch (thisType) {
                 case Character.UPPERCASE_LETTER:
-                    if (nextType == Character.UPPERCASE_LETTER) {
+                    // takes care of the case where there are consistent uppercase letters as well
+                    // as a special symbol following the capitalize letters for example: LEGO®
+                    if (nextType != Character.UPPERCASE_LETTER && nextType != Character.OTHER_SYMBOL
+                            && nextType != Character.DECIMAL_DIGIT_NUMBER
+                            && nextType != Character.UNASSIGNED) {
                         return true;
                     }
                     // Follow through
diff --git a/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java
index 3b53255..0ba46f4 100644
--- a/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java
+++ b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java
@@ -15,8 +15,10 @@
  */
 package com.android.launcher3.search;
 
+import static com.android.launcher3.search.StringMatcherUtility.getListOfBreakpoints;
 import static com.android.launcher3.search.StringMatcherUtility.matches;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
@@ -25,6 +27,7 @@
 
 import com.android.launcher3.search.StringMatcherUtility.StringMatcher;
 import com.android.launcher3.search.StringMatcherUtility.StringMatcherSpace;
+import com.android.launcher3.util.IntArray;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -138,4 +141,96 @@
         assertFalse(matches("phant", "elephant", MATCHER_SPACE));
         assertFalse(matches("elephants", "elephant", MATCHER_SPACE));
     }
+
+    @Test
+    public void testStringWithProperBreaks() {
+        // empty string
+        assertEquals(IntArray.wrap(), getListOfBreakpoints("", MATCHER));
+
+        // should be "D Dz" that's why breakpoint is at 0
+        assertEquals(IntArray.wrap(0), getListOfBreakpoints("DDz", MATCHER));
+
+        // test all caps and all lower-case
+        assertEquals(IntArray.wrap(), getListOfBreakpoints("SNKRS", MATCHER));
+        assertEquals(IntArray.wrap(), getListOfBreakpoints("flutterappflorafy", MATCHER));
+        assertEquals(IntArray.wrap(), getListOfBreakpoints("LEGO®", MATCHER));
+
+        // test camel case
+        // breakpoint at 9 to be "flutterapp Florafy"
+        assertEquals(IntArray.wrap(9), getListOfBreakpoints("flutterappFlorafy", MATCHER));
+        // breakpoint at 4 to be "Metro Zone"
+        assertEquals(IntArray.wrap(4), getListOfBreakpoints("MetroZone", MATCHER));
+        // breakpoint at 4,5 to be "metro X Zone"
+        assertEquals(IntArray.wrap(4,5), getListOfBreakpoints("metroXZone", MATCHER));
+        // breakpoint at 0 to be "G Pay"
+        assertEquals(IntArray.wrap(0), getListOfBreakpoints("GPay", MATCHER));
+        // breakpoint at 4 to be "Whats App"
+        assertEquals(IntArray.wrap(4), getListOfBreakpoints("WhatsApp", MATCHER));
+        // breakpoint at 2 to be "aaa A"
+        assertEquals(IntArray.wrap(2), getListOfBreakpoints("aaaA", MATCHER));
+        // breakpoint at 4,12,16 to be "Other Launcher Test App"
+        assertEquals(IntArray.wrap(4,12,16),
+                getListOfBreakpoints("OtherLauncherTestApp", MATCHER));
+
+        // test with TITLECASE_LETTER
+        // should be "DDz" that's why there are no break points
+        assertEquals(IntArray.wrap(), getListOfBreakpoints("DDz", MATCHER));
+        // breakpoint at 0 to be "D DDž"
+        assertEquals(IntArray.wrap(0), getListOfBreakpoints("DDDž", MATCHER));
+        // breakpoint at 0 because there is a space to be "Dž DD"
+        assertEquals(IntArray.wrap(0), getListOfBreakpoints("Dž DD", MATCHER));
+        // breakpoint at 1 to be "Dw Dz"
+        assertEquals(IntArray.wrap(1), getListOfBreakpoints("DwDz", MATCHER));
+        // breakpoint at 0,2 to be "Dw Dz"
+        assertEquals(IntArray.wrap(0,2), getListOfBreakpoints("wDwDz", MATCHER));
+        // breakpoint at 1,3 to be "ᾋw Dw Dz"
+        assertEquals(IntArray.wrap(1,3), getListOfBreakpoints("ᾋwDwDz", MATCHER));
+        // breakpoint at 0,2,4 to be "ᾋ ᾋw Dw Dz"
+        assertEquals(IntArray.wrap(0,2,4), getListOfBreakpoints("ᾋᾋwDwDz", MATCHER));
+        // breakpoint at 0,2,4 to be "ᾋ ᾋw Dw Dz®"
+        assertEquals(IntArray.wrap(0,2,4), getListOfBreakpoints("ᾋᾋwDwDz®", MATCHER));
+
+        // test with numbers and symbols
+        // breakpoint at 3,11 to be "Test Activity 13"
+        assertEquals(IntArray.wrap(3,11), getListOfBreakpoints("TestActivity13", MATCHER));
+        // breakpoint at 3, 4, 12, 13 as the breakpoints are at the dashes
+        assertEquals(IntArray.wrap(3,4,12,13),
+                getListOfBreakpoints("Test-Activity-12", MATCHER));
+        // breakpoint at 1 to be "AA 2"
+        assertEquals(IntArray.wrap(1), getListOfBreakpoints("AA2", MATCHER));
+        // breakpoint at 1 to be "AAA 2"
+        assertEquals(IntArray.wrap(2), getListOfBreakpoints("AAA2", MATCHER));
+        // breakpoint at 1 to be "ab 2"
+        assertEquals(IntArray.wrap(1), getListOfBreakpoints("ab2", MATCHER));
+        // breakpoint at 1,2 to be "el 3 suhwee"
+        assertEquals(IntArray.wrap(1,2), getListOfBreakpoints("el3suhwee", MATCHER));
+        // breakpoint at 0,1 as the breakpoints are at '-'
+        assertEquals(IntArray.wrap(0,1), getListOfBreakpoints("t-mobile", MATCHER));
+        assertEquals(IntArray.wrap(0,1), getListOfBreakpoints("t-Mobile", MATCHER));
+        // breakpoint at 0,1,2 as the breakpoints are at '-'
+        assertEquals(IntArray.wrap(0,1,2), getListOfBreakpoints("t--Mobile", MATCHER));
+        // breakpoint at 1,2,3 as the breakpoints are at '-'
+        assertEquals(IntArray.wrap(1,2,3), getListOfBreakpoints("tr--Mobile", MATCHER));
+        // breakpoint at 3,4 as the breakpoints are at '.'
+        assertEquals(IntArray.wrap(3,4), getListOfBreakpoints("Agar.io", MATCHER));
+        assertEquals(IntArray.wrap(3,4), getListOfBreakpoints("Hole.Io", MATCHER));
+
+        // breakpoint at 0 to be "µ Torrent®"
+        assertEquals(IntArray.wrap(0), getListOfBreakpoints("µTorrent®", MATCHER));
+        // breakpoint at 4 to be "LEGO® Builder"
+        assertEquals(IntArray.wrap(4), getListOfBreakpoints("LEGO®Builder", MATCHER));
+        // breakpoint at 4 to be "LEGO® builder"
+        assertEquals(IntArray.wrap(4), getListOfBreakpoints("LEGO®builder", MATCHER));
+        // breakpoint at 4 to be "lego® builder"
+        assertEquals(IntArray.wrap(4), getListOfBreakpoints("lego®builder", MATCHER));
+
+        // test string with spaces - where the breakpoints are right before where the spaces are at
+        assertEquals(IntArray.wrap(3,8), getListOfBreakpoints("HEAD BALL 2", MATCHER));
+        assertEquals(IntArray.wrap(2,8),
+                getListOfBreakpoints("OFL Agent Application", MATCHER));
+        assertEquals(IntArray.wrap(0,2), getListOfBreakpoints("D D z", MATCHER));
+        assertEquals(IntArray.wrap(6), getListOfBreakpoints("Battery Stats", MATCHER));
+        assertEquals(IntArray.wrap(5,9,15),
+                getListOfBreakpoints("System UWB Field Test", MATCHER));
+    }
 }
diff --git a/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java b/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java
new file mode 100644
index 0000000..528f7ac
--- /dev/null
+++ b/tests/src/com/android/launcher3/ui/BubbleTextViewTest.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2023 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.launcher3.ui;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+
+import static com.android.launcher3.config.FeatureFlags.ENABLE_TWOLINE_ALLAPPS;
+
+import static org.junit.Assert.assertEquals;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.view.ViewGroup;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.Utilities;
+import com.android.launcher3.model.data.ItemInfoWithIcon;
+import com.android.launcher3.search.StringMatcherUtility;
+import com.android.launcher3.util.ActivityContextWrapper;
+import com.android.launcher3.util.IntArray;
+import com.android.launcher3.util.TestUtil;
+import com.android.launcher3.views.BaseDragLayer;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for testing modifyTitleToSupportMultiLine() in BubbleTextView.java
+ * This class tests a couple of strings and uses the getMaxLines() to determine if the test passes.
+ * Verifying with getMaxLines() is sufficient since BubbleTextView can only be in one line or
+ * two lines, and this is enough to ensure whether the string should be specifically wrapped onto
+ * the second line and to ensure truncation.
+ */
+public class BubbleTextViewTest {
+
+    private static final StringMatcherUtility.StringMatcher
+            MATCHER = StringMatcherUtility.StringMatcher.getInstance();
+    private static final int ONE_LINE = 1;
+    private static final int TWO_LINE = 2;
+    private static final String TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT = "Battery Stats";
+    private static final String TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT_RESULT =
+            "Battery\nStats";
+    private static final String TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT =
+            "flutterappflorafy";
+    private static final String TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT =
+            "System UWB Field Test";
+    private static final String TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT_RESULT =
+            "System\nUWB Field Test";
+    private static final String TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT =
+            "LEGO®Builder";
+    private static final String TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT_RESULT =
+            "LEGO®\nBuilder";
+    private static final String EMPTY_STRING = "";
+    private static final int CHAR_CNT = 7;
+
+    private BubbleTextView mBubbleTextView;
+    private ItemInfoWithIcon mItemInfoWithIcon;
+    private Context mContext;
+    private int mLimitedWidth;
+
+    @Before
+    public void setUp() throws Exception {
+        Utilities.enableRunningInTestHarnessForTests();
+        mContext = new ActivityContextWrapper(getApplicationContext());
+        mBubbleTextView = new BubbleTextView(mContext);
+        mBubbleTextView.reset();
+        mBubbleTextView.setDisplayAllApps();
+        assertEquals(ONE_LINE, mBubbleTextView.getMaxLines());
+
+        BubbleTextView testView = new BubbleTextView(mContext);
+        testView.setTypeface(Typeface.MONOSPACE);
+        testView.setText("B");
+        // calculate the maxWidth of the textView by calculating the width of one monospace
+        // character * CHAR_CNT
+        mLimitedWidth =
+                (int) (testView.getPaint().measureText(testView.getText().toString()) * CHAR_CNT);
+        // needed otherwise there is a NPE during setText() on checkForRelayout()
+        mBubbleTextView.setLayoutParams(
+                new ViewGroup.LayoutParams(mLimitedWidth,
+                BaseDragLayer.LayoutParams.WRAP_CONTENT));
+        mItemInfoWithIcon = new ItemInfoWithIcon() {
+            @Override
+            public ItemInfoWithIcon clone() {
+                return null;
+            }
+        };
+    }
+
+    @Test
+    public void testEmptyString_flagOn() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, true);
+        mItemInfoWithIcon.title = EMPTY_STRING;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getMaxLines());
+    }
+
+    @Test
+    public void testEmptyString_flagOff() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, false);
+        mItemInfoWithIcon.title = EMPTY_STRING;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testStringWithSpaceLongerThanCharLimit_flagOn() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, true);
+        // test string: "Battery Stats"
+        mItemInfoWithIcon.title = TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(TWO_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testStringWithSpaceLongerThanCharLimit_flagOff() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, false);
+        // test string: "Battery Stats"
+        mItemInfoWithIcon.title = TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringNoSpaceLongerThanCharLimit_flagOn() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, true);
+        // test string: "flutterappflorafy"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringNoSpaceLongerThanCharLimit_flagOff() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, false);
+        // test string: "flutterappflorafy"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringWithSpaceLongerThanCharLimit_flagOn() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, true);
+        // test string: "System UWB Field Test"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(TWO_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringWithSpaceLongerThanCharLimit_flagOff() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, false);
+        // test string: "System UWB Field Test"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringSymbolLongerThanCharLimit_flagOn() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, true);
+        // test string: "LEGO®Builder"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(TWO_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void testLongStringSymbolLongerThanCharLimit_flagOff() {
+        TestUtil.overrideFlag(ENABLE_TWOLINE_ALLAPPS, false);
+        // test string: "LEGO®Builder"
+        mItemInfoWithIcon.title = TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT;
+        mBubbleTextView.applyLabel(mItemInfoWithIcon);
+        mBubbleTextView.setTypeface(Typeface.MONOSPACE);
+        mBubbleTextView.measure(mLimitedWidth, 0);
+        mBubbleTextView.onPreDraw();
+        assertEquals(ONE_LINE, mBubbleTextView.getLineCount());
+    }
+
+    @Test
+    public void modifyTitleToSupportMultiLine_TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT() {
+        // test string: "Battery Stats"
+        IntArray breakPoints = StringMatcherUtility.getListOfBreakpoints(
+                TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT, MATCHER);
+        CharSequence newString = BubbleTextView.modifyTitleToSupportMultiLine(mLimitedWidth,
+                TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT, mBubbleTextView.getPaint(),
+                breakPoints);
+        assertEquals(TEST_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT_RESULT, newString);
+    }
+
+    @Test
+    public void modifyTitleToSupportMultiLine_TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT() {
+        // test string: "flutterappflorafy"
+        IntArray breakPoints = StringMatcherUtility.getListOfBreakpoints(
+                TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT, MATCHER);
+        CharSequence newString = BubbleTextView.modifyTitleToSupportMultiLine(mLimitedWidth,
+                TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT, mBubbleTextView.getPaint(),
+                breakPoints);
+        assertEquals(TEST_LONG_STRING_NO_SPACE_LONGER_THAN_CHAR_LIMIT, newString);
+    }
+
+    @Test
+    public void modifyTitleToSupportMultiLine_TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT() {
+        // test string: "System UWB Field Test"
+        IntArray breakPoints = StringMatcherUtility.getListOfBreakpoints(
+                TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT, MATCHER);
+        CharSequence newString = BubbleTextView.modifyTitleToSupportMultiLine(mLimitedWidth,
+                TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT, mBubbleTextView.getPaint(),
+                breakPoints);
+        assertEquals(TEST_LONG_STRING_WITH_SPACE_LONGER_THAN_CHAR_LIMIT_RESULT, newString);
+    }
+
+    @Test
+    public void modifyTitleToSupportMultiLine_TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT() {
+        // test string: "LEGO®Builder"
+        IntArray breakPoints = StringMatcherUtility.getListOfBreakpoints(
+                TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT, MATCHER);
+        CharSequence newString = BubbleTextView.modifyTitleToSupportMultiLine(mLimitedWidth,
+                TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT, mBubbleTextView.getPaint(),
+                breakPoints);
+        assertEquals(TEST_LONG_STRING_SYMBOL_LONGER_THAN_CHAR_LIMIT_RESULT, newString);
+    }
+}