Repository: beeware/toga

Issue:
Android Label widget can't be used on API 28 or earlier
### Describe the bug

The Android Label widget references the LineBreaker class, which wasn't added until API level 29 (Android 10).

If you try to launch an app that uses label on Android 9 or earlier, the app will crash.

### Steps to reproduce

1. Launch any Android ap that uses toga.Label
2. See error 

(logs provided below)


### Expected behavior

The gradle project specifies an API minimum of 21 (5); Toga should adhere to the same minimum.

### Screenshots

_No response_

### Environment

- Operating System: Android 8.1
- Python version: Any
- Software versions:
  - Briefcase: 0.3.14
  - Toga: 0.3.1


### Logs

```
04-17 15:29:56.737  6976  6976 E AndroidRuntime: java.lang.RuntimeException: Unable to start activity ComponentInfo{com.keith_magee.traveltips/org.beeware.android.MainActivity}: com.chaquo.python.PyException: java.lang.NoClassDefFoundError: android.graphics.text.LineBreaker
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2805)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2883)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread.-wrap11(Unknown Source:0)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1613)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.os.Handler.dispatchMessage(Handler.java:106)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.os.Looper.loop(Looper.java:164)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread.main(ActivityThread.java:6523)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at java.lang.reflect.Method.invoke(Native Method)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:857)
04-17 15:29:56.737  6976  6976 E AndroidRuntime: Caused by: com.chaquo.python.PyException: java.lang.NoClassDefFoundError: android.graphics.text.LineBreaker
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.chaquo.python.PyObject.callAttr(PyObject.java:225)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at org.beeware.android.MainActivity.onCreate(MainActivity.java:85)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.Activity.performCreate(Activity.java:7023)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.Activity.performCreate(Activity.java:7014)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1214)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2758)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        ... 9 more
04-17 15:29:56.737  6976  6976 E AndroidRuntime: Caused by: java.lang.NoClassDefFoundError: android.graphics.text.LineBreaker
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at java.lang.Class.classForName(Native Method)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at java.lang.Class.forName(Class.java:453)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.CQPEnv.FindClass(env.pxi:63)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.JavaClass.__new__(class.pxi:79)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.new_class(class.pxi:67)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.jclass(class.pxi:59)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.jclass(class.pxi:53)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.jclass(class.pxi:34)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.toga_android.libs.android.graphics.<module>(graphics.py:17)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.import_override(import.pxi:26)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.toga_android.app.<module>(app.py:9)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.chaquopy.import_override(import.pxi:26)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.toga_android.factory.<module>(factory.py:2)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap._call_with_frames_removed(<frozen importlib._bootstrap>:241)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap_external.exec_module(<frozen importlib._bootstrap_external>:883)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.android.importer.exec_module(importer.py:554)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.java.android.importer.exec_module(importer.py:606)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap._load_unlocked(<frozen importlib._bootstrap>:688)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap._find_and_load_unlocked(<frozen importlib._bootstrap>:1006)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap._find_and_load(<frozen importlib._bootstrap>:1027)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib._bootstrap._gcd_import(<frozen importlib._bootstrap>:1050)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.importlib.import_module(__init__.py:126)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.toga.platform.get_platform_factory(platform.py:122)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.toga.app.__init__(app.py:323)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.traveltips.app.main(app.py:237)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.__main__.<module>(__main__.py:4)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.runpy._run_code(runpy.py:86)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.runpy._run_module_code(runpy.py:96)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.runpy.run_module(runpy.py:224)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.chaquopy_java.call(chaquopy_java.pyx:354)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at <python>.chaquopy_java.Java_com_chaquo_python_PyObject_callAttrThrowsNative(chaquopy_java.pyx:326)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.chaquo.python.PyObject.callAttrThrowsNative(Native Method)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.chaquo.python.PyObject.callAttrThrows(PyObject.java:232)
04-17 15:29:56.737  6976  6976 E AndroidRuntime:        at com.chaquo.python.PyObject.callAttr(PyObject.java:221)
```


### Additional context

The implementation of `rehint()` for Label includes a version check against API Level O (26/8.0), to protect against the non-existence of `setJustificationMode` API; however, the constant that is used in both cases uses the `LineBreaker` class, which was only introduced in API 29.

The issue isn't just the usage of the constant - it's the import of the `LineBreaker` class, which won't exist (and thus crashes on app startup).


Discussion:

Resolving patch:
diff --git a/android/src/toga_android/libs/android/graphics.py b/android/src/toga_android/libs/android/graphics.py
--- a/android/src/toga_android/libs/android/graphics.py
+++ b/android/src/toga_android/libs/android/graphics.py
@@ -13,5 +13,3 @@
 PorterDuffColorFilter = JavaClass("android/graphics/PorterDuffColorFilter")
 Rect = JavaClass("android/graphics/Rect")
 Typeface = JavaClass("android/graphics/Typeface")
-
-LineBreaker = JavaClass("android/graphics/text/LineBreaker")
diff --git a/android/src/toga_android/libs/android/text.py b/android/src/toga_android/libs/android/text.py
--- a/android/src/toga_android/libs/android/text.py
+++ b/android/src/toga_android/libs/android/text.py
@@ -1,4 +1,5 @@
 from rubicon.java import JavaClass, JavaInterface
 
 InputType = JavaClass("android/text/InputType")
+Layout = JavaClass("android/text/Layout")
 TextWatcher = JavaInterface("android/text/TextWatcher")
diff --git a/android/src/toga_android/widgets/label.py b/android/src/toga_android/widgets/label.py
--- a/android/src/toga_android/widgets/label.py
+++ b/android/src/toga_android/widgets/label.py
@@ -3,8 +3,8 @@
 from toga.constants import JUSTIFY
 from toga_android.colors import native_color
 
-from ..libs.android.graphics import LineBreaker
 from ..libs.android.os import Build
+from ..libs.android.text import Layout
 from ..libs.android.view import Gravity, View__MeasureSpec
 from ..libs.android.widget import TextView
 from .base import Widget, align
@@ -66,9 +66,13 @@ def set_alignment(self, value):
         if not self.native.getLayoutParams():
             return
 
-        # Justified text wasn't added until Android O (SDK 26)
-        if value == JUSTIFY and Build.VERSION.SDK_INT >= Build.VERSION_CODES.O:
-            self.native.setJustificationMode(LineBreaker.JUSTIFICATION_MODE_INTER_WORD)
-        else:
-            self.native.setJustificationMode(LineBreaker.JUSTIFICATION_MODE_NONE)
+        # Justified text wasn't added until API level 26.
+        # We only run the test suite on API 31, so we need to disable branch coverage.
+        if Build.VERSION.SDK_INT >= 26:  # pragma: no branch
+            self.native.setJustificationMode(
+                Layout.JUSTIFICATION_MODE_INTER_WORD
+                if value == JUSTIFY
+                else Layout.JUSTIFICATION_MODE_NONE
+            )
+
         self.native.setGravity(Gravity.CENTER_VERTICAL | align(value))


Test patch:
diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py
--- a/android/tests_backend/widgets/base.py
+++ b/android/tests_backend/widgets/base.py
@@ -3,8 +3,10 @@
 from java import dynamic_proxy
 from pytest import approx
 
+from android.os import Build
 from android.view import View, ViewTreeObserver
 from toga.fonts import SYSTEM
+from toga.style.pack import JUSTIFY, LEFT
 
 from .properties import toga_color
 
@@ -55,7 +57,11 @@ def assert_not_contained(self):
         assert self.native.getParent() is None
 
     def assert_alignment(self, expected):
-        assert self.alignment == expected
+        actual = self.alignment
+        if expected == JUSTIFY and Build.VERSION.SDK_INT < 26:
+            assert actual == LEFT
+        else:
+            assert actual == expected
 
     def assert_font_family(self, expected):
         actual = self.font.family
diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py
--- a/android/tests_backend/widgets/label.py
+++ b/android/tests_backend/widgets/label.py
@@ -1,5 +1,7 @@
 from java import jclass
 
+from android.os import Build
+
 from .base import SimpleProbe
 from .properties import toga_alignment, toga_color, toga_font
 
@@ -25,6 +27,7 @@ def font(self):
 
     @property
     def alignment(self):
-        return toga_alignment(
-            self.native.getGravity(), self.native.getJustificationMode()
+        justification_mode = (
+            None if Build.VERSION.SDK_INT < 26 else self.native.getJustificationMode()
         )
+        return toga_alignment(self.native.getGravity(), justification_mode)
diff --git a/android/tests_backend/widgets/properties.py b/android/tests_backend/widgets/properties.py
--- a/android/tests_backend/widgets/properties.py
+++ b/android/tests_backend/widgets/properties.py
@@ -2,7 +2,8 @@
 from travertino.fonts import Font
 
 from android.graphics import Color, Typeface
-from android.graphics.text import LineBreaker
+from android.os import Build
+from android.text import Layout
 from android.util import TypedValue
 from android.view import Gravity
 from toga.colors import TRANSPARENT, rgba
@@ -70,15 +71,18 @@ def toga_font(typeface, size, resources):
 def toga_alignment(gravity, justification_mode):
     horizontal_gravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK
     if (
-        justification_mode == LineBreaker.JUSTIFICATION_MODE_INTER_WORD
-        and horizontal_gravity == Gravity.LEFT
+        Build.VERSION.SDK_INT < 26
+        or justification_mode == Layout.JUSTIFICATION_MODE_NONE
     ):
-        return JUSTIFY
-    elif justification_mode == LineBreaker.JUSTIFICATION_MODE_NONE:
         return {
             Gravity.LEFT: LEFT,
             Gravity.RIGHT: RIGHT,
             Gravity.CENTER_HORIZONTAL: CENTER,
         }[horizontal_gravity]
+    elif (
+        justification_mode == Layout.JUSTIFICATION_MODE_INTER_WORD
+        and horizontal_gravity == Gravity.LEFT
+    ):
+        return JUSTIFY
     else:
         raise ValueError(f"unknown combination: {gravity=}, {justification_mode=}")