From 317937dd34c67fb21f88dea7b16104898ba360ca Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Fri, 5 Sep 2025 20:22:42 +0200 Subject: [PATCH 01/10] Android DetailedList: use theme text colors (replace Color.BLACK) --- .../src/toga_android/widgets/detailedlist.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 8c1f0bedca..49b24963aa 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -7,6 +7,26 @@ from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy +from android.util import TypedValue + + +def _resolve_theme_color(view, attr_id, fallback): + tv = TypedValue() + ctx = view.getContext() + th = ctx.getTheme() + if th.resolveAttribute(attr_id, tv, True): + if tv.resourceId: + try: + return ctx.getColor(tv.resourceId) + except Exception: + try: + return ctx.getResources().getColor(tv.resourceId) + except Exception: + pass + if getattr(tv, "data", 0): + return tv.data + return fallback + try: from androidx.swiperefreshlayout.widget import SwipeRefreshLayout @@ -180,15 +200,11 @@ def get_string(value): top_text = TextView(self._native_activity) top_text.setText(get_string(title)) top_text.setTextSize(20.0) - top_text.setTextColor( - self._native_activity.getResources().getColor(R.color.black) - ) + top_text.setTextColor(_resolve_theme_color(top_text, R.attr.textColorPrimary, Color.BLACK)) bottom_text = TextView(self._native_activity) - bottom_text.setTextColor( - self._native_activity.getResources().getColor(R.color.black) - ) bottom_text.setText(get_string(subtitle)) bottom_text.setTextSize(16.0) + bottom_text.setTextColor(_resolve_theme_color(bottom_text, R.attr.textColorSecondary, Color.BLACK)) top_text_params = LinearLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.MATCH_PARENT, From fef6e376301224908a1a7698f5a2e67fc221ad37 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sat, 6 Sep 2025 21:05:17 +0200 Subject: [PATCH 02/10] Add towncrier change note for PR #3751 --- changes/3751.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3751.bugfix.rst diff --git a/changes/3751.bugfix.rst b/changes/3751.bugfix.rst new file mode 100644 index 0000000000..5772f42bfb --- /dev/null +++ b/changes/3751.bugfix.rst @@ -0,0 +1 @@ +Android: DetailedList now uses theme-resolved text colors (Primary/Secondary) instead of hard-coded black, fixing unreadable text in dark mode. From aa06f393ceec8af72affd194b18f8b6420a0469f Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sat, 6 Sep 2025 21:20:06 +0200 Subject: [PATCH 03/10] Pre-commit: import ordering and line-wrap for ruff E402/E501 --- android/src/toga_android/widgets/detailedlist.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 49b24963aa..3632bf2c2e 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -4,10 +4,12 @@ from android.app import AlertDialog from android.content import DialogInterface from android.graphics import Color, Rect +from android.util import TypedValue from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy -from android.util import TypedValue + +from .base import Widget def _resolve_theme_color(view, attr_id, fallback): @@ -36,9 +38,6 @@ def _resolve_theme_color(view, attr_id, fallback): SwipeRefreshLayout = None -from .base import Widget - - class DetailedListOnClickListener(dynamic_proxy(View.OnClickListener)): def __init__(self, impl, row_number): super().__init__() @@ -200,11 +199,15 @@ def get_string(value): top_text = TextView(self._native_activity) top_text.setText(get_string(title)) top_text.setTextSize(20.0) - top_text.setTextColor(_resolve_theme_color(top_text, R.attr.textColorPrimary, Color.BLACK)) + top_text.setTextColor( + _resolve_theme_color(top_text, R.attr.textColorPrimary, Color.BLACK) + ) bottom_text = TextView(self._native_activity) bottom_text.setText(get_string(subtitle)) bottom_text.setTextSize(16.0) - bottom_text.setTextColor(_resolve_theme_color(bottom_text, R.attr.textColorSecondary, Color.BLACK)) + bottom_text.setTextColor( + _resolve_theme_color(bottom_text, R.attr.textColorSecondary, Color.BLACK) + ) top_text_params = LinearLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.MATCH_PARENT, From 5d663032c2d8bf37f7b68a32a07fed662a5371a2 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sat, 6 Sep 2025 21:51:10 +0200 Subject: [PATCH 04/10] android: mark legacy/fallback resolver branches as no-cover (CI 100% coverage) --- android/src/toga_android/widgets/detailedlist.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 3632bf2c2e..778daf08b1 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -19,12 +19,17 @@ def _resolve_theme_color(view, attr_id, fallback): if th.resolveAttribute(attr_id, tv, True): if tv.resourceId: try: + # API 23+ path used on modern emulators return ctx.getColor(tv.resourceId) - except Exception: + except Exception: # pragma: no cover - emulator hits API 23+ path try: - return ctx.getResources().getColor(tv.resourceId) - except Exception: - pass + # Legacy path (pre-23); not exercised in CI emulators + return ctx.getResources().getColor( + tv.resourceId + ) # pragma: no cover + except Exception: # pragma: no cover - double-fallback not hit + pass # pragma: no cover + # Inline color int (ARGB) stored in tv.data (rare on textColor attrs) if getattr(tv, "data", 0): return tv.data return fallback From e3e46209566694fc33f2646767e6d64cdf69edaf Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sat, 6 Sep 2025 22:40:37 +0200 Subject: [PATCH 05/10] CI: re-run From cf2dc87b8f9c8bc5384875c4d151ce3155aca647 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sun, 7 Sep 2025 00:12:06 +0200 Subject: [PATCH 06/10] android: mark inline-color and fallback branches as no-cover to satisfy 100% coverage --- android/src/toga_android/widgets/detailedlist.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 778daf08b1..16034559f8 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -30,9 +30,9 @@ def _resolve_theme_color(view, attr_id, fallback): except Exception: # pragma: no cover - double-fallback not hit pass # pragma: no cover # Inline color int (ARGB) stored in tv.data (rare on textColor attrs) - if getattr(tv, "data", 0): - return tv.data - return fallback + if getattr(tv, "data", 0): # pragma: no cover + return tv.data # pragma: no cover + return fallback # pragma: no cover try: From 52989ae40be24371e14acc96fc148b2c7e14e7b0 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sun, 7 Sep 2025 21:54:04 +0200 Subject: [PATCH 07/10] android: minimize no-cover pragmas; narrow exceptions (JavaException, AttributeError) --- .../src/toga_android/widgets/detailedlist.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 16034559f8..193361faa8 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -8,6 +8,7 @@ from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy +from rubicon.java import JavaException from .base import Widget @@ -16,22 +17,15 @@ def _resolve_theme_color(view, attr_id, fallback): tv = TypedValue() ctx = view.getContext() th = ctx.getTheme() - if th.resolveAttribute(attr_id, tv, True): - if tv.resourceId: - try: - # API 23+ path used on modern emulators - return ctx.getColor(tv.resourceId) - except Exception: # pragma: no cover - emulator hits API 23+ path - try: - # Legacy path (pre-23); not exercised in CI emulators - return ctx.getResources().getColor( - tv.resourceId - ) # pragma: no cover - except Exception: # pragma: no cover - double-fallback not hit - pass # pragma: no cover - # Inline color int (ARGB) stored in tv.data (rare on textColor attrs) - if getattr(tv, "data", 0): # pragma: no cover - return tv.data # pragma: no cover + if not th.resolveAttribute(attr_id, tv, True): + return fallback # pragma: no cover + if tv.resourceId: + try: + return ctx.getColor(tv.resourceId) + except (JavaException, AttributeError): # pragma: no cover + return ctx.getResources().getColor(tv.resourceId) # pragma: no cover + if getattr(tv, "data", 0): # pragma: no branch + return tv.data # pragma: no cover return fallback # pragma: no cover From 79592a8fc77101d4bb6768f80a3721188ab92728 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sun, 7 Sep 2025 22:49:14 +0200 Subject: [PATCH 08/10] android: minimize no-cover pragmas; narrow exceptions (JavaException, AttributeError) --- android/src/toga_android/widgets/detailedlist.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 193361faa8..49407d11bd 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -8,12 +8,18 @@ from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy -from rubicon.java import JavaException + +try: + from rubicon.java import JavaException # type: ignore +except ModuleNotFoundError: # pragma: no cover + # On non-Android CI, rubicon-java may not be installed; fall back to Exception. + JavaException = Exception # type: ignore[assignment] from .base import Widget def _resolve_theme_color(view, attr_id, fallback): + # No covers due to not being able to test in CI tv = TypedValue() ctx = view.getContext() th = ctx.getTheme() From c7a03d6b744e7c77635c224e9b3ab2d6eecc65e5 Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Sun, 7 Sep 2025 23:21:02 +0200 Subject: [PATCH 09/10] android: exception-free theme color resolver via API level + ColorStateList --- .../src/toga_android/widgets/detailedlist.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 49407d11bd..6c2404cb38 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -4,32 +4,34 @@ from android.app import AlertDialog from android.content import DialogInterface from android.graphics import Color, Rect +from android.os import Build from android.util import TypedValue from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy -try: - from rubicon.java import JavaException # type: ignore -except ModuleNotFoundError: # pragma: no cover - # On non-Android CI, rubicon-java may not be installed; fall back to Exception. - JavaException = Exception # type: ignore[assignment] - from .base import Widget def _resolve_theme_color(view, attr_id, fallback): - # No covers due to not being able to test in CI tv = TypedValue() ctx = view.getContext() th = ctx.getTheme() + res = ctx.getResources() + if not th.resolveAttribute(attr_id, tv, True): return fallback # pragma: no cover if tv.resourceId: - try: + if Build.VERSION.SDK_INT >= 23: + csl = res.getColorStateList(tv.resourceId, None) + if csl is not None: + return csl.getDefaultColor() return ctx.getColor(tv.resourceId) - except (JavaException, AttributeError): # pragma: no cover - return ctx.getResources().getColor(tv.resourceId) # pragma: no cover + else: # pragma: no cover + csl = res.getColorStateList(tv.resourceId) + if csl is not None: + return csl.getDefaultColor() + return res.getColor(tv.resourceId) if getattr(tv, "data", 0): # pragma: no branch return tv.data # pragma: no cover return fallback # pragma: no cover From 65935bbd1259399bd35ae3557a791f5415852f6f Mon Sep 17 00:00:00 2001 From: rasmus_jenle Date: Mon, 8 Sep 2025 21:49:17 +0200 Subject: [PATCH 10/10] android: resolve text colors via obtainStyledAttributes; no rubicon, no CSL; raise if missing --- .../src/toga_android/widgets/detailedlist.py | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 6c2404cb38..ff80715b9d 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -4,8 +4,6 @@ from android.app import AlertDialog from android.content import DialogInterface from android.graphics import Color, Rect -from android.os import Build -from android.util import TypedValue from android.view import Gravity, View from android.widget import ImageView, LinearLayout, RelativeLayout, ScrollView, TextView from java import dynamic_proxy @@ -13,28 +11,19 @@ from .base import Widget -def _resolve_theme_color(view, attr_id, fallback): - tv = TypedValue() +def _resolve_theme_color(view, attr_id): ctx = view.getContext() - th = ctx.getTheme() - res = ctx.getResources() - - if not th.resolveAttribute(attr_id, tv, True): - return fallback # pragma: no cover - if tv.resourceId: - if Build.VERSION.SDK_INT >= 23: - csl = res.getColorStateList(tv.resourceId, None) - if csl is not None: - return csl.getDefaultColor() - return ctx.getColor(tv.resourceId) - else: # pragma: no cover - csl = res.getColorStateList(tv.resourceId) - if csl is not None: - return csl.getDefaultColor() - return res.getColor(tv.resourceId) - if getattr(tv, "data", 0): # pragma: no branch - return tv.data # pragma: no cover - return fallback # pragma: no cover + ta = ctx.getTheme().obtainStyledAttributes([attr_id]) + try: + if not ta.hasValue(0): + # CI's emulator theme always defines textColorPrimary/Secondary, + # so this path can't be exercised there. + raise RuntimeError( # pragma: no cover + f"Required theme color attribute not found: {attr_id}" + ) + return ta.getColor(0, 0) + finally: + ta.recycle() try: @@ -206,14 +195,12 @@ def get_string(value): top_text = TextView(self._native_activity) top_text.setText(get_string(title)) top_text.setTextSize(20.0) - top_text.setTextColor( - _resolve_theme_color(top_text, R.attr.textColorPrimary, Color.BLACK) - ) + top_text.setTextColor(_resolve_theme_color(top_text, R.attr.textColorPrimary)) bottom_text = TextView(self._native_activity) bottom_text.setText(get_string(subtitle)) bottom_text.setTextSize(16.0) bottom_text.setTextColor( - _resolve_theme_color(bottom_text, R.attr.textColorSecondary, Color.BLACK) + _resolve_theme_color(bottom_text, R.attr.textColorSecondary) ) top_text_params = LinearLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT,