From 10263b63dd8d31f9460ef660546687f0509012dd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:38:46 -0500 Subject: [PATCH 1/6] Fix CoreCLR timezone cache invalidation Route Android timezone broadcasts through a CoreCLR reverse P/Invoke callback so managed culture and TimeZoneInfo caches are cleared in a live process. Add MonoVM/CoreCLR device coverage for changing timezone without restarting the app. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- .../Android.Runtime/AndroidEnvironment.cs | 2 +- .../Android.Runtime/JNIEnvInit.cs | 14 +++ src/native/clr/host/host-jni.cc | 2 +- src/native/clr/host/host.cc | 15 +++ src/native/clr/include/host/host.hh | 2 + .../common/include/managed-interface.hh | 2 + .../Tests/TimeZoneInfoTests.cs | 109 ++++++++++++++++++ 7 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/AndroidEnvironment.cs b/src/Mono.Android/Android.Runtime/AndroidEnvironment.cs index cd487e8e3e4..226232ebc8e 100644 --- a/src/Mono.Android/Android.Runtime/AndroidEnvironment.cs +++ b/src/Mono.Android/Android.Runtime/AndroidEnvironment.cs @@ -220,7 +220,7 @@ static Java.Security.Cert.X509Certificate ConvertCertificate (Java.Security.Cert // This is invoked by libmonodroid.so. // DO NOT REMOVE - static void NotifyTimeZoneChanged () + internal static void NotifyTimeZoneChanged () { var thread = Thread.CurrentThread; var timeZoneClearInfo = new[]{ diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 9e6c6fa599d..67bee549dc4 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -36,6 +36,7 @@ internal struct JnienvInitializeArgs { public IntPtr grefGCUserPeerable; public IntPtr propagateUncaughtExceptionFn; public IntPtr registerJniNativesFn; + public IntPtr notifyTimeZoneChangedFn; } #pragma warning restore 0649 @@ -56,6 +57,18 @@ static void PropagateUncaughtException (IntPtr env, IntPtr javaThread, IntPtr ja JNIEnv.PropagateUncaughtException (env, javaThread, javaException); } + [UnmanagedCallersOnly] + static int NotifyTimeZoneChanged () + { + try { + AndroidEnvironment.NotifyTimeZoneChanged (); + return 0; + } catch (Exception e) { + Logger.Log (LogLevel.Error, "MonoAndroid", $"Exception while clearing time-zone caches: {e}"); + return -1; + } + } + [UnmanagedCallersOnly] [RequiresUnreferencedCode ("Uses reflection to access System.StartupHookProvider.")] static unsafe void RegisterJniNatives (IntPtr typeName_ptr, int typeName_len, IntPtr jniClass, IntPtr methods_ptr, int methods_len) @@ -145,6 +158,7 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) RegisterTrimmableTypeMapNativeMethodsIfNeeded (); args->propagateUncaughtExceptionFn = (IntPtr)(delegate* unmanaged)&PropagateUncaughtException; + args->notifyTimeZoneChangedFn = (IntPtr)(delegate* unmanaged)&NotifyTimeZoneChanged; if (!RuntimeFeature.TrimmableTypeMap) { args->registerJniNativesFn = GetRegisterJniNativesFnPtr (); diff --git a/src/native/clr/host/host-jni.cc b/src/native/clr/host/host-jni.cc index a41c1c507cc..a619ade6a60 100644 --- a/src/native/clr/host/host-jni.cc +++ b/src/native/clr/host/host-jni.cc @@ -55,5 +55,5 @@ JNICALL Java_mono_android_Runtime_propagateUncaughtException (JNIEnv *env, [[may JNIEXPORT void JNICALL Java_mono_android_Runtime_notifyTimeZoneChanged ([[maybe_unused]] JNIEnv *env, [[maybe_unused]] jclass klass) { - // TODO: implement or remove + Host::notify_time_zone_changed (); } diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index f1c3fa1e8e4..6ba914a8279 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -478,7 +478,9 @@ void Host::Java_mono_android_Runtime_initInternal ( // trimmable typemap path (the method is trimmed; registration is handled in managed code). jnienv_register_jni_natives = init.registerJniNativesFn; jnienv_propagate_uncaught_exception = init.propagateUncaughtExceptionFn; + jnienv_notify_time_zone_changed = init.notifyTimeZoneChangedFn; abort_unless (jnienv_propagate_uncaught_exception != nullptr, "Failed to obtain unmanaged-callers-only function pointer to the PropagateUncaughtException method."); + abort_unless (jnienv_notify_time_zone_changed != nullptr, "Failed to obtain unmanaged-callers-only function pointer to the NotifyTimeZoneChanged method."); if (FastTiming::enabled ()) [[unlikely]] { internal_timing.end_event (); // native to managed @@ -554,3 +556,16 @@ void Host::propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrow jnienv_propagate_uncaught_exception (env, javaThread, javaException); } + +void Host::notify_time_zone_changed () noexcept +{ + if (jnienv_notify_time_zone_changed == nullptr) { + log_warn (LOG_DEFAULT, "notify_time_zone_changed called before JNIEnvInit.NotifyTimeZoneChanged was initialized"sv); + return; + } + + int32_t result = jnienv_notify_time_zone_changed (); + if (result != 0) { + log_warn (LOG_DEFAULT, "JNIEnvInit.NotifyTimeZoneChanged failed with result {}"sv, result); + } +} diff --git a/src/native/clr/include/host/host.hh b/src/native/clr/include/host/host.hh index a492ecf21b6..ce5ef3f1e52 100644 --- a/src/native/clr/include/host/host.hh +++ b/src/native/clr/include/host/host.hh @@ -22,6 +22,7 @@ namespace xamarin::android { static void Java_mono_android_Runtime_register (JNIEnv *env, jstring managedType, jclass nativeClass, jstring methods) noexcept; static void Java_mono_android_Runtime_registerNatives (JNIEnv *env, jclass nativeClass) noexcept; static void propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrowable javaException) noexcept; + static void notify_time_zone_changed () noexcept; static auto get_timing () -> std::shared_ptr { @@ -58,6 +59,7 @@ namespace xamarin::android { static inline bool found_assembly_store = false; static inline jnienv_register_jni_natives_fn jnienv_register_jni_natives = nullptr; static inline jnienv_propagate_uncaught_exception_fn jnienv_propagate_uncaught_exception = nullptr; + static inline jnienv_notify_time_zone_changed_fn jnienv_notify_time_zone_changed = nullptr; static inline jclass java_TimeZone = nullptr; diff --git a/src/native/common/include/managed-interface.hh b/src/native/common/include/managed-interface.hh index ca29461dc05..4ff0c671706 100644 --- a/src/native/common/include/managed-interface.hh +++ b/src/native/common/include/managed-interface.hh @@ -16,6 +16,7 @@ namespace xamarin::android { using jnienv_propagate_uncaught_exception_fn = void (*)(JNIEnv *env, jobject javaThread, jthrowable javaException); using jnienv_register_jni_natives_fn = void (*)(const jchar *typeName_ptr, int32_t typeName_len, jclass jniClass, const jchar *methods_ptr, int32_t methods_len); + using jnienv_notify_time_zone_changed_fn = int32_t (*)(); // NOTE: Keep this in sync with managed side in src/Mono.Android/Android.Runtime/JNIEnvInit.cs struct JnienvInitializeArgs { @@ -37,6 +38,7 @@ namespace xamarin::android { jobject grefGCUserPeerable; jnienv_propagate_uncaught_exception_fn propagateUncaughtExceptionFn; jnienv_register_jni_natives_fn registerJniNativesFn; + jnienv_notify_time_zone_changed_fn notifyTimeZoneChangedFn; }; // Keep the enum values in sync with those in src/Mono.Android/AndroidRuntime/BoundExceptionType.cs diff --git a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs index 33417011dcb..9deb33c15bb 100644 --- a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs @@ -259,4 +259,113 @@ public void CheckTimeZoneInfoIsCorrect (string timeZone) Assert.IsTrue (logLine.Contains (expectedLogcatOutput), $"Line '{logLine}' did not contain '{expectedLogcatOutput}'"); } } + + [TestFixture (AndroidRuntime.MonoVM)] + [TestFixture (AndroidRuntime.CoreCLR)] + [Category ("TimeZoneInfo")] + [NonParallelizable] + public class TimeZoneChangeTests : DeviceTest + { + const string InitialTimeZone = "America/New_York"; + const string ChangedTimeZone = "America/Los_Angeles"; + + readonly AndroidRuntime runtime; + ProjectBuilder builder; + XamarinAndroidApplicationProject proj; + string originalAutoTimeZone; + string originalTimeZone; + + public TimeZoneChangeTests (AndroidRuntime runtime) + { + this.runtime = runtime; + } + + [OneTimeSetUp] + public void BeforeAllTests () + { + AssertHasDevices (); + + originalAutoTimeZone = RunAdbCommand ("shell settings get global auto_time_zone").Trim (); + originalTimeZone = RunAdbCommand ("shell getprop persist.sys.timezone").Trim (); + RunAdbCommand ("shell settings put global auto_time_zone 0"); + SetTimeZone (InitialTimeZone); + + proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (runtime, "TimeZoneChangeTests")); + proj.SetRuntime (runtime); + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", """ + if (button == null) { + throw new InvalidOperationException ("Could not find the test button."); + } + + Console.WriteLine ($"TimeZoneChangeTests.Initial={TimeZoneInfo.Local.Id}"); + button.Click += delegate { + Console.WriteLine ($"TimeZoneChangeTests.Changed={TimeZoneInfo.Local.Id}"); + }; + """); + + builder = CreateApkBuilder (Path.Combine ("temp", TestName)); + builder.BuildLogFile = "timezone-change-install.log"; + Assert.IsTrue (builder.Install (proj), "Install should have succeeded."); + } + + [OneTimeTearDown] + protected override void AfterAllTests () + { + if (proj != null) { + RunAdbCommand ($"shell am force-stop --user all {proj.PackageName}"); + } + if (!string.IsNullOrEmpty (originalTimeZone)) { + SetTimeZone (originalTimeZone); + } + if (!string.IsNullOrEmpty (originalAutoTimeZone)) { + RunAdbCommand ($"shell settings put global auto_time_zone {originalAutoTimeZone}"); + } + } + + [Test] + public void TimeZoneChangeClearsCachedTimeZoneInfo () + { + ClearAdbLogcat (); + StartActivityAndAssert (proj); + + string initialLog = $"TimeZoneChangeTests.Initial={InitialTimeZone}"; + Assert.IsTrue ( + MonitorAdbLogcat ( + line => line.Contains (initialLog), + Path.Combine (Root, builder.ProjectDirectory, "timezone-change-initial.log"), + 45 + ), + $"App output did not contain '{initialLog}'" + ); + + ClearAdbLogcat (); + SetTimeZone (ChangedTimeZone); + Thread.Sleep (1000); + Assert.IsTrue (ClickButton (proj.PackageName, "myButton", "HELLO WORLD, CLICK ME!"), "Test button should have been clicked."); + + string changedLog = $"TimeZoneChangeTests.Changed={ChangedTimeZone}"; + Assert.IsTrue ( + MonitorAdbLogcat ( + line => line.Contains (changedLog), + Path.Combine (Root, builder.ProjectDirectory, "timezone-change-updated.log"), + 45 + ), + $"App output did not contain '{changedLog}'" + ); + } + + static void SetTimeZone (string timeZone) + { + string deviceTimeZone = ""; + for (int attempt = 0; attempt < 5; attempt++) { + RunAdbCommand ($"shell cmd alarm set-timezone \"{timeZone}\""); + deviceTimeZone = RunAdbCommand ("shell getprop persist.sys.timezone").Trim (); + if (deviceTimeZone == timeZone) { + break; + } + } + + Assert.AreEqual (timeZone, deviceTimeZone, $"The command to set the device timezone to {timeZone} failed. Current device timezone is {deviceTimeZone}"); + } + } } From b841070500e1d2558d743d2d4e3f7336f94d7346 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:39:39 -0500 Subject: [PATCH 2/6] Limit timezone regression test to CoreCLR Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs index 9deb33c15bb..9a2427bdd4b 100644 --- a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs @@ -260,7 +260,6 @@ public void CheckTimeZoneInfoIsCorrect (string timeZone) } } - [TestFixture (AndroidRuntime.MonoVM)] [TestFixture (AndroidRuntime.CoreCLR)] [Category ("TimeZoneInfo")] [NonParallelizable] From afe5ec1ab6bad9dc5d2ce09f04fbf7a84c9b92bf Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:40:22 -0500 Subject: [PATCH 3/6] Run timezone change test nightly Keep the live timezone mutation test out of PR validation while adding it to the dedicated nightly timezone stage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- build-tools/automation/azure-pipelines-nightly.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build-tools/automation/azure-pipelines-nightly.yaml b/build-tools/automation/azure-pipelines-nightly.yaml index 12733b86759..03c502bfb86 100644 --- a/build-tools/automation/azure-pipelines-nightly.yaml +++ b/build-tools/automation/azure-pipelines-nightly.yaml @@ -218,6 +218,18 @@ stages: - template: /build-tools/automation/yaml-templates/fail-on-issue.yaml + - template: /build-tools/automation/yaml-templates/run-emulator-tests.yaml + parameters: + jobName: TimeZoneChangeTests + jobTimeout: 120 + use1ESTemplate: false + testSteps: + - template: run-nunit-tests.yaml + parameters: + testRunTitle: TimeZoneChangeTests On Device - macOS + testAssembly: $(System.DefaultWorkingDirectory)/bin/Test$(XA.Build.Configuration)/MSBuildDeviceIntegration/$(DotNetStableTargetFramework)/MSBuildDeviceIntegration.dll + dotNetTestExtraArgs: --filter "Name=TimeZoneChangeClearsCachedTimeZoneInfo" + # Localization test jobs - stage: test_locals From bf42f4dfe4efe2ef607de3b096a84e12edd39e09 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:43:58 -0500 Subject: [PATCH 4/6] Use printf logging for timezone callback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- src/native/clr/host/host.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 6ba914a8279..8f227010455 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -560,12 +560,12 @@ void Host::propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrow void Host::notify_time_zone_changed () noexcept { if (jnienv_notify_time_zone_changed == nullptr) { - log_warn (LOG_DEFAULT, "notify_time_zone_changed called before JNIEnvInit.NotifyTimeZoneChanged was initialized"sv); + log_warnf (LOG_DEFAULT, "notify_time_zone_changed called before JNIEnvInit.NotifyTimeZoneChanged was initialized"); return; } int32_t result = jnienv_notify_time_zone_changed (); if (result != 0) { - log_warn (LOG_DEFAULT, "JNIEnvInit.NotifyTimeZoneChanged failed with result {}"sv, result); + log_warnf (LOG_DEFAULT, "JNIEnvInit.NotifyTimeZoneChanged failed with result %d", result); } } From 5bf6b3e20d2c4fca8642f2963d1cdf26ff74c2c0 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:46:01 -0500 Subject: [PATCH 5/6] Use void timezone callback Match the MonoVM timezone notification contract while catching and logging unexpected managed exceptions before they reach native code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- src/Mono.Android/Android.Runtime/JNIEnvInit.cs | 6 ++---- src/native/clr/host/host.cc | 5 +---- src/native/common/include/managed-interface.hh | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 67bee549dc4..fa85e1af608 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -58,14 +58,12 @@ static void PropagateUncaughtException (IntPtr env, IntPtr javaThread, IntPtr ja } [UnmanagedCallersOnly] - static int NotifyTimeZoneChanged () + static void NotifyTimeZoneChanged () { try { AndroidEnvironment.NotifyTimeZoneChanged (); - return 0; } catch (Exception e) { Logger.Log (LogLevel.Error, "MonoAndroid", $"Exception while clearing time-zone caches: {e}"); - return -1; } } @@ -158,7 +156,7 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) RegisterTrimmableTypeMapNativeMethodsIfNeeded (); args->propagateUncaughtExceptionFn = (IntPtr)(delegate* unmanaged)&PropagateUncaughtException; - args->notifyTimeZoneChangedFn = (IntPtr)(delegate* unmanaged)&NotifyTimeZoneChanged; + args->notifyTimeZoneChangedFn = (IntPtr)(delegate* unmanaged)&NotifyTimeZoneChanged; if (!RuntimeFeature.TrimmableTypeMap) { args->registerJniNativesFn = GetRegisterJniNativesFnPtr (); diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 8f227010455..5e21bb2d486 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -564,8 +564,5 @@ void Host::notify_time_zone_changed () noexcept return; } - int32_t result = jnienv_notify_time_zone_changed (); - if (result != 0) { - log_warnf (LOG_DEFAULT, "JNIEnvInit.NotifyTimeZoneChanged failed with result %d", result); - } + jnienv_notify_time_zone_changed (); } diff --git a/src/native/common/include/managed-interface.hh b/src/native/common/include/managed-interface.hh index 4ff0c671706..ab8668355fb 100644 --- a/src/native/common/include/managed-interface.hh +++ b/src/native/common/include/managed-interface.hh @@ -16,7 +16,7 @@ namespace xamarin::android { using jnienv_propagate_uncaught_exception_fn = void (*)(JNIEnv *env, jobject javaThread, jthrowable javaException); using jnienv_register_jni_natives_fn = void (*)(const jchar *typeName_ptr, int32_t typeName_len, jclass jniClass, const jchar *methods_ptr, int32_t methods_len); - using jnienv_notify_time_zone_changed_fn = int32_t (*)(); + using jnienv_notify_time_zone_changed_fn = void (*)(); // NOTE: Keep this in sync with managed side in src/Mono.Android/Android.Runtime/JNIEnvInit.cs struct JnienvInitializeArgs { From 4f75f54a4c9308c87e64662d4c7d039bfc6b36fc Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 23 Jul 2026 16:57:14 -0500 Subject: [PATCH 6/6] Run base timezone test cleanup Ensure shared device cleanup runs even when restoring time-zone settings fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeee62e5-04ed-40dd-9f0e-a9c7e145ace0 --- .../Tests/TimeZoneInfoTests.cs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs index 9a2427bdd4b..8188e9edd4b 100644 --- a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs @@ -310,14 +310,18 @@ public void BeforeAllTests () [OneTimeTearDown] protected override void AfterAllTests () { - if (proj != null) { - RunAdbCommand ($"shell am force-stop --user all {proj.PackageName}"); - } - if (!string.IsNullOrEmpty (originalTimeZone)) { - SetTimeZone (originalTimeZone); - } - if (!string.IsNullOrEmpty (originalAutoTimeZone)) { - RunAdbCommand ($"shell settings put global auto_time_zone {originalAutoTimeZone}"); + try { + if (proj != null) { + RunAdbCommand ($"shell am force-stop --user all {proj.PackageName}"); + } + if (!string.IsNullOrEmpty (originalTimeZone)) { + SetTimeZone (originalTimeZone); + } + if (!string.IsNullOrEmpty (originalAutoTimeZone)) { + RunAdbCommand ($"shell settings put global auto_time_zone {originalAutoTimeZone}"); + } + } finally { + base.AfterAllTests (); } }