From 0f7c6387dae372653909eb5075bddfe22dcbeee4 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 5 Aug 2026 11:56:22 +0200 Subject: [PATCH] Fix theme crash when showing permission disclosures PermissionDisclosures built an AppCompat AlertDialog directly on the host activity, crashing with "You need to use a Theme.AppCompat theme (or descendant) with this activity" in console apps whose activity theme is not an AppCompat descendant. Wrap the activity in an AppCompat dialog theme when its own theme cannot resolve alertDialogTheme. Adds instrumented regression tests covering AppCompat and non-AppCompat themed activities. Closes #71 --- ORLib/build.gradle | 5 ++ ORLib/src/androidTest/AndroidManifest.xml | 14 +++++ .../orlib/ui/PermissionDisclosuresTest.kt | 54 +++++++++++++++++++ .../orlib/ui/PermissionDisclosures.kt | 17 +++++- gradle/libs.versions.toml | 6 +++ 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 ORLib/src/androidTest/AndroidManifest.xml create mode 100644 ORLib/src/androidTest/java/io/openremote/orlib/ui/PermissionDisclosuresTest.kt diff --git a/ORLib/build.gradle b/ORLib/build.gradle index 91bf604..226c01f 100644 --- a/ORLib/build.gradle +++ b/ORLib/build.gradle @@ -82,6 +82,11 @@ dependencies { implementation libs.password.generator api project(':orlib-protobuf') + + androidTestImplementation libs.junit + androidTestImplementation libs.androidx.test.junit + androidTestImplementation libs.androidx.test.runner + androidTestImplementation libs.espresso.core } diff --git a/ORLib/src/androidTest/AndroidManifest.xml b/ORLib/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..d71a54e --- /dev/null +++ b/ORLib/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/ORLib/src/androidTest/java/io/openremote/orlib/ui/PermissionDisclosuresTest.kt b/ORLib/src/androidTest/java/io/openremote/orlib/ui/PermissionDisclosuresTest.kt new file mode 100644 index 0000000..a620053 --- /dev/null +++ b/ORLib/src/androidTest/java/io/openremote/orlib/ui/PermissionDisclosuresTest.kt @@ -0,0 +1,54 @@ +package io.openremote.orlib.ui + +import android.app.Activity +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.openremote.orlib.R +import org.junit.Test +import org.junit.runner.RunWith + +class PlatformThemeActivity : Activity() +class AppCompatThemeActivity : Activity() + +/** + * Regression test for https://github.com/openremote/console-android/issues/71: + * the disclosure dialog must not crash when the host activity does not use a + * Theme.AppCompat descendant, which is the case for consoles that supply their + * own theme. + */ +@RunWith(AndroidJUnit4::class) +class PermissionDisclosuresTest { + + private fun showDisclosureAndAssertVisible(scenario: ActivityScenario) { + scenario.onActivity { activity -> + PermissionDisclosures.show( + activity, + R.string.location_disclosure_title, + R.string.location_disclosure_body, + onAccept = {} + ) + } + onView(withText(R.string.disclosure_continue)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + + @Test + fun showsDisclosureOnNonAppCompatThemedActivity() { + ActivityScenario.launch(PlatformThemeActivity::class.java).use { + showDisclosureAndAssertVisible(it) + } + } + + @Test + fun showsDisclosureOnAppCompatThemedActivity() { + ActivityScenario.launch(AppCompatThemeActivity::class.java).use { + showDisclosureAndAssertVisible(it) + } + } +} diff --git a/ORLib/src/main/java/io/openremote/orlib/ui/PermissionDisclosures.kt b/ORLib/src/main/java/io/openremote/orlib/ui/PermissionDisclosures.kt index 91545fa..c170ba7 100644 --- a/ORLib/src/main/java/io/openremote/orlib/ui/PermissionDisclosures.kt +++ b/ORLib/src/main/java/io/openremote/orlib/ui/PermissionDisclosures.kt @@ -1,8 +1,10 @@ package io.openremote.orlib.ui import android.app.Activity +import android.util.TypedValue import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.appcompat.view.ContextThemeWrapper import io.openremote.orlib.R /** @@ -23,7 +25,20 @@ object PermissionDisclosures { if (activity.isFinishing || activity.isDestroyed) { return@runOnUiThread } - AlertDialog.Builder(activity) + // The AppCompat AlertDialog requires a theme that resolves alertDialogTheme; + // consuming apps may run this activity with a non-AppCompat theme. + val themedContext = if (activity.theme.resolveAttribute( + androidx.appcompat.R.attr.alertDialogTheme, TypedValue(), true + ) + ) { + activity + } else { + ContextThemeWrapper( + activity, + androidx.appcompat.R.style.Theme_AppCompat_DayNight_Dialog_Alert + ) + } + AlertDialog.Builder(themedContext) .setTitle(title) .setMessage(message) .setCancelable(false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 586f9d6..6c972f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,9 +4,12 @@ androidx-appcompat = "1.7.1" androidx-constraintlayout = "2.2.1" androidx-core-ktx = "1.18.0" androidx-preference = "1.2.1" +androidx-test-junit = "1.3.0" +androidx-test-runner = "1.7.0" axion-release-plugin = "1.21.2" coroutines = "1.11.0" esp-idf-provisioning = "lib-2.4.4" +espresso = "3.7.0" eventbus = "3.3.1" firebase-bom = "34.16.0" google-services-plugin = "4.5.0" @@ -28,9 +31,12 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } esp-idf-provisioning = { module = "com.github.espressif:esp-idf-provisioning-android", version.ref = "esp-idf-provisioning" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-messaging = { module = "com.google.firebase:firebase-messaging" }