From 51b6fd35fb0862c45602e98983449fbd320cab2f Mon Sep 17 00:00:00 2001 From: "Brian J. Coan" Date: Sun, 6 Sep 2026 12:46:38 -0700 Subject: [PATCH 1/4] not yet working --- 5calls/.java-version | 1 + 5calls/app/build.gradle | 2 +- .../android/a5calls/BaseIntegrationTest.java | 147 ++++++++++++++++++ .../a5calls/LocaleAwareTestRunner.java | 64 ++++++++ .../controller/MainActivityBaseTest.java | 62 ++------ .../controller/MainActivityErrorTest.java | 22 --- .../controller/MainActivityHappyPathTest.java | 46 ------ .../android/a5calls/net/FakeRequestQueue.java | 2 +- .../android/a5calls/net/FiveCallsApiTest.java | 36 +---- 9 files changed, 232 insertions(+), 150 deletions(-) create mode 100644 5calls/.java-version create mode 100644 5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java create mode 100644 5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java diff --git a/5calls/.java-version b/5calls/.java-version new file mode 100644 index 00000000..5f39e914 --- /dev/null +++ b/5calls/.java-version @@ -0,0 +1 @@ +21.0 diff --git a/5calls/app/build.gradle b/5calls/app/build.gradle index 744c428c..292eea56 100644 --- a/5calls/app/build.gradle +++ b/5calls/app/build.gradle @@ -12,7 +12,7 @@ android { targetSdkVersion 36 versionCode 85 versionName '2.4.6' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + testInstrumentationRunner "org.a5calls.android.a5calls.LocaleAwareTestRunner" signingConfig signingConfigs.debug } buildTypes { diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java new file mode 100644 index 00000000..4f979013 --- /dev/null +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java @@ -0,0 +1,147 @@ +package org.a5calls.android.a5calls; + +import android.content.Context; +import android.os.SystemClock; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.volley.toolbox.BasicNetwork; +import com.google.android.material.appbar.CollapsingToolbarLayout; + +import org.a5calls.android.a5calls.controller.MainActivity; +import org.a5calls.android.a5calls.model.AccountManager; +import org.a5calls.android.a5calls.net.FakeRequestQueue; +import org.a5calls.android.a5calls.net.FiveCallsApi; +import org.a5calls.android.a5calls.net.MockHttpStack; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.RuleChain; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; + +/** + * Base class for all instrumentation tests in the app. + * Handles common setup for mocking network requests and accessing context. + */ +@RunWith(AndroidJUnit4.class) +public abstract class BaseIntegrationTest { + + protected Context mContext; + protected MockHttpStack mHttpStack; + protected FakeRequestQueue mRequestQueue; + protected FiveCallsApi mApi; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + // Use a fixed caller ID for consistent test results + AccountManager.Instance.setCallerID(mContext, "itMe"); + + mHttpStack = new MockHttpStack(); + BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); + mRequestQueue = new FakeRequestQueue(basicNetwork); + + mApi = new FiveCallsApi("itMe", mRequestQueue, mContext); + + // Replace the global API and RequestQueue in the AppSingleton for tests that use it + AppSingleton.getInstance(mContext).setRequestQueue(mRequestQueue); + AppSingleton.getInstance(mContext).setFiveCallsApi(mApi); + } + + @After + public void tearDown() { + if (mRequestQueue != null) { + mRequestQueue.mRequest = null; + } + } + + /** + * Helper to wait for asynchronous network requests to complete in tests. + */ + protected void waitForHttpRequestComplete() { + assertNotNull(mRequestQueue.mRequest); + mRequestQueue.start(); + + // Wait for the async stuff. + // TODO: Use a more robust synchronization mechanism like IdlingResource + SystemClock.sleep(200); + } + + /** + * Custom matcher to check if a RecyclerView has exactly one item + */ + public static Matcher hasExactlyOneItem() { + return new TypeSafeMatcher<>() { + @Override + protected boolean matchesSafely(View view) { + if (!(view instanceof RecyclerView)) { + return false; + } + RecyclerView recyclerView = (RecyclerView) view; + return recyclerView.getAdapter() != null && recyclerView.getAdapter().getItemCount() == 1; + } + + @Override + public void describeTo(Description description) { + description.appendText("RecyclerView with exactly one item"); + } + }; + } + + // Custom matcher that matches only the first view matching the given matcher. + public static Matcher first(final Matcher matcher) { + return new TypeSafeMatcher() { + boolean matched = false; + + @Override + public boolean matchesSafely(View view) { + if (matched) { + return false; + } + if (matcher.matches(view)) { + matched = true; + return true; + } + return false; + } + + @Override + public void describeTo(Description description) { + description.appendText("first view matching: "); + matcher.describeTo(description); + } + }; + } + + // Custom matcher to check if a CollapsingToolbarLayout's title contains specific text + public static Matcher withCollapsingToolbarTitle(final Matcher textMatcher) { + return new TypeSafeMatcher() { + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof CollapsingToolbarLayout)) { + return false; + } + CollapsingToolbarLayout toolbarLayout = (CollapsingToolbarLayout) view; + CharSequence title = toolbarLayout.getTitle(); + return title != null && textMatcher.matches(title.toString()); + } + + @Override + public void describeTo(Description description) { + description.appendText("with toolbar title: "); + textMatcher.describeTo(description); + } + }; + } + +} diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java new file mode 100644 index 00000000..8bd9444a --- /dev/null +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java @@ -0,0 +1,64 @@ +package org.a5calls.android.a5calls; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import android.os.LocaleList; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnitRunner; +import java.util.Locale; + +public class LocaleAwareTestRunner extends AndroidJUnitRunner { + + @Override + public void onCreate(Bundle arguments) { + System.err.println("LocaleAwareTestRunner onCreate"); + // 1. Intercept the custom CLI parameter 'locale' + String localeTag = arguments.getString("locale"); + System.err.println("LocaleAwareTestRunner localeTag: " + localeTag); + + Bundle testArguments = InstrumentationRegistry.getArguments(); + localeTag = testArguments.getString("locale"); + System.err.println("LocaleAwareTestRunner localeTag: " + localeTag); + + + if (localeTag != null && !localeTag.isEmpty()) { + Locale locale = Locale.forLanguageTag(localeTag); + setGlobalLocale(locale); + } + + // 1. Extract the device language + String systemLanguage = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + ? LocaleList.getDefault().get(0).toLanguageTag() + : Locale.getDefault().toLanguageTag(); + + System.err.println("LocaleAwareTestRunner systemLanguage: " + systemLanguage); + // 2. Put it into the arguments bundle + arguments.putString("device_language", systemLanguage); + + // 2. Continue initializing the rest of the runner configuration + super.onCreate(arguments); + } + + @SuppressWarnings("deprecation") + private void setGlobalLocale(Locale locale) { + System.err.println("LocaleAwareTestRunner Setting global locale to " + locale); + Locale.setDefault(locale); + + // Modify Target App Context configuration before tests boot up + Context context = getTargetContext(); + Resources resources = context.getResources(); + Configuration configuration = resources.getConfiguration(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.setLocale(locale); + } else { + configuration.locale = locale; + } + + resources.updateConfiguration(configuration, resources.getDisplayMetrics()); + } +} diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java index 17e9964c..7683eeb4 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java @@ -8,95 +8,65 @@ import com.android.volley.toolbox.BasicNetwork; import org.a5calls.android.a5calls.AppSingleton; +import org.a5calls.android.a5calls.BaseIntegrationTest; import org.a5calls.android.a5calls.model.AccountManager; import org.a5calls.android.a5calls.net.FakeRequestQueue; import org.a5calls.android.a5calls.net.FiveCallsApi; import org.a5calls.android.a5calls.net.MockHttpStack; import org.junit.After; import org.junit.Before; -import org.junit.runner.RunWith; /** * Base class for MainActivity integration tests that contains shared setup and utility methods. */ -@RunWith(AndroidJUnit4.class) -public abstract class MainActivityBaseTest { +public abstract class MainActivityBaseTest extends BaseIntegrationTest { - protected MockHttpStack mHttpStack; protected RequestQueue mOriginalRequestQueue; protected FiveCallsApi mOriginalApi; protected String mOriginalAddress; protected ActivityScenario scenario; @Before + @Override public void setUp() { + super.setUp(); // Save original state - mOriginalRequestQueue = AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()).getRequestQueue(); - mOriginalApi = AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()).getJsonController(); + mOriginalRequestQueue = AppSingleton.getInstance(mContext).getRequestQueue(); + mOriginalApi = AppSingleton.getInstance(mContext).getJsonController(); // Save original location - mOriginalAddress = AccountManager.Instance.getAddress( - InstrumentationRegistry.getInstrumentation().getTargetContext()); + mOriginalAddress = AccountManager.Instance.getAddress(mContext); // Set a mock location to avoid location prompts - AccountManager.Instance.setAddress( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - "90210"); + AccountManager.Instance.setAddress(mContext, "90210"); // Mark tutorial as seen to bypass onboarding screen - AccountManager.Instance.setTutorialSeen( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - true); - - // Create mock HTTP stack - mHttpStack = new MockHttpStack(); + AccountManager.Instance.setTutorialSeen(mContext, true); } @After + @Override public void tearDown() { // Restore original state - AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - .setRequestQueue(mOriginalRequestQueue); - AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - .setFiveCallsApi(mOriginalApi); + AppSingleton.getInstance(mContext).setRequestQueue(mOriginalRequestQueue); + AppSingleton.getInstance(mContext).setFiveCallsApi(mOriginalApi); // Restore original location - AccountManager.Instance.setAddress( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - mOriginalAddress); + AccountManager.Instance.setAddress(mContext, mOriginalAddress); // Close the activity scenario if it's open if (scenario != null) { scenario.close(); } + super.tearDown(); } /** * Sets up the mock request queue and API + * (Deprecated: functionality moved to BaseIntegrationTest.setUp) */ protected void setupMockRequestQueue() { - // Create a custom RequestQueue with our mock HTTP stack - BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); - FakeRequestQueue requestQueue = new FakeRequestQueue(basicNetwork); - requestQueue.start(); - - // Replace the app's RequestQueue with our mock - AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - .setRequestQueue(requestQueue); - - // Create a new FiveCallsApi with our mock RequestQueue - String callerId = AccountManager.Instance.getCallerID( - InstrumentationRegistry.getInstrumentation().getTargetContext()); - FiveCallsApi api = new FiveCallsApi(callerId, requestQueue, - InstrumentationRegistry.getInstrumentation().getTargetContext()); - AppSingleton.getInstance( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - .setFiveCallsApi(api); + // This is now redundant but kept for backward compatibility with existing tests } /** diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java index 2396a371..8ba5defb 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java @@ -28,30 +28,8 @@ /** * Integration test for MainActivity that tests error handling. */ -@RunWith(AndroidJUnit4.class) public class MainActivityErrorTest extends MainActivityBaseTest { - /** - * Custom matcher to check if a RecyclerView has exactly one item - */ - public static Matcher hasExactlyOneItem() { - return new TypeSafeMatcher() { - @Override - protected boolean matchesSafely(View view) { - if (!(view instanceof RecyclerView)) { - return false; - } - RecyclerView recyclerView = (RecyclerView) view; - return recyclerView.getAdapter() != null && recyclerView.getAdapter().getItemCount() == 1; - } - - @Override - public void describeTo(Description description) { - description.appendText("RecyclerView with exactly one item"); - } - }; - } - /** * Verifies that error UI is displayed correctly. */ diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java index 748bc07b..2637590c 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java @@ -52,52 +52,6 @@ @RunWith(AndroidJUnit4.class) public class MainActivityHappyPathTest extends MainActivityBaseTest { - // Custom matcher that matches only the first view matching the given matcher. - public static Matcher first(final Matcher matcher) { - return new TypeSafeMatcher() { - boolean matched = false; - - @Override - public boolean matchesSafely(View view) { - if (matched) { - return false; - } - if (matcher.matches(view)) { - matched = true; - return true; - } - return false; - } - - @Override - public void describeTo(Description description) { - description.appendText("first view matching: "); - matcher.describeTo(description); - } - }; - } - - // Custom matcher to check if a CollapsingToolbarLayout's title contains specific text - public static Matcher withCollapsingToolbarTitle(final Matcher textMatcher) { - return new TypeSafeMatcher() { - @Override - public boolean matchesSafely(View view) { - if (!(view instanceof CollapsingToolbarLayout)) { - return false; - } - CollapsingToolbarLayout toolbarLayout = (CollapsingToolbarLayout) view; - CharSequence title = toolbarLayout.getTitle(); - return title != null && textMatcher.matches(title.toString()); - } - - @Override - public void describeTo(Description description) { - description.appendText("with toolbar title: "); - textMatcher.describeTo(description); - } - }; - } - /** * Sets up mock responses for API calls */ diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java index 2a7e4b03..6f314407 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java @@ -6,7 +6,7 @@ import com.android.volley.toolbox.NoCache; public class FakeRequestQueue extends RequestQueue { - protected Request mRequest = null; + public Request mRequest = null; public FakeRequestQueue(BasicNetwork network) { super(new NoCache(), network); diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java index 8d1ec9bd..a0288afa 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java @@ -6,23 +6,18 @@ import com.android.volley.toolbox.BasicNetwork; import com.android.volley.toolbox.HttpResponse; +import org.a5calls.android.a5calls.BaseIntegrationTest; import org.a5calls.android.a5calls.model.Contact; import org.a5calls.android.a5calls.model.Issue; import org.a5calls.android.a5calls.model.Outcome; import org.json.JSONException; import org.json.JSONObject; -import org.junit.After; -import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; import java.io.IOException; import java.util.ArrayList; import java.util.List; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - import static org.a5calls.android.a5calls.FakeJSONData.ISSUE_DATA; import static org.a5calls.android.a5calls.FakeJSONData.REPORT_DATA; import static org.a5calls.android.a5calls.FakeJSONData.REPS_DATA_SUFFIX; @@ -30,8 +25,7 @@ import static org.a5calls.android.a5calls.FakeJSONData.REPS_DATA_SPLIT_PREFIX; import static org.junit.Assert.*; -@RunWith(AndroidJUnit4.class) -public class FiveCallsApiTest { +public class FiveCallsApiTest extends BaseIntegrationTest { static class TestCallListener implements FiveCallsApi.CallRequestListener { protected int mCallError = 0; @@ -133,24 +127,6 @@ public void onError() { } - private FiveCallsApi mApi; - private FakeRequestQueue mRequestQueue; - private MockHttpStack mHttpStack; - - - @Before - public void setUp() { - mHttpStack = new MockHttpStack(); - BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); - mRequestQueue = new FakeRequestQueue(basicNetwork); - mApi = new FiveCallsApi("itMe", mRequestQueue, - InstrumentationRegistry.getInstrumentation().getTargetContext()); - } - - @After - public void tearDown() { - mRequestQueue.mRequest = null; - } @Test public void testGetCallCount() { @@ -463,12 +439,4 @@ private void testReportCallStatus(Outcome.Status status, String outcomeString) { mApi.unregisterCallRequestListener(testCallListener); } - private void waitForHttpRequestComplete() { - assertNotNull(mRequestQueue.mRequest); - mRequestQueue.start(); - - // Wait for the async stuff. - // TODO: I'm sure there's a better way to do this... - SystemClock.sleep(200); - } } \ No newline at end of file From f919bdd07152dbd14d494a08f20adef3e2fd0c5f Mon Sep 17 00:00:00 2001 From: "Brian J. Coan" Date: Mon, 7 Sep 2026 16:00:49 -0700 Subject: [PATCH 2/4] README updated --- 5calls/.java-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 5calls/.java-version diff --git a/5calls/.java-version b/5calls/.java-version deleted file mode 100644 index 5f39e914..00000000 --- a/5calls/.java-version +++ /dev/null @@ -1 +0,0 @@ -21.0 From 87ff35c81bfb55d10cdeff5c6b64e1a3b9e16b21 Mon Sep 17 00:00:00 2001 From: "Brian J. Coan" Date: Mon, 7 Sep 2026 16:17:20 -0700 Subject: [PATCH 3/4] LocaleAwareTestRunner.kt --- .../a5calls/LocaleAwareTestRunner.java | 86 ------------------- .../android/a5calls/LocaleAwareTestRunner.kt | 72 ++++++++++++++++ 2 files changed, 72 insertions(+), 86 deletions(-) delete mode 100644 5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java create mode 100644 5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java deleted file mode 100644 index 5dacc339..00000000 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.a5calls.android.a5calls; - -import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.os.Build; -import android.os.Bundle; -import android.os.LocaleList; - -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.os.LocaleListCompat; -import androidx.test.runner.AndroidJUnitRunner; - -import java.util.Locale; - -public class LocaleAwareTestRunner extends AndroidJUnitRunner { - - private Bundle mArguments; - - @Override - public void onCreate(Bundle arguments) { - mArguments = arguments; - // 1. Intercept the custom CLI parameter 'locale' - final String localeTag = arguments.getString("locale"); - System.err.println("LocaleAwareTestRunner intercepted localeTag: " + localeTag); - - if (localeTag != null && !localeTag.isEmpty()) { - Locale locale = Locale.forLanguageTag(localeTag); - setGlobalLocale(locale); - } - - // 2. Extract the device language for diagnostic purposes - String systemLanguage = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) ? LocaleList.getDefault().get(0).toLanguageTag() : Locale.getDefault().toLanguageTag(); - - System.err.println("LocaleAwareTestRunner systemLanguage: " + systemLanguage); - arguments.putString("device_language", systemLanguage); - - super.onCreate(arguments); - } - - @Override - public void onStart() { - // Use AppCompatDelegate to set locales globally for the app. - // This is the most reliable way for AppCompat-based activities. - final String localeTag = mArguments != null ? mArguments.getString("locale") : null; - runOnMainSync(new Runnable() { - @Override - public void run() { - if (localeTag != null && !localeTag.isEmpty()) { - System.err.println("LocaleAwareTestRunner setting AppCompatDelegate locales to: " + localeTag); - AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(localeTag)); - } else { - // Reset to system default if no locale specified to avoid persistence from previous runs - AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()); - } - } - }); - super.onStart(); - } - - @SuppressWarnings("deprecation") - private void setGlobalLocale(Locale locale) { - System.err.println("LocaleAwareTestRunner Setting global locale to " + locale); - Locale.setDefault(locale); - - // Update configuration for both target context and application context - updateContextLocale(getTargetContext(), locale); - updateContextLocale(getTargetContext().getApplicationContext(), locale); - } - - @SuppressWarnings("deprecation") - private void updateContextLocale(Context context, Locale locale) { - if (context == null) return; - - Resources resources = context.getResources(); - Configuration configuration = resources.getConfiguration(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - configuration.setLocales(new LocaleList(locale)); - } else { - configuration.locale = locale; - } - - resources.updateConfiguration(configuration, resources.getDisplayMetrics()); - } -} diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt new file mode 100644 index 00000000..0e0fb5fe --- /dev/null +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt @@ -0,0 +1,72 @@ +package org.a5calls.android.a5calls + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.LocaleList +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.test.runner.AndroidJUnitRunner +import java.util.Locale + +class LocaleAwareTestRunner : AndroidJUnitRunner() { + private var mArguments: Bundle? = null + + override fun onCreate(arguments: Bundle) { + mArguments = arguments + val localeTag = arguments.getString("locale") + System.err.println("LocaleAwareTestRunner intercepted localeTag: $localeTag") + + if (!localeTag.isNullOrEmpty()) { + val locale = Locale.forLanguageTag(localeTag) + setGlobalLocale(locale) + } + super.onCreate(arguments) + } + + override fun onStart() { + // Use AppCompatDelegate to set locales globally for the app. + // This is the most reliable way for AppCompat-based activities. + val localeTag = if (mArguments != null) mArguments!!.getString("locale") else null + runOnMainSync { + if (!localeTag.isNullOrEmpty()) { + System.err.println("LocaleAwareTestRunner setting AppCompatDelegate locales to: $localeTag") + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags( + localeTag + ) + ) + } else { + // Reset to system default if no locale specified to avoid persistence from previous runs + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + } + super.onStart() + } + + @Suppress("deprecation") + private fun setGlobalLocale(locale: Locale) { + System.err.println("LocaleAwareTestRunner Setting global locale to $locale") + Locale.setDefault(locale) + + // Update configuration for both target context and application context + updateContextLocale(targetContext, locale) + updateContextLocale(targetContext.applicationContext, locale) + } + + @Suppress("deprecation") + private fun updateContextLocale(context: Context?, locale: Locale) { + if (context == null) return + + val resources = context.resources + val configuration = resources.configuration + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.setLocales(LocaleList(locale)) + } else { + configuration.locale = locale + } + + resources.updateConfiguration(configuration, resources.displayMetrics) + } +} From 5da354ca493cf5920deb494116e5bc64302d533d Mon Sep 17 00:00:00 2001 From: "Brian J. Coan" Date: Mon, 7 Sep 2026 16:18:50 -0700 Subject: [PATCH 4/4] README updated --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0e624d11..915baded 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ For instrumentation testing, connect a device or start an emulator, then: cd 5calls ./gradlew connectedAndroidTest ``` +For instrumentation testing running the app in Spanish: +``` +cd 5calls +./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.locale=es-US +``` ### Notifications To test snoozing notifications, change `FREQUENT_NOTIFICATION_DEBUG_MODE` to `true` in `NotificationUtils.java`. Note that notifications