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 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..fa85e1af608 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,16 @@ static void PropagateUncaughtException (IntPtr env, IntPtr javaThread, IntPtr ja JNIEnv.PropagateUncaughtException (env, javaThread, javaException); } + [UnmanagedCallersOnly] + static void NotifyTimeZoneChanged () + { + try { + AndroidEnvironment.NotifyTimeZoneChanged (); + } catch (Exception e) { + Logger.Log (LogLevel.Error, "MonoAndroid", $"Exception while clearing time-zone caches: {e}"); + } + } + [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 +156,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..5e21bb2d486 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,13 @@ 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_warnf (LOG_DEFAULT, "notify_time_zone_changed called before JNIEnvInit.NotifyTimeZoneChanged was initialized"); + return; + } + + jnienv_notify_time_zone_changed (); +} 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..ab8668355fb 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 = void (*)(); // 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..8188e9edd4b 100644 --- a/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/TimeZoneInfoTests.cs @@ -259,4 +259,116 @@ public void CheckTimeZoneInfoIsCorrect (string timeZone) Assert.IsTrue (logLine.Contains (expectedLogcatOutput), $"Line '{logLine}' did not contain '{expectedLogcatOutput}'"); } } + + [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 () + { + 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 (); + } + } + + [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}"); + } + } }