Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catalystInstance is a legacy arch concept and javaScriptContextHolder won't work there. Please ensure this works with the new arch.

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) :
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ add_library(
OnLoad-common.cpp
ReadableNativeArray.cpp
ReadableNativeMap.cpp
TimeZoneCache.cpp
TransformHelper.cpp
WritableNativeArray.cpp
WritableNativeMap.cpp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include "TransformHelper.h"
#include "WritableNativeArray.h"
#include "WritableNativeMap.h"
#include "TimeZoneCache.h"

namespace facebook::react {

Expand All @@ -29,6 +30,7 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
WritableNativeArray::registerNatives();
WritableNativeMap::registerNatives();
TransformHelper::registerNatives();
TimeZoneCache::registerNatives();
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <jsi/hermes-interfaces.h>

namespace facebook::react {

void TimeZoneCache::resetNativeHermesTimeZoneCache(jni::alias_ref<jclass> /* unused */,jlong jsRuntimePtr) {
if (!jsRuntimePtr) {
return;
}
jsi::Runtime &runtime = *reinterpret_cast<jsi::Runtime*>(jsRuntimePtr);
auto* hermesAPI = jsi::castInterface<hermes::IHermes>(&runtime);
if (!hermesAPI) {
return;
}
hermesAPI->resetTimezoneCache();
}
void TimeZoneCache::registerNatives() {
registerHybrid({
makeNativeMethod("resetNativeHermesTimeZoneCache", TimeZoneCache::resetNativeHermesTimeZoneCache),
});
}



} // namespace facebook::react
Original file line number Diff line number Diff line change
@@ -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 <fbjni/fbjni.h>

namespace facebook::react {

class TimeZoneCache : public jni::HybridClass<TimeZoneCache> {
public:
static constexpr auto kJavaDescriptor = "Lcom/facebook/react/modules/timezone/TimeZoneModule;";

static void registerNatives();

static void resetNativeHermesTimeZoneCache(jni::alias_ref<jclass> /* unused */, jlong jsRuntimePtr);

~TimeZoneCache() = default;

friend HybridBase;
};

} // namespace facebook::react
Original file line number Diff line number Diff line change
@@ -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<BroadcastReceiver>(), any<IntentFilter>())
}

@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<BroadcastReceiver>())
}

@Test
fun testTimeZoneChangeTriggersHermesReset() {
// 1. Mock JavaScriptContextHolder
val jsContextHolder = mock<JavaScriptContextHolder>()
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<Runnable>())

// 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)
}
}