Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 51322d6b authored by András Kurucz's avatar András Kurucz
Browse files

BigPicture lazy loading

This CL covers the followign so far:
 - after inflation: read the image header (if we can) and set a placeholder or set the full image (if there is no header)
 - on expansion: read the full image and set it on the ImageView
 - on collapse: free the full image and set the placeholder again
 - throttle the jobs started by the expand/collapse calls

Gated by the statusbar flag bigpicture_notification_lazy_loading.

Bug: 283082473
Test: atest BigPictureIconManagerTest
Change-Id: I1f62516862a7bb8471b09ddb2c546ec696bf83a0
parent a41ed506
Loading
Loading
Loading
Loading
+21 −0
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import android.view.ViewGroup;
import com.android.internal.util.NotificationMessagingUtil;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.flags.FeatureFlags;
import com.android.systemui.flags.Flags;
import com.android.systemui.statusbar.NotificationLockscreenUserManager;
import com.android.systemui.statusbar.NotificationPresenter;
import com.android.systemui.statusbar.NotificationRemoteInputManager;
@@ -38,6 +39,7 @@ import com.android.systemui.statusbar.notification.InflationException;
import com.android.systemui.statusbar.notification.NotificationClicker;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.icon.IconManager;
import com.android.systemui.statusbar.notification.row.BigPictureIconManager;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRowController;
import com.android.systemui.statusbar.notification.row.NotifBindPipeline;
@@ -151,6 +153,7 @@ public class NotificationRowBinderImpl implements NotificationRowBinder {
                                component.getExpandableNotificationRowController();
                        rowController.init(entry);
                        entry.setRowController(rowController);
                        maybeSetBigPictureIconManager(row, component);
                        bindRow(entry, row);
                        updateRow(entry, row);
                        inflateContentViews(entry, params, row, callback);
@@ -165,6 +168,7 @@ public class NotificationRowBinderImpl implements NotificationRowBinder {
            return;
        }
        mLogger.logReleasingViews(entry);
        cancelRunningJobs(entry.getRow());
        final RowContentBindParams params = mRowContentBindStage.getStageParams(entry);
        params.markContentViewsFreeable(FLAG_CONTENT_VIEW_CONTRACTED);
        params.markContentViewsFreeable(FLAG_CONTENT_VIEW_EXPANDED);
@@ -172,6 +176,23 @@ public class NotificationRowBinderImpl implements NotificationRowBinder {
        mRowContentBindStage.requestRebind(entry, null);
    }

    private void maybeSetBigPictureIconManager(ExpandableNotificationRow row,
            ExpandableNotificationRowComponent component) {
        if (mFeatureFlags.isEnabled(Flags.BIGPICTURE_NOTIFICATION_LAZY_LOADING)) {
            row.setBigPictureIconManager(component.getBigPictureIconManager());
        }
    }

    private void cancelRunningJobs(ExpandableNotificationRow row) {
        if (row == null) {
            return;
        }
        BigPictureIconManager iconManager = row.getBigPictureIconManager();
        if (iconManager != null) {
            iconManager.cancelJobs();
        }
    }

    /**
     * Bind row to various controllers and managers. This is only called when the row is first
     * created.
+298 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.notification.row

import android.annotation.WorkerThread
import android.app.ActivityManager
import android.content.Context
import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
import android.util.Dumpable
import android.util.Log
import android.util.Size
import com.android.internal.R
import com.android.internal.widget.NotificationDrawableConsumer
import com.android.internal.widget.NotificationIconManager
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.graphics.ImageLoader
import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.Empty
import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.FullImage
import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.PlaceHolder
import java.io.PrintWriter
import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

private const val TAG = "BigPicImageLoader"
private const val DEBUG = false
private const val FREE_IMAGE_DELAY_MS = 3000L

/**
 * A helper class for [com.android.internal.widget.BigPictureNotificationImageView] to lazy-load
 * images from SysUI. It replaces the placeholder image with the fully loaded one, and vica versa.
 *
 * TODO(b/283082473) move the logs to a [com.android.systemui.log.LogBuffer]
 */
@SuppressWarnings("DumpableNotRegistered")
class BigPictureIconManager
@Inject
constructor(
    private val context: Context,
    private val imageLoader: ImageLoader,
    @Application private val scope: CoroutineScope,
    @Main private val mainDispatcher: CoroutineDispatcher,
    @Background private val bgDispatcher: CoroutineDispatcher
) : NotificationIconManager, Dumpable {

    private var lastLoadingJob: Job? = null
    private var drawableConsumer: NotificationDrawableConsumer? = null
    private var displayedState: DrawableState = Empty(null)
    private var viewShown = false

    private var maxWidth = getMaxWidth()
    private var maxHeight = getMaxHeight()

    /**
     * Called when the displayed state changes of the view.
     *
     * @param shown true if the view is shown, and the image needs to be displayed.
     */
    fun onViewShown(shown: Boolean) {
        log("onViewShown:$shown")

        if (this.viewShown != shown) {
            this.viewShown = shown

            val state = displayedState

            this.lastLoadingJob?.cancel()
            this.lastLoadingJob =
                when {
                    state is Empty && shown -> state.icon?.let(::startLoadingJob)
                    state is PlaceHolder && shown -> startLoadingJob(state.icon)
                    state is FullImage && !shown ->
                        startFreeImageJob(state.icon, state.drawableSize)
                    else -> null
                }
        }
    }

    /**
     * Update the maximum width and height allowed for bitmaps, ex. after a configuration change.
     */
    fun updateMaxImageSizes() {
        log("updateMaxImageSizes")
        maxWidth = getMaxWidth()
        maxHeight = getMaxHeight()
    }

    /** Cancels all currently running jobs. */
    fun cancelJobs() {
        lastLoadingJob?.cancel()
    }

    @WorkerThread
    override fun updateIcon(drawableConsumer: NotificationDrawableConsumer, icon: Icon?): Runnable {
        if (this.drawableConsumer != null && this.drawableConsumer != drawableConsumer) {
            Log.wtf(TAG, "A consumer is already set for this iconManager.")
            return Runnable {}
        }

        if (displayedState.iconSameAs(icon)) {
            // We're already handling this icon, nothing to do here.
            log("skipping updateIcon for consumer:$drawableConsumer with icon:$icon")
            return Runnable {}
        }

        this.drawableConsumer = drawableConsumer
        this.displayedState = Empty(icon)
        this.lastLoadingJob?.cancel()

        val drawable = loadImageOrPlaceHolderSync(icon)

        log("icon updated")

        return Runnable { drawableConsumer.setImageDrawable(drawable) }
    }

    override fun dump(pw: PrintWriter, args: Array<out String>?) {
        pw.println("BigPictureIconManager ${getDebugString()}")
    }

    @WorkerThread
    private fun loadImageOrPlaceHolderSync(icon: Icon?): Drawable? {
        icon ?: return null

        if (viewShown) {
            return loadImageSync(icon)
        }

        return loadPlaceHolderSync(icon) ?: loadImageSync(icon)
    }

    @WorkerThread
    private fun loadImageSync(icon: Icon): Drawable? {
        return imageLoader.loadDrawableSync(icon, context, maxWidth, maxHeight)?.also { drawable ->
            checkPlaceHolderSizeForDrawable(this.displayedState, drawable)
            this.displayedState = FullImage(icon, drawable.intrinsicSize)
        }
    }

    private fun checkPlaceHolderSizeForDrawable(
        displayedState: DrawableState,
        newDrawable: Drawable
    ) {
        if (displayedState is PlaceHolder) {
            val (oldWidth, oldHeight) = displayedState.drawableSize
            val (newWidth, newHeight) = newDrawable.intrinsicSize

            if (oldWidth != newWidth || oldHeight != newHeight) {
                Log.e(
                    TAG,
                    "Mismatch in dimensions, when replacing PlaceHolder " +
                        "$oldWidth X $oldHeight with Drawable $newWidth X $newHeight."
                )
            }
        }
    }

    @WorkerThread
    private fun loadPlaceHolderSync(icon: Icon): Drawable? {
        return imageLoader
            .loadSizeSync(icon, context)
            ?.resizeToMax(maxWidth, maxHeight) // match the dimensions of the fully loaded drawable
            ?.let { size -> createPlaceHolder(size) }
            ?.also { drawable -> this.displayedState = PlaceHolder(icon, drawable.intrinsicSize) }
    }

    private fun startLoadingJob(icon: Icon): Job =
        scope.launch {
            val drawable = withContext(bgDispatcher) { loadImageSync(icon) }
            withContext(mainDispatcher) { drawableConsumer?.setImageDrawable(drawable) }
            log("image loaded")
        }

    private fun startFreeImageJob(icon: Icon, drawableSize: Size): Job =
        scope.launch {
            delay(FREE_IMAGE_DELAY_MS)
            val drawable = createPlaceHolder(drawableSize)
            displayedState = PlaceHolder(icon, drawable.intrinsicSize)
            withContext(mainDispatcher) { drawableConsumer?.setImageDrawable(drawable) }
            log("placeholder loaded")
        }

    private fun createPlaceHolder(size: Size): Drawable {
        return PlaceHolderDrawable(width = size.width, height = size.height)
    }

    private fun isLowRam(): Boolean {
        return ActivityManager.isLowRamDeviceStatic()
    }

    private fun getMaxWidth() =
        context.resources.getDimensionPixelSize(
            if (isLowRam()) {
                R.dimen.notification_big_picture_max_width_low_ram
            } else {
                R.dimen.notification_big_picture_max_width
            }
        )

    private fun getMaxHeight() =
        context.resources.getDimensionPixelSize(
            if (isLowRam()) {
                R.dimen.notification_big_picture_max_height_low_ram
            } else {
                R.dimen.notification_big_picture_max_height
            }
        )

    private fun log(msg: String) {
        if (DEBUG) {
            Log.d(TAG, "$msg state=${getDebugString()}")
        }
    }

    private fun getDebugString() =
        "{ state:$displayedState, hasConsumer:${drawableConsumer != null}, viewShown:$viewShown}"

    private sealed class DrawableState(open val icon: Icon?) {
        data class Empty(override val icon: Icon?) : DrawableState(icon)
        data class PlaceHolder(override val icon: Icon, val drawableSize: Size) :
            DrawableState(icon)
        data class FullImage(override val icon: Icon, val drawableSize: Size) : DrawableState(icon)

        fun iconSameAs(other: Icon?): Boolean {
            val displayedIcon = icon
            return when {
                displayedIcon == null && other == null -> true
                displayedIcon != null && other != null -> displayedIcon.sameAs(other)
                else -> false
            }
        }
    }
}

/**
 * @return an image size that conforms to the maxWidth / maxHeight parameters. It can be the same
 *   instance, if the provided size was already small enough.
 */
private fun Size.resizeToMax(maxWidth: Int, maxHeight: Int): Size {
    if (width <= maxWidth && height <= maxHeight) {
        return this
    }

    // Calculate the scale factor for both dimensions
    val wScale =
        if (maxWidth <= 0) {
            1.0f
        } else {
            maxWidth.toFloat() / width.toFloat()
        }

    val hScale =
        if (maxHeight <= 0) {
            1.0f
        } else {
            maxHeight.toFloat() / height.toFloat()
        }

    // Scale down to the smaller scale factor
    val scale = min(wScale, hScale)
    if (scale < 1.0f) {
        val targetWidth = (width * scale).toInt()
        val targetHeight = (height * scale).toInt()

        return Size(targetWidth, targetHeight)
    }

    return this
}

private val Drawable.intrinsicSize
    get() = Size(/*width=*/ intrinsicWidth, /*height=*/ intrinsicHeight)

private operator fun Size.component1() = width

private operator fun Size.component2() = height
+52 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License
 */

package com.android.systemui.statusbar.notification.row

import android.content.Context
import android.util.AttributeSet
import android.view.View
import com.android.internal.widget.BigPictureNotificationImageView
import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_EXPANDED
import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag
import javax.inject.Inject

class BigPictureLayoutInflaterFactory @Inject constructor() : NotifRemoteViewsFactory {

    override fun instantiate(
        row: ExpandableNotificationRow,
        @InflationFlag layoutType: Int,
        parent: View?,
        name: String,
        context: Context,
        attrs: AttributeSet
    ): View? {
        // Currently the [BigPictureIconManager] only handles one view per notification.
        // Exclude other layout types for now, to make sure that we set the same iconManager
        // on only one [BigPictureNotificationImageView].
        if (layoutType != FLAG_CONTENT_VIEW_EXPANDED) {
            return null
        }

        return when (name) {
            BigPictureNotificationImageView::class.java.name ->
                BigPictureNotificationImageView(context, attrs).also { view ->
                    view.setIconManager(row.bigPictureIconManager)
                }
            else -> null
        }
    }
}
+17 −0
Original line number Diff line number Diff line
@@ -376,6 +376,7 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
    private float mTranslationWhenRemoved;
    private boolean mWasChildInGroupWhenRemoved;
    private NotificationInlineImageResolver mImageResolver;
    private BigPictureIconManager mBigPictureIconManager;
    @Nullable
    private OnExpansionChangedListener mExpansionChangedListener;
    @Nullable
@@ -1355,6 +1356,9 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
        if (mImageResolver != null) {
            mImageResolver.updateMaxImageSizes();
        }
        if (mBigPictureIconManager != null) {
            mBigPictureIconManager.updateMaxImageSizes();
        }
    }

    public void onUiModeChanged() {
@@ -1794,6 +1798,16 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
        return mImageResolver;
    }

    public BigPictureIconManager getBigPictureIconManager() {
        return mBigPictureIconManager;
    }

    public void setBigPictureIconManager(
            BigPictureIconManager bigPictureIconManager) {
        mBigPictureIconManager = bigPictureIconManager;
    }


    /**
     * Resets this view so it can be re-used for an updated notification.
     */
@@ -3687,6 +3701,9 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
                pw.println("no viewState!!!");
            }
            pw.println(getRoundableState().debugString());
            if (mBigPictureIconManager != null) {
                mBigPictureIconManager.dump(pw, args);
            }
            dumpBackgroundView(pw, args);

            int transientViewCount = mChildrenContainer == null
+5 −1
Original line number Diff line number Diff line
@@ -60,12 +60,16 @@ public abstract class NotificationRowModule {
    @Named(NOTIF_REMOTEVIEWS_FACTORIES)
    static Set<NotifRemoteViewsFactory> provideNotifRemoteViewsFactories(
            FeatureFlags featureFlags,
            PrecomputedTextViewFactory precomputedTextViewFactory
            PrecomputedTextViewFactory precomputedTextViewFactory,
            BigPictureLayoutInflaterFactory bigPictureLayoutInflaterFactory
    ) {
        final Set<NotifRemoteViewsFactory> replacementFactories = new HashSet<>();
        if (featureFlags.isEnabled(Flags.PRECOMPUTED_TEXT)) {
            replacementFactories.add(precomputedTextViewFactory);
        }
        if (featureFlags.isEnabled(Flags.BIGPICTURE_NOTIFICATION_LAZY_LOADING)) {
            replacementFactories.add(bigPictureLayoutInflaterFactory);
        }
        return replacementFactories;
    }
}
Loading