package com.swmansion.rnscreens.utils import android.app.Activity import android.content.Context import android.util.Log import android.view.View import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import com.facebook.jni.annotations.DoNotStrip import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.PixelUtil import com.google.android.material.appbar.AppBarLayout import com.swmansion.rnscreens.ScreenStackHeaderConfig import java.lang.ref.WeakReference /** * This class provides methods to create dummy layout (that mimics Screen setup), and to compute * expected header height. It is meant to be accessed from C++ layer via JNI. * See https://github.com/software-mansion/react-native-screens/pull/2169 * for more detailed description of the issue this code solves. */ @DoNotStrip internal class ScreenDummyLayoutHelper( reactContext: ReactApplicationContext, ) : LifecycleEventListener { // The state required to compute header dimensions. We want this on instance rather than on class // for context access & being tied to instance lifetime. private lateinit var coordinatorLayout: CoordinatorLayout private lateinit var appBarLayout: AppBarLayout private lateinit var dummyContentView: View private lateinit var toolbar: Toolbar private var defaultFontSize: Float = 0f private var defaultContentInsetStartWithNavigation: Int = 0 // LRU with size 1 private var cache: CacheEntry = CacheEntry.EMPTY // We do not want to be responsible for the context lifecycle. If it's null, we're fine. // This same context is being passed down to our view components so it is destroyed // only if our views also are. private var reactContextRef: WeakReference = WeakReference(reactContext) init { // We load the library so that we are able to communicate with our C++ code (descriptor & shadow nodes). // Basically we leak this object to C++, as its lifecycle should span throughout whole application // lifecycle anyway. try { System.loadLibrary(LIBRARY_NAME) } catch (e: UnsatisfiedLinkError) { Log.w(TAG, "[RNScreens] Failed to load $LIBRARY_NAME library.") } weakInstance = WeakReference(this) if (!(reactContext.hasCurrentActivity() && maybeInitDummyLayoutWithHeader(reactContext))) { reactContext.addLifecycleEventListener(this) } } /** * Tries to initialize dummy view hierarchy with CoordinatorLayout, AppBarLayout and dummy View. * We utilize this to compute header height (app bar layout height) from C++ layer when its needed. * * This method might fail in case there is activity attached to the react context. * * This method is called from various threads! * * @return boolean whether the layout was initialised or not */ private fun maybeInitDummyLayoutWithHeader(reactContext: ReactApplicationContext): Boolean { if (isLayoutInitialized) { return true } // Possible data race here - activity is injected into context on UI thread. if (!reactContext.hasCurrentActivity()) { return false } // We need to use activity here, as react context does not have theme attributes required by // AppBarLayout attached leading to crash. val contextWithTheme = requireNotNull(reactContext.currentActivity) { "[RNScreens] Attempt to use context detached from activity. This could happen only due to race-condition." } synchronized(this) { // The layout could have been initialised when this thread waited for access to critical section. if (isLayoutInitialized) { return true } initDummyLayoutWithHeader(contextWithTheme) } return true } /** * Initialises the dummy layout. This method is **not** thread-safe. * * @param contextWithTheme this function expects the context to have theme attributes required * to initialize the AppBarLayout. */ private fun initDummyLayoutWithHeader(contextWithTheme: Context) { coordinatorLayout = CoordinatorLayout(contextWithTheme) appBarLayout = AppBarLayout(contextWithTheme).apply { layoutParams = CoordinatorLayout.LayoutParams( CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.WRAP_CONTENT, ) } toolbar = Toolbar(contextWithTheme).apply { title = DEFAULT_HEADER_TITLE layoutParams = AppBarLayout .LayoutParams( AppBarLayout.LayoutParams.MATCH_PARENT, AppBarLayout.LayoutParams.WRAP_CONTENT, ).apply { scrollFlags = 0 } } // We know the title text view will be there, cause we've just set title. defaultFontSize = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar)!!.textSize defaultContentInsetStartWithNavigation = toolbar.contentInsetStartWithNavigation appBarLayout.addView(toolbar) dummyContentView = View(contextWithTheme).apply { layoutParams = CoordinatorLayout.LayoutParams( CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.MATCH_PARENT, ) } coordinatorLayout.apply { addView(appBarLayout) addView(dummyContentView) } isLayoutInitialized = true } /** * Triggers layout pass on dummy view hierarchy, taking into consideration selected * ScreenStackHeaderConfig props that might have impact on final header height. * * @param fontSize font size value as passed from JS * @return header height in dp as consumed by Yoga */ @DoNotStrip private fun computeDummyLayout( fontSize: Int, isTitleEmpty: Boolean, ): Float { if (!isLayoutInitialized) { val reactContext = requireReactContext { "[RNScreens] Context was null-ed before dummy layout was initialized" } if (!maybeInitDummyLayoutWithHeader(reactContext)) { // This theoretically might happen at Fabric + "bridgefull" combination, due to race condition where `reactContext.currentActivity` // is still null at this execution point. We don't wanna crash in such case, thus returning zeroed height. Log.e( TAG, "[RNScreens] Failed to late-init layout while computing header height. This is most likely a race-condition-bug in react-native-screens, please file an issue at https://github.com/software-mansion/react-native-screens/issues", ) return 0.0f } } if (cache.hasKey(CacheKey(fontSize, isTitleEmpty))) { return cache.headerHeight } val topLevelDecorView = requireActivity().window.decorView // These dimensions are not accurate, as they do include status bar & navigation bar, however // it is ok for our purposes. val decorViewWidth = topLevelDecorView.width val decorViewHeight = topLevelDecorView.height val widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewWidth, View.MeasureSpec.EXACTLY) val heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(decorViewHeight, View.MeasureSpec.EXACTLY) if (isTitleEmpty) { toolbar.title = "" toolbar.contentInsetStartWithNavigation = 0 } else { toolbar.title = DEFAULT_HEADER_TITLE toolbar.contentInsetStartWithNavigation = defaultContentInsetStartWithNavigation } val textView = ScreenStackHeaderConfig.findTitleTextViewInToolbar(toolbar) textView?.textSize = if (fontSize != FONT_SIZE_UNSET) fontSize.toFloat() else defaultFontSize coordinatorLayout.measure(widthMeasureSpec, heightMeasureSpec) // It seems that measure pass would be enough, however I'm not certain whether there are no // scenarios when layout violates measured dimensions. coordinatorLayout.layout(0, 0, decorViewWidth, decorViewHeight) val headerHeight = PixelUtil.toDIPFromPixel(appBarLayout.height.toFloat()) cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight) return headerHeight } private fun requireReactContext(lazyMessage: (() -> Any)? = null): ReactApplicationContext = requireNotNull( reactContextRef.get(), lazyMessage ?: { "[RNScreens] Attempt to require missing react context" }, ) private fun requireActivity(): Activity = requireNotNull(requireReactContext().currentActivity) { "[RNScreens] Attempt to use context detached from activity" } companion object { const val TAG = "ScreenDummyLayoutHelper" const val LIBRARY_NAME = "react_codegen_rnscreens" const val FONT_SIZE_UNSET = -1 private const val DEFAULT_HEADER_TITLE: String = "FontSize123!#$" // We access this field from C++ layer, through `getInstance` method. // We don't care what instance we get access to as long as it has initialized // dummy view hierarchy. private var weakInstance = WeakReference(null) @DoNotStrip @JvmStatic fun getInstance(): ScreenDummyLayoutHelper? = weakInstance.get() } // This value is fetched / stored from UI and background thread. Volatile here ensures // that updates are visible to the other thread. @Volatile private var isLayoutInitialized = false override fun onHostResume() { // This is the earliest we have guarantee that the context has a reference to an activity. val reactContext = requireReactContext { "[RNScreens] ReactContext missing in onHostResume! This should not happen." } // There are some exotic edge cases where activity might not be present in context // at this point, e.g. when reloading RN in development after an error was reported with redbox. if (maybeInitDummyLayoutWithHeader(reactContext)) { reactContext.removeLifecycleEventListener(this) } else { Log.w(TAG, "[RNScreens] Failed to initialise dummy layout in onHostResume.") } } override fun onHostPause() = Unit override fun onHostDestroy() { reactContextRef.get()?.removeLifecycleEventListener(this) } } private data class CacheKey( val fontSize: Int, val isTitleEmpty: Boolean, ) private class CacheEntry( val cacheKey: CacheKey, val headerHeight: Float, ) { fun hasKey(key: CacheKey) = cacheKey.fontSize != Int.MIN_VALUE && cacheKey == key companion object { val EMPTY = CacheEntry(CacheKey(Int.MIN_VALUE, false), 0f) } }