diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt new file mode 100644 index 00000000000000..efbcd2933396d6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/timezone/TimeZoneModule.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.modules.timezone + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.module.annotations.ReactModule + +/** + * Internal module responsible for listening to system timezone changes and + * resetting the Hermes timezone cache when applicable. + * + * This ensures Date behavior remains correct after a device timezone change + * during runtime of app. + */ +@ReactModule(name = TimeZoneModule.NAME) +public open class TimeZoneModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + public companion object { + public const val NAME: String = "TimeZoneModule" + } + + private var timeZoneChangeReceiver: BroadcastReceiver? = null + + public override fun getName(): String = NAME + + private external fun resetNativeHermesTimeZoneCache(jsRuntimePtr: Long) + + public override fun initialize() { + super.initialize() + registerTimeZoneChangeReceiver() + } + + private fun registerTimeZoneChangeReceiver() { + if (timeZoneChangeReceiver != null) { + return + } + timeZoneChangeReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + handleTimeZoneChange() + } + } + + reactContext.registerReceiver( + timeZoneChangeReceiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED) + ) + } + + public override fun onCatalystInstanceDestroy() { + timeZoneChangeReceiver?.let { reactContext.unregisterReceiver(it) } + timeZoneChangeReceiver = null + super.onCatalystInstanceDestroy() + } + + protected open fun resetHermesTimeZoneCache(jsRuntimePtr: Long) { + resetNativeHermesTimeZoneCache(jsRuntimePtr) + } + + private fun handleTimeZoneChange() { + try { + val catalystInstance = reactApplicationContext.catalystInstance ?: return + reactApplicationContext.runOnJSQueueThread { + val jsContext = catalystInstance.javaScriptContextHolder.get() + resetHermesTimeZoneCache(jsContext) + } + } catch (e: Exception) { + Log.e(NAME, "Failed to reset Hermes timezone cache on timezone change", e) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index 9927bf571536e5..79ef578c7708e3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -41,6 +41,7 @@ import com.facebook.react.modules.reactdevtoolssettings.ReactDevToolsSettingsMan import com.facebook.react.modules.share.ShareModule import com.facebook.react.modules.sound.SoundManagerModule import com.facebook.react.modules.statusbar.StatusBarModule +import com.facebook.react.modules.timezone.TimeZoneModule import com.facebook.react.modules.toast.ToastModule import com.facebook.react.modules.vibration.VibrationModule import com.facebook.react.modules.websocket.WebSocketModule @@ -91,11 +92,12 @@ import com.facebook.react.views.view.ReactViewManager ShareModule::class, SoundManagerModule::class, StatusBarModule::class, + TimeZoneModule::class, ToastModule::class, VibrationModule::class, WebSocketModule::class, ] -) + ) public class MainReactPackage @JvmOverloads constructor(private val config: MainPackageConfig? = null) : @@ -124,6 +126,7 @@ constructor(private val config: MainPackageConfig? = null) : ShareModule.NAME -> ShareModule(reactContext) StatusBarModule.NAME -> StatusBarModule(reactContext) SoundManagerModule.NAME -> SoundManagerModule(reactContext) + TimeZoneModule.NAME -> TimeZoneModule(reactContext) ToastModule.NAME -> ToastModule(reactContext) VibrationModule.NAME -> VibrationModule(reactContext) WebSocketModule.NAME -> WebSocketModule(reactContext) @@ -265,6 +268,7 @@ constructor(private val config: MainPackageConfig? = null) : ShareModule::class.java, StatusBarModule::class.java, SoundManagerModule::class.java, + TimeZoneModule::class.java, ToastModule::class.java, VibrationModule::class.java, WebSocketModule::class.java, diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt index 1e17ddb93db17d..820886919749aa 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt @@ -31,6 +31,7 @@ add_library( OnLoad-common.cpp ReadableNativeArray.cpp ReadableNativeMap.cpp + TimeZoneCache.cpp TransformHelper.cpp WritableNativeArray.cpp WritableNativeMap.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp index ef069ed7e41d51..53660205ccde06 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/OnLoad-common.cpp @@ -14,6 +14,7 @@ #include "TransformHelper.h" #include "WritableNativeArray.h" #include "WritableNativeMap.h" +#include "TimeZoneCache.h" namespace facebook::react { @@ -29,6 +30,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { WritableNativeArray::registerNatives(); WritableNativeMap::registerNatives(); TransformHelper::registerNatives(); + TimeZoneCache::registerNatives(); }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp new file mode 100644 index 00000000000000..b2a9935e9c2167 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.cpp @@ -0,0 +1,32 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "TimeZoneCache.h" +#include + +namespace facebook::react { + +void TimeZoneCache::resetNativeHermesTimeZoneCache(jni::alias_ref /* unused */,jlong jsRuntimePtr) { + if (!jsRuntimePtr) { + return; +} + jsi::Runtime &runtime = *reinterpret_cast(jsRuntimePtr); + auto* hermesAPI = jsi::castInterface(&runtime); + if (!hermesAPI) { + return; + } + hermesAPI->resetTimezoneCache(); +} +void TimeZoneCache::registerNatives() { + registerHybrid({ + makeNativeMethod("resetNativeHermesTimeZoneCache", TimeZoneCache::resetNativeHermesTimeZoneCache), + }); +} + + + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h new file mode 100644 index 00000000000000..a3db987c392e58 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/TimeZoneCache.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +namespace facebook::react { + +class TimeZoneCache : public jni::HybridClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/facebook/react/modules/timezone/TimeZoneModule;"; + + static void registerNatives(); + + static void resetNativeHermesTimeZoneCache(jni::alias_ref /* unused */, jlong jsRuntimePtr); + + ~TimeZoneCache() = default; + + friend HybridBase; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt new file mode 100644 index 00000000000000..941e1fcad38450 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/timezone/TimeZoneModuleTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.modules.timezone + +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter +import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.JavaScriptContextHolder +import com.facebook.react.bridge.ReactApplicationContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TimeZoneModuleTest { + + private lateinit var reactContext: ReactApplicationContext + private lateinit var catalystInstance: CatalystInstance + private lateinit var module: TestableTimeZoneModule + + // Test subclass to override the native method and avoid UnsatisfiedLinkError + class TestableTimeZoneModule(context: ReactApplicationContext) : TimeZoneModule(context) { + public override fun resetHermesTimeZoneCache(jsRuntimePtr: Long) { + // Do nothing in JVM test + } + } + + @Before + fun setup() { + // Mock ReactApplicationContext and CatalystInstance + reactContext = mock() + catalystInstance = mock() + whenever(reactContext.catalystInstance).thenReturn(catalystInstance) + + // Use the test subclass that overrides the native method + module = spy(TestableTimeZoneModule(reactContext)) + } + + @Test + fun testModuleName() { + assertEquals(TimeZoneModule.NAME, module.name) + } + + @Test + fun testInitializeRegistersReceiver() { + module.initialize() + + // Verify that the private receiver is not null + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) + assertNotNull(receiver) + + // Verify that the receiver was registered with the correct intent filter + verify(reactContext).registerReceiver(any(), any()) + } + + @Test + fun testOnCatalystInstanceDestroyUnregistersReceiver() { + module.initialize() // registers the receiver + + module.onCatalystInstanceDestroy() + + // Verify that the private receiver is cleared + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) + assertNull(receiver) + + // Verify that the receiver was unregistered + verify(reactContext).unregisterReceiver(any()) + } + + @Test + fun testTimeZoneChangeTriggersHermesReset() { + // 1. Mock JavaScriptContextHolder + val jsContextHolder = mock() + whenever(catalystInstance.javaScriptContextHolder).thenReturn(jsContextHolder) + whenever(jsContextHolder.get()).thenReturn(12345L) + + // 2. Mock runOnJSQueueThread to run synchronously + doAnswer { invocation -> + val runnable = invocation.arguments[0] as Runnable + runnable.run() + null + } + .whenever(reactContext) + .runOnJSQueueThread(any()) + + // 3. Initialize the module (registers the receiver) + module.initialize() + + // 4. Access the private receiver via reflection + val receiverField = TimeZoneModule::class.java.getDeclaredField("timeZoneChangeReceiver") + receiverField.isAccessible = true + val receiver = receiverField.get(module) as BroadcastReceiver + + // 5. Simulate timezone change broadcast + receiver.onReceive(reactContext, Intent(Intent.ACTION_TIMEZONE_CHANGED)) + + // 6. Verify that the JS context was accessed + verify(catalystInstance).javaScriptContextHolder + verify(jsContextHolder).get() + + // 7. Verify that resetHermesTimeZoneCache was called with correct JS context + verify(module).resetHermesTimeZoneCache(12345L) + } +}