From 098d16dd01b9fef34336d8e0036ba2e833a517c1 Mon Sep 17 00:00:00 2001 From: Thunnini Date: Mon, 5 Feb 2024 18:53:01 +0900 Subject: [PATCH] Fix some build problems on expo-image --- packages/mobile/android/build.gradle | 2 + packages/mobile/package.json | 2 +- packages/mobile/scripts/postinstall/exec.sh | 3 + .../expo-image/ExpoImageViewWrapper.kt | 605 ++++++++++++++++++ .../expo-image/ImageViewWrapperTarget.kt | 313 +++++++++ yarn.lock | 4 +- 6 files changed, 926 insertions(+), 3 deletions(-) create mode 100644 packages/mobile/scripts/postinstall/expo-image/ExpoImageViewWrapper.kt create mode 100644 packages/mobile/scripts/postinstall/expo-image/ImageViewWrapperTarget.kt diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle index 92a66fea1c..e0f5d2d1f9 100644 --- a/packages/mobile/android/build.gradle +++ b/packages/mobile/android/build.gradle @@ -7,6 +7,8 @@ buildscript { compileSdkVersion = 34 targetSdkVersion = 34 + kotlinVersion = "1.8.10" + // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. ndkVersion = "23.1.7779620" } diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 39fffcc96d..1f5e57442e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -53,7 +53,7 @@ "buffer": "^6.0.3", "color": "^4.2.3", "delay": "^6.0.0", - "expo": "^49.0.21", + "expo": "^49.0.22", "expo-apple-authentication": "^6.3.0", "expo-clipboard": "^4.8.0", "expo-crypto": "^12.8.0", diff --git a/packages/mobile/scripts/postinstall/exec.sh b/packages/mobile/scripts/postinstall/exec.sh index a95017dbeb..4284e83986 100644 --- a/packages/mobile/scripts/postinstall/exec.sh +++ b/packages/mobile/scripts/postinstall/exec.sh @@ -16,3 +16,6 @@ cp ${DIR}/ledger/BleTransport.js ${DIR}/../../node_modules/@ledgerhq/react-nativ cp ${DIR}/restart-fix/JSCRuntime.cpp ${DIR}/../../node_modules/react-native/ReactCommon/jsc/JSCRuntime.cpp cp ${DIR}/wc/index.js ${DIR}/../../node_modules/@walletconnect/react-native-compat/index.js + +cp ${DIR}/expo-image/ExpoImageViewWrapper.kt ${DIR}/../../node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt +cp ${DIR}/expo-image/ImageViewWrapperTarget.kt ${DIR}/../../node_modules/expo-image/android/src/main/java/expo/modules/image/ImageViewWrapperTarget.kt diff --git a/packages/mobile/scripts/postinstall/expo-image/ExpoImageViewWrapper.kt b/packages/mobile/scripts/postinstall/expo-image/ExpoImageViewWrapper.kt new file mode 100644 index 0000000000..b840dca169 --- /dev/null +++ b/packages/mobile/scripts/postinstall/expo-image/ExpoImageViewWrapper.kt @@ -0,0 +1,605 @@ +package expo.modules.image + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Handler +import android.view.View +import android.widget.FrameLayout +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.RequestOptions +import com.facebook.yoga.YogaConstants +import expo.modules.image.enums.ContentFit +import expo.modules.image.enums.Priority +import expo.modules.image.events.GlideRequestListener +import expo.modules.image.events.OkHttpProgressListener +import expo.modules.image.okhttp.GlideUrlWrapper +import expo.modules.image.records.CachePolicy +import expo.modules.image.records.ContentPosition +import expo.modules.image.records.ImageErrorEvent +import expo.modules.image.records.ImageLoadEvent +import expo.modules.image.records.ImageProgressEvent +import expo.modules.image.records.ImageTransition +import expo.modules.image.records.SourceMap +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import jp.wasabeef.glide.transformations.BlurTransformation +import java.lang.ref.WeakReference +import kotlin.math.abs +import kotlin.math.min + +@SuppressLint("ViewConstructor") +class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val activity: Activity + get() = appContext.currentActivity ?: throw MissingActivity() + + internal val requestManager = getOrCreateRequestManager(appContext, activity) + private val progressListener = OkHttpProgressListener(WeakReference(this)) + + private val firstView = ExpoImageView(activity) + private val secondView = ExpoImageView(activity) + + private val mainHandler = Handler(context.mainLooper) + + /** + * @returns the view which is currently active or will be used when both views are empty + */ + private val activeView: ExpoImageView + get() { + if (secondView.drawable != null) { + return secondView + } + return firstView + } + + private var firstTarget = ImageViewWrapperTarget(WeakReference(this)) + private var secondTarget = ImageViewWrapperTarget(WeakReference(this)) + + internal val onLoadStart by EventDispatcher() + internal val onProgress by EventDispatcher() + internal val onError by EventDispatcher() + internal val onLoad by EventDispatcher() + + internal var sources: List = emptyList() + private val bestSource: SourceMap? + get() = getBestSource(sources) + + internal var placeholders: List = emptyList() + private val bestPlaceholder: SourceMap? + get() = getBestSource(placeholders) + + internal var blurRadius: Int? = null + set(value) { + if (field != value) { + shouldRerender = true + } + field = value + } + + internal var transition: ImageTransition? = null + + internal var contentFit: ContentFit = ContentFit.Cover + set(value) { + field = value + activeView.contentFit = value + transformationMatrixChanged = true + } + + internal var placeholderContentFit: ContentFit = ContentFit.ScaleDown + set(value) { + field = value + activeView.placeholderContentFit = value + transformationMatrixChanged = true + } + + internal var contentPosition: ContentPosition = ContentPosition.center + set(value) { + field = value + activeView.contentPosition = value + transformationMatrixChanged = true + } + + internal var borderStyle: String? = null + set(value) { + field = value + activeView.setBorderStyle(value) + } + + internal var backgroundColor: Int? = null + set(value) { + field = value + activeView.setBackgroundColor(value) + } + + internal var tintColor: Int? = null + set(value) { + field = value + activeView.setTintColor(value) + } + + internal var isFocusableProp: Boolean = false + set(value) { + field = value + activeView.isFocusable = value + } + + internal var accessible: Boolean = false + set(value) { + field = value + setIsScreenReaderFocusable(activeView, value) + } + + internal var accessibilityLabel: String? = null + set(value) { + field = value + activeView.contentDescription = accessibilityLabel + } + + var recyclingKey: String? = null + set(value) { + clearViewBeforeChangingSource = field != null && value != null && value != field + field = value + } + + internal var allowDownscaling: Boolean = true + set(value) { + field = value + shouldRerender = true + } + + internal var autoplay: Boolean = true + + internal var priority: Priority = Priority.NORMAL + internal var cachePolicy: CachePolicy = CachePolicy.DISK + + private var borderRadius = FloatArray(9) { YogaConstants.UNDEFINED } + private var borderWidth = FloatArray(9) { YogaConstants.UNDEFINED } + private var borderColor = Array(9) { YogaConstants.UNDEFINED to YogaConstants.UNDEFINED } + + fun setBorderRadius(index: Int, radius: Float) { + borderRadius[index] = radius + activeView.setBorderRadius(index, radius) + } + + fun setBorderWidth(index: Int, width: Float) { + borderWidth[index] = width + activeView.setBorderWidth(index, width) + } + + fun setBorderColor(index: Int, rgb: Float, alpha: Float) { + borderColor[index] = rgb to alpha + activeView.setBorderColor(index, rgb, alpha) + } + + fun setIsAnimating(setAnimating: Boolean) { + val resource = activeView.drawable + + if (resource is Animatable) { + if (setAnimating) { + resource.start() + } else { + resource.stop() + } + } + } + + /** + * Whether the image should be loaded again + */ + private var shouldRerender = false + + /** + * Currently loaded source + */ + private var loadedSource: GlideModel? = null + + /** + * Whether the transformation matrix should be reapplied + */ + private var transformationMatrixChanged = false + + /** + * Whether the view content should be cleared to blank when the source was changed. + */ + private var clearViewBeforeChangingSource = false + + /** + * Copies saved props to the provided view. + * It ensures that the view state is up to date. + */ + private fun copyProps(view: ExpoImageView) { + view.contentFit = contentFit + view.contentPosition = contentPosition + view.setBorderStyle(borderStyle) + view.setBackgroundColor(backgroundColor) + view.setTintColor(tintColor) + view.isFocusable = isFocusableProp + view.contentDescription = accessibilityLabel + borderColor.forEachIndexed { index, (rgb, alpha) -> + view.setBorderColor(index, rgb, alpha) + } + borderRadius.forEachIndexed { index, value -> + view.setBorderRadius(index, value) + } + borderWidth.forEachIndexed { index, value -> + view.setBorderWidth(index, value) + } + setIsScreenReaderFocusable(view, accessible) + } + + /** + * Allows `isScreenReaderFocusable` to be set on apis below level 28 + */ + private fun setIsScreenReaderFocusable(view: View, value: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + view.isScreenReaderFocusable = value + } else { + ViewCompat.setAccessibilityDelegate( + this, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + info.isScreenReaderFocusable = value + super.onInitializeAccessibilityNodeInfo(host, info) + } + } + ) + } + } + + /** + * When a new resource is available, this method tries to handle it. + * It decides where provided bitmap should be displayed and clears the previous target/image. + */ + fun onResourceReady( + target: ImageViewWrapperTarget, + resource: Drawable, + isPlaceholder: Boolean = false + ) = + // The "onResourceReady" function will be triggered when the new resource is available by the Glide. + // According to the Glide documentation (https://bumptech.github.io/glide/doc/debugging.html#you-cant-start-or-clear-loads-in-requestlistener-or-target-callbacks), + // it's not advisable to clear the Glide target within the stack frame. + // To avoid this, a new runnable is posted to the front of the main queue, which can then clean or create targets. + // This ensures that the "onResourceReady" frame of the Glide code will be discarded, and the internal state can be altered once again. + // Normally, using "postAtFrontOfQueue" can lead to issues such as message queue starvation, ordering problems, and other unexpected consequences. + // However, in this case, it is safe to use as long as nothing else is added to the queue. + // The intention is simply to wait for the Glide code to finish before the content of the underlying views is changed during the same rendering tick. + mainHandler.postAtFrontOfQueue { + val transitionDuration = (transition?.duration ?: 0).toLong() + + // If provided resource is a placeholder, but the target doesn't have a source, we treat it as a normal image. + if (!isPlaceholder || !target.hasSource) { + val (newView, previousView) = if (firstView.drawable == null) { + firstView to secondView + } else { + secondView to firstView + } + + val clearPreviousView = { + previousView + .recycleView() + ?.apply { + // When the placeholder is loaded, one target is displayed in both views. + // So we just have to move the reference to a new view instead of clearing the target. + if (this != target) { + clear(requestManager) + } + } + } + + configureView(newView, target, resource, isPlaceholder) + if (transitionDuration <= 0) { + clearPreviousView() + newView.alpha = 1f + newView.bringToFront() + } else { + newView.bringToFront() + previousView.alpha = 1f + newView.alpha = 0f + previousView.animate().apply { + duration = transitionDuration + alpha(0f) + withEndAction { + clearPreviousView() + } + } + newView.animate().apply { + duration = transitionDuration + alpha(1f) + } + } + } else { + // We don't want to show the placeholder if something is currently displayed. + // There is one exception - when we're displaying a different placeholder. + if ((firstView.drawable != null && !firstView.isPlaceholder) || secondView.drawable != null) { + return@postAtFrontOfQueue + } + + firstView + .recycleView() + ?.apply { + // The current target is already bound to the view. We don't want to cancel it in that case. + if (this != target) { + clear(requestManager) + } + } + + configureView(firstView, target, resource, isPlaceholder) + if (transitionDuration > 0) { + firstView.bringToFront() + firstView.alpha = 0f + secondView.isVisible = false + firstView.animate().apply { + duration = transitionDuration + alpha(1f) + } + } + } + + // If our image is animated, we want to see if autoplay is disabled. If it is, we should + // stop the animation as soon as the resource is ready. Placeholders should not follow this + // value since the intention is almost certainly to display the animation (i.e. a spinner) + if (resource is Animatable && !isPlaceholder && !autoplay) { + resource.stop() + } + } + + private fun configureView( + view: ExpoImageView, + target: ImageViewWrapperTarget, + resource: Drawable, + isPlaceholder: Boolean + ) { + view.let { + it.setImageDrawable(resource) + + it.isPlaceholder = isPlaceholder + it.placeholderContentFit = target.placeholderContentFit ?: ContentFit.ScaleDown + copyProps(it) + + it.isVisible = true + + it.currentTarget = target + + // The view isn't layout when it's invisible. + // Therefore, we have to set the correct size manually. + it.layout(0, 0, width, height) + + it.applyTransformationMatrix() + } + target.isUsed = true + + if (resource is Animatable) { + resource.start() + } + } + + private fun getBestSource(sources: List): SourceMap? { + if (sources.isEmpty()) { + return null + } + + if (sources.size == 1) { + return sources.first() + } + + val targetPixelCount = width * height + if (targetPixelCount == 0) { + return null + } + + var bestSource: SourceMap? = null + var bestFit = Double.MAX_VALUE + + sources.forEach { + val fit = abs(1 - (it.pixelCount / targetPixelCount)) + if (fit < bestFit) { + bestFit = fit + bestSource = it + } + } + + return bestSource + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + rerenderIfNeeded( + shouldRerenderBecauseOfResize = allowDownscaling && + contentFit != ContentFit.Fill && + contentFit != ContentFit.None + ) + } + + private fun createPropOptions(): RequestOptions { + return RequestOptions() + .apply { + priority(this@ExpoImageViewWrapper.priority.toGlidePriority()) + + if (cachePolicy != CachePolicy.MEMORY_AND_DISK && cachePolicy != CachePolicy.MEMORY) { + skipMemoryCache(true) + } + if (cachePolicy == CachePolicy.NONE || cachePolicy == CachePolicy.MEMORY) { + diskCacheStrategy(DiskCacheStrategy.NONE) + } + + blurRadius?.let { + transform(BlurTransformation(min(it, 25), 4)) + } + } + } + + fun onViewDestroys() { + firstView.setImageDrawable(null) + secondView.setImageDrawable(null) + + requestManager.clear(firstTarget) + requestManager.clear(secondTarget) + } + + internal fun rerenderIfNeeded(shouldRerenderBecauseOfResize: Boolean = false) { + val bestSource = bestSource + val bestPlaceholder = bestPlaceholder + + val sourceToLoad = bestSource?.createGlideModel(context) + val placeholder = bestPlaceholder?.createGlideModel(context) + // We only clean the image when the source is set to null and we don't have a placeholder or the view is empty. + if (width == 0 || height == 0 || (bestSource == null || sourceToLoad == null) && placeholder == null) { + firstView.recycleView() + secondView.recycleView() + + requestManager.clear(firstTarget) + requestManager.clear(secondTarget) + + shouldRerender = false + loadedSource = null + transformationMatrixChanged = false + clearViewBeforeChangingSource = false + return + } + + val shouldRerender = sourceToLoad != loadedSource || shouldRerender || (sourceToLoad == null && placeholder != null) + if (shouldRerender || shouldRerenderBecauseOfResize) { + if (clearViewBeforeChangingSource) { + val activeView = if (firstView.drawable != null) { + firstView + } else { + secondView + } + + activeView + .recycleView() + ?.apply { + clear(requestManager) + } + } + + this.shouldRerender = false + loadedSource = sourceToLoad + val options = bestSource?.createOptions(context) + val propOptions = createPropOptions() + + val model = sourceToLoad?.glideData + if (model is GlideUrlWrapper) { + model.progressListener = progressListener + } + + onLoadStart.invoke(Unit) + val newTarget = if (secondTarget.isUsed) { + firstTarget + } else { + secondTarget + } + newTarget.hasSource = sourceToLoad != null + + val downsampleStrategy = if (allowDownscaling) { + ContentFitDownsampleStrategy(newTarget, contentFit) + } else { + DownsampleStrategy.NONE + } + + val request = requestManager + .asDrawable() + .load(model) + .apply { + if (placeholder != null) { + thumbnail(requestManager.load(placeholder.glideData)) + val newPlaceholderContentFit = if (bestPlaceholder.isBlurhash() || bestPlaceholder.isThumbhash()) { + contentFit + } else { + placeholderContentFit + } + newTarget.placeholderContentFit = newPlaceholderContentFit + } + } + .apply { + options?.let { + apply(it) + } + } + .downsample(downsampleStrategy) + .addListener(GlideRequestListener(WeakReference(this))) + .encodeQuality(100) + .apply(propOptions) + + request.into(newTarget) + } else { + // In the case where the source didn't change, but the transformation matrix has to be + // recalculated, we can apply the new transformation right away. + // When the source and the matrix is different, we don't want to do anything. + // We don't want to changed the transformation of the currently displayed image. + // The new matrix will be applied when new resource is loaded. + if (transformationMatrixChanged) { + activeView.applyTransformationMatrix() + } + } + transformationMatrixChanged = false + clearViewBeforeChangingSource = false + } + + init { + val matchParent = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + + layoutParams = matchParent + + firstView.isVisible = true + secondView.isVisible = true + + // We need to add a `FrameLayout` to allow views to overflow. + // With the `LinearLayout` is impossible to render two views on each other. + val layout = FrameLayout(context).apply { + layoutParams = matchParent + addView( + firstView, + matchParent + ) + addView( + secondView, + matchParent + ) + } + + addView(layout, matchParent) + } + + companion object { + private var requestManager: RequestManager? = null + private var appContextRef: WeakReference = WeakReference(null) + private var activityRef: WeakReference = WeakReference(null) + + fun getOrCreateRequestManager( + appContext: AppContext, + activity: Activity + ): RequestManager = synchronized(Companion) { + val cachedRequestManager = requestManager + ?: return createNewRequestManager(activity).also { + requestManager = it + appContextRef = WeakReference(appContext) + activityRef = WeakReference(activity) + } + + // Request manager was created using different activity or app context + if (appContextRef.get() != appContext || activityRef.get() != activity) { + return createNewRequestManager(activity).also { + requestManager = it + appContextRef = WeakReference(appContext) + activityRef = WeakReference(activity) + } + } + + return cachedRequestManager + } + + private fun createNewRequestManager(activity: Activity): RequestManager = Glide.with(activity) + } +} diff --git a/packages/mobile/scripts/postinstall/expo-image/ImageViewWrapperTarget.kt b/packages/mobile/scripts/postinstall/expo-image/ImageViewWrapperTarget.kt new file mode 100644 index 0000000000..0c3f0fa5a9 --- /dev/null +++ b/packages/mobile/scripts/postinstall/expo-image/ImageViewWrapperTarget.kt @@ -0,0 +1,313 @@ +package expo.modules.image + +import android.content.Context +import android.graphics.Point +import android.graphics.drawable.Drawable +import android.util.Log +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.WindowManager +import androidx.annotation.VisibleForTesting +import com.bumptech.glide.RequestManager +import com.bumptech.glide.request.Request +import com.bumptech.glide.request.ThumbnailRequestCoordinator +import com.bumptech.glide.request.target.SizeReadyCallback +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.bumptech.glide.util.Preconditions +import com.bumptech.glide.util.Synthetic +import expo.modules.core.utilities.ifNull +import expo.modules.image.enums.ContentFit +import java.lang.ref.WeakReference +import kotlin.math.max + +/** + * A custom target to provide a smooth transition between multiple drawables. + * It delegates images to the [ExpoImageViewWrapper], where we handle the loaded [Drawable]. + * When the target is cleared, we don't do anything. The [ExpoImageViewWrapper] is responsible for + * clearing bitmaps before freeing targets. That may be error-prone, but that is the only way + * of implementing the transition between bitmaps. + */ +class ImageViewWrapperTarget( + private val imageViewHolder: WeakReference, +) : Target { + /** + * Whether the target has a main, non-placeholder source + */ + var hasSource = false + + /** + * Whether the target is used - the asset loaded by it has been drawn in the image view + */ + var isUsed = false + + /** + * The main source height where -1 means unknown + */ + var sourceHeight = -1 + + /** + * The main source width where -1 means unknown + */ + var sourceWidth = -1 + + /** + * The content fit of the placeholder + */ + var placeholderContentFit: ContentFit? = null + + private var request: Request? = null + private var sizeDeterminer = SizeDeterminer(imageViewHolder) + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + // The image view should always be valid. When the view is deallocated, all targets should be + // canceled. Therefore that code shouldn't be called in that case. Instead of crashing, we + // decided to ignore that. + val imageView = imageViewHolder.get().ifNull { + Log.w("ExpoImage", "The `ExpoImageViewWrapper` was deallocated, but the target wasn't canceled in time.") + return + } + + // The thumbnail and full request are handled in the same way by Glide. + // Here we're checking if the provided resource is the final bitmap or a thumbnail. + val isPlaceholder = if (request is ThumbnailRequestCoordinator) { + (request as? ThumbnailRequestCoordinator) + ?.getPrivateFullRequest() + ?.isComplete == false + } else { + false + } + + imageView.onResourceReady(this, resource, isPlaceholder) + } + + override fun onStart() = Unit + + override fun onStop() = Unit + + override fun onDestroy() = Unit + + override fun onLoadStarted(placeholder: Drawable?) = Unit + + // When loading fails, it's handled by the global listener, therefore that method can be NOOP. + override fun onLoadFailed(errorDrawable: Drawable?) = Unit + + override fun onLoadCleared(placeholder: Drawable?) = Unit + + override fun getSize(cb: SizeReadyCallback) { + // If we can't resolve the image, we just return unknown size. + // It shouldn't happen in a production application, because it means that our view was deallocated. + if (imageViewHolder.get() == null) { + cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + return + } + + sizeDeterminer.getSize(cb) + } + + override fun removeCallback(cb: SizeReadyCallback) { + sizeDeterminer.removeCallback(cb) + } + + override fun setRequest(request: Request?) { + this.request = request + } + + override fun getRequest() = request + + fun clear(requestManager: RequestManager) { + sizeDeterminer.clearCallbacksAndListener() + requestManager.clear(this) + } +} + +// Copied from the Glide codebase. +// We modified that to receive a weak ref to our view instead of strong one. +internal class SizeDeterminer(private val imageViewHolder: WeakReference) { + private val cbs: MutableList = ArrayList() + + @Synthetic + var waitForLayout = false + private var layoutListener: SizeDeterminerLayoutListener? = null + private fun notifyCbs(width: Int, height: Int) { + // One or more callbacks may trigger the removal of one or more additional callbacks, so we + // need a copy of the list to avoid a concurrent modification exception. One place this + // happens is when a full request completes from the in memory cache while its thumbnail is + // still being loaded asynchronously. See #2237. + for (cb in ArrayList(cbs)) { + cb.onSizeReady(width, height) + } + } + + @Synthetic + fun checkCurrentDimens() { + if (cbs.isEmpty()) { + return + } + val currentWidth = targetWidth + val currentHeight = targetHeight + if (!isViewStateAndSizeValid(currentWidth, currentHeight)) { + return + } + notifyCbs(currentWidth, currentHeight) + clearCallbacksAndListener() + } + + fun getSize(cb: SizeReadyCallback) { + val view = imageViewHolder.get() ?: return + + val currentWidth = targetWidth + val currentHeight = targetHeight + if (isViewStateAndSizeValid(currentWidth, currentHeight)) { + cb.onSizeReady(currentWidth, currentHeight) + return + } + + // We want to notify callbacks in the order they were added and we only expect one or two + // callbacks to be added a time, so a List is a reasonable choice. + if (!cbs.contains(cb)) { + cbs.add(cb) + } + if (layoutListener == null) { + val observer = view.viewTreeObserver + layoutListener = SizeDeterminerLayoutListener(this) + observer.addOnPreDrawListener(layoutListener) + } + } + + /** + * The callback may be called anyway if it is removed by another [SizeReadyCallback] or + * otherwise removed while we're notifying the list of callbacks. + * + * + * See #2237. + */ + fun removeCallback(cb: SizeReadyCallback) { + cbs.remove(cb) + } + + fun clearCallbacksAndListener() { + // Keep a reference to the layout attachStateListener and remove it here + // rather than having the observer remove itself because the observer + // we add the attachStateListener to will be almost immediately merged into + // another observer and will therefore never be alive. If we instead + // keep a reference to the attachStateListener and remove it here, we get the + // current view tree observer and should succeed. + val observer = imageViewHolder.get()?.viewTreeObserver + if (observer?.isAlive == true) { + observer.removeOnPreDrawListener(layoutListener) + } + layoutListener = null + cbs.clear() + } + + private fun isViewStateAndSizeValid(width: Int, height: Int): Boolean { + return isDimensionValid(width) && isDimensionValid(height) + } + + private val targetHeight: Int + get() { + val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL + val verticalPadding = view.paddingTop + view.paddingBottom + val layoutParams = view.layoutParams + val layoutParamSize = layoutParams?.height ?: PENDING_SIZE + return getTargetDimen(view.height, layoutParamSize, verticalPadding) + } + private val targetWidth: Int + get() { + val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL + val horizontalPadding = view.paddingLeft + view.paddingRight + val layoutParams = view.layoutParams + val layoutParamSize = layoutParams?.width ?: PENDING_SIZE + return getTargetDimen(view.width, layoutParamSize, horizontalPadding) + } + + private fun getTargetDimen(viewSize: Int, paramSize: Int, paddingSize: Int): Int { + val view = imageViewHolder.get() ?: return Target.SIZE_ORIGINAL + + // We consider the View state as valid if the View has non-null layout params and a non-zero + // layout params width and height. This is imperfect. We're making an assumption that View + // parents will obey their child's layout parameters, which isn't always the case. + val adjustedParamSize = paramSize - paddingSize + if (adjustedParamSize > 0) { + return adjustedParamSize + } + + // Since we always prefer layout parameters with fixed sizes, even if waitForLayout is true, + // we might as well ignore it and just return the layout parameters above if we have them. + // Otherwise we should wait for a layout pass before checking the View's dimensions. + if (waitForLayout && view.isLayoutRequested) { + return PENDING_SIZE + } + + // We also consider the View state valid if the View has a non-zero width and height. This + // means that the View has gone through at least one layout pass. It does not mean the Views + // width and height are from the current layout pass. For example, if a View is re-used in + // RecyclerView or ListView, this width/height may be from an old position. In some cases + // the dimensions of the View at the old position may be different than the dimensions of the + // View in the new position because the LayoutManager/ViewParent can arbitrarily decide to + // change them. Nevertheless, in most cases this should be a reasonable choice. + val adjustedViewSize = viewSize - paddingSize + if (adjustedViewSize > 0) { + return adjustedViewSize + } + + // Finally we consider the view valid if the layout parameter size is set to wrap_content. + // It's difficult for Glide to figure out what to do here. Although Target.SIZE_ORIGINAL is a + // coherent choice, it's extremely dangerous because original images may be much too large to + // fit in memory or so large that only a couple can fit in memory, causing OOMs. If users want + // the original image, they can always use .override(Target.SIZE_ORIGINAL). Since wrap_content + // may never resolve to a real size unless we load something, we aim for a square whose length + // is the largest screen size. That way we're loading something and that something has some + // hope of being downsampled to a size that the device can support. We also log a warning that + // tries to explain what Glide is doing and why some alternatives are preferable. + // Since WRAP_CONTENT is sometimes used as a default layout parameter, we always wait for + // layout to complete before using this fallback parameter (ConstraintLayout among others). + if (!view.isLayoutRequested && paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) { + return getMaxDisplayLength(view.context) + } + + // If the layout parameters are < padding, the view size is < padding, or the layout + // parameters are set to match_parent or wrap_content and no layout has occurred, we should + // wait for layout and repeat. + return PENDING_SIZE + } + + private fun isDimensionValid(size: Int): Boolean { + return size > 0 || size == Target.SIZE_ORIGINAL + } + + private class SizeDeterminerLayoutListener(sizeDeterminer: SizeDeterminer) : ViewTreeObserver.OnPreDrawListener { + private val sizeDeterminerRef: WeakReference + + init { + sizeDeterminerRef = WeakReference(sizeDeterminer) + } + + override fun onPreDraw(): Boolean { + val sizeDeterminer = sizeDeterminerRef.get() + sizeDeterminer?.checkCurrentDimens() + return true + } + } + + companion object { + // Some negative sizes (Target.SIZE_ORIGINAL) are valid, 0 is never valid. + private const val PENDING_SIZE = 0 + + @VisibleForTesting + var maxDisplayLength: Int? = null + + // Use the maximum to avoid depending on the device's current orientation. + private fun getMaxDisplayLength(context: Context): Int { + if (maxDisplayLength == null) { + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = Preconditions.checkNotNull(windowManager).defaultDisplay + val displayDimensions = Point() + display.getSize(displayDimensions) + maxDisplayLength = max(displayDimensions.x, displayDimensions.y) + } + return maxDisplayLength!! + } + } +} diff --git a/yarn.lock b/yarn.lock index a7faa7d410..ac3553a8d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8063,7 +8063,7 @@ __metadata: buffer: ^6.0.3 color: ^4.2.3 delay: ^6.0.0 - expo: ^49.0.21 + expo: ^49.0.22 expo-apple-authentication: ^6.3.0 expo-clipboard: ^4.8.0 expo-crypto: ^12.8.0 @@ -22740,7 +22740,7 @@ __metadata: languageName: node linkType: hard -"expo@npm:^49.0.21": +"expo@npm:^49.0.22": version: 49.0.22 resolution: "expo@npm:49.0.22" dependencies: