ApplyModifiers.kt

/*
 * Copyright 2021 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 androidx.glance.appwidget

import android.content.Context
import android.os.Build
import android.util.Log
import android.util.TypedValue.COMPLEX_UNIT_DIP
import android.util.TypedValue.COMPLEX_UNIT_PX
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.RemoteViews
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.toArgb
import androidx.core.widget.RemoteViewsCompat.setTextViewHeight
import androidx.core.widget.RemoteViewsCompat.setTextViewWidth
import androidx.core.widget.RemoteViewsCompat.setViewBackgroundColor
import androidx.core.widget.RemoteViewsCompat.setViewBackgroundColorResource
import androidx.core.widget.RemoteViewsCompat.setViewBackgroundResource
import androidx.core.widget.RemoteViewsCompat.setViewClipToOutline
import androidx.glance.AndroidResourceImageProvider
import androidx.glance.BackgroundModifier
import androidx.glance.GlanceModifier
import androidx.glance.Visibility
import androidx.glance.VisibilityModifier
import androidx.glance.action.ActionModifier
import androidx.glance.appwidget.action.applyAction
import androidx.glance.appwidget.unit.DayNightColorProvider
import androidx.glance.layout.HeightModifier
import androidx.glance.layout.PaddingModifier
import androidx.glance.layout.WidthModifier
import androidx.glance.unit.Dimension
import androidx.glance.unit.FixedColorProvider
import androidx.glance.unit.ResourceColorProvider

internal fun applyModifiers(
    translationContext: TranslationContext,
    rv: RemoteViews,
    modifiers: GlanceModifier,
    viewDef: InsertedViewInfo,
) {
    val context = translationContext.context
    var widthModifier: WidthModifier? = null
    var heightModifier: HeightModifier? = null
    var paddingModifiers: PaddingModifier? = null
    var cornerRadius: Dimension? = null
    var visibility = Visibility.Visible
    var actionModifier: ActionModifier? = null
    modifiers.foldIn(Unit) { _, modifier ->
        when (modifier) {
            is ActionModifier -> {
                if (actionModifier != null) {
                    Log.w(
                        GlanceAppWidgetTag,
                        "More than one clickable defined on the same GlanceModifier, " +
                            "only the last one will be used."
                    )
                }
                actionModifier = modifier
            }
            is WidthModifier -> widthModifier = modifier
            is HeightModifier -> heightModifier = modifier
            is BackgroundModifier -> applyBackgroundModifier(context, rv, modifier, viewDef)
            is PaddingModifier -> {
                paddingModifiers = paddingModifiers?.let { it + modifier } ?: modifier
            }
            is VisibilityModifier -> visibility = modifier.visibility
            is CornerRadiusModifier -> cornerRadius = modifier.radius
            is AppWidgetBackgroundModifier -> {
                // This modifier is handled somewhere else.
            }
            else -> {
                Log.w(GlanceAppWidgetTag, "Unknown modifier '$modifier', nothing done.")
            }
        }
    }
    applySizeModifiers(translationContext, rv, widthModifier, heightModifier, viewDef)
    actionModifier?.let { applyAction(translationContext, rv, it.action, viewDef.mainViewId) }
    cornerRadius?.let { applyRoundedCorners(rv, viewDef.mainViewId, it) }
    paddingModifiers?.let { padding ->
        val absolutePadding = padding.toDp(context.resources).toAbsolute(translationContext.isRtl)
        val displayMetrics = context.resources.displayMetrics
        rv.setViewPadding(
            viewDef.mainViewId,
            absolutePadding.left.toPixels(displayMetrics),
            absolutePadding.top.toPixels(displayMetrics),
            absolutePadding.right.toPixels(displayMetrics),
            absolutePadding.bottom.toPixels(displayMetrics)
        )
    }
    rv.setViewVisibility(viewDef.mainViewId, visibility.toViewVisibility())
}

private fun Visibility.toViewVisibility() =
    when (this) {
        Visibility.Visible -> View.VISIBLE
        Visibility.Invisible -> View.INVISIBLE
        Visibility.Gone -> View.GONE
    }

private fun applySizeModifiers(
    translationContext: TranslationContext,
    rv: RemoteViews,
    widthModifier: WidthModifier?,
    heightModifier: HeightModifier?,
    viewDef: InsertedViewInfo
) {
    val context = translationContext.context
    if (viewDef.isSimple) {
        widthModifier?.let { applySimpleWidthModifier(context, rv, it, viewDef.mainViewId) }
        heightModifier?.let { applySimpleHeightModifier(context, rv, it, viewDef.mainViewId) }
        return
    }

    check(Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
        "There is currently no valid use case where a complex view is used on Android S"
    }

    val width = widthModifier?.width
    val height = heightModifier?.height

    if (!(width.isFixed || height.isFixed)) {
        // The sizing view is only present and needed for setting fixed dimensions.
        return
    }

    val useMatchSizeWidth = width is Dimension.Fill || width is Dimension.Expand
    val useMatchSizeHeight = height is Dimension.Fill || height is Dimension.Expand
    val sizeViewLayout = when {
        useMatchSizeWidth && useMatchSizeHeight -> R.layout.size_match_match
        useMatchSizeWidth -> R.layout.size_match_wrap
        useMatchSizeHeight -> R.layout.size_wrap_match
        else -> R.layout.size_wrap_wrap
    }

    val sizeTargetViewId = rv.inflateViewStub(translationContext, R.id.sizeViewStub, sizeViewLayout)

    fun Dimension.Dp.toPixels() = dp.toPixels(context)
    fun Dimension.Resource.toPixels() = context.resources.getDimensionPixelSize(res)
    when (width) {
        is Dimension.Dp -> rv.setTextViewWidth(sizeTargetViewId, width.toPixels())
        is Dimension.Resource -> rv.setTextViewWidth(sizeTargetViewId, width.toPixels())
        Dimension.Expand, Dimension.Fill, Dimension.Wrap, null -> {
        }
    }.let {}
    when (height) {
        is Dimension.Dp -> rv.setTextViewHeight(sizeTargetViewId, height.toPixels())
        is Dimension.Resource -> rv.setTextViewHeight(sizeTargetViewId, height.toPixels())
        Dimension.Expand, Dimension.Fill, Dimension.Wrap, null -> {
        }
    }.let {}
}

internal fun applySimpleWidthModifier(
    context: Context,
    rv: RemoteViews,
    modifier: WidthModifier,
    viewId: Int,
) {
    val width = modifier.width
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
        // Prior to Android S, these layouts already have the appropriate attribute in the xml, so
        // no action is needed.
        if (
            width.resolveDimension(context) in listOf(
                Dimension.Wrap,
                Dimension.Fill,
                Dimension.Expand
            )
        ) {
            return
        }
        throw IllegalArgumentException(
            "Using a width of $width requires a complex layout before API 31"
        )
    }
    // Wrap and Expand are done in XML on Android S+
    if (width in listOf(Dimension.Wrap, Dimension.Expand)) return
    ApplyModifiersApi31Impl.setViewWidth(rv, viewId, width)
}

internal fun applySimpleHeightModifier(
    context: Context,
    rv: RemoteViews,
    modifier: HeightModifier,
    viewId: Int,
) {
    // These layouts already have the appropriate attribute in the xml, so no action is needed.
    val height = modifier.height
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
        // Prior to Android S, these layouts already have the appropriate attribute in the xml, so
        // no action is needed.
        if (
            height.resolveDimension(context) in listOf(
                Dimension.Wrap,
                Dimension.Fill,
                Dimension.Expand
            )
        ) {
            return
        }
        throw IllegalArgumentException(
            "Using a height of $height requires a complex layout before API 31"
        )
    }
    // Wrap and Expand are done in XML on Android S+
    if (height in listOf(Dimension.Wrap, Dimension.Expand)) return
    ApplyModifiersApi31Impl.setViewHeight(rv, viewId, height)
}

private fun applyBackgroundModifier(
    context: Context,
    rv: RemoteViews,
    modifier: BackgroundModifier,
    viewDef: InsertedViewInfo
) {
    val viewId = viewDef.mainViewId
    val imageProvider = modifier.imageProvider
    if (imageProvider != null) {
        if (imageProvider is AndroidResourceImageProvider) {
            rv.setViewBackgroundResource(viewId, imageProvider.resId)
        }
        // Otherwise, the background has been transformed and should be ignored
        // (removing modifiers is not really possible).
        return
    }
    when (val colorProvider = modifier.colorProvider) {
        is FixedColorProvider -> rv.setViewBackgroundColor(viewId, colorProvider.color.toArgb())
        is ResourceColorProvider -> rv.setViewBackgroundColorResource(
            viewId,
            colorProvider.resId
        )
        is DayNightColorProvider -> {
            if (Build.VERSION.SDK_INT >= 31) {
                rv.setViewBackgroundColor(
                    viewId,
                    colorProvider.day.toArgb(),
                    colorProvider.night.toArgb()
                )
            } else {
                rv.setViewBackgroundColor(viewId, colorProvider.resolve(context).toArgb())
            }
        }
        else -> Log.w(GlanceAppWidgetTag, "Unexpected background color modifier: $colorProvider")
    }
}

private val Dimension?.isFixed: Boolean
    get() = when (this) {
        is Dimension.Dp, is Dimension.Resource -> true
        Dimension.Expand, Dimension.Fill, Dimension.Wrap, null -> false
    }

private fun applyRoundedCorners(rv: RemoteViews, viewId: Int, radius: Dimension) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        ApplyModifiersApi31Impl.applyRoundedCorners(rv, viewId, radius)
        return
    }
    Log.w(GlanceAppWidgetTag, "Cannot set the rounded corner of views before Api 31.")
}

@RequiresApi(Build.VERSION_CODES.S)
private object ApplyModifiersApi31Impl {
    @DoNotInline
    fun setViewWidth(rv: RemoteViews, viewId: Int, width: Dimension) {
        when (width) {
            is Dimension.Wrap -> {
                rv.setViewLayoutWidth(viewId, WRAP_CONTENT.toFloat(), COMPLEX_UNIT_PX)
            }
            is Dimension.Expand -> rv.setViewLayoutWidth(viewId, 0f, COMPLEX_UNIT_PX)
            is Dimension.Dp -> rv.setViewLayoutWidth(viewId, width.dp.value, COMPLEX_UNIT_DIP)
            is Dimension.Resource -> rv.setViewLayoutWidthDimen(viewId, width.res)
            Dimension.Fill -> {
                rv.setViewLayoutWidth(viewId, MATCH_PARENT.toFloat(), COMPLEX_UNIT_PX)
            }
        }.let {}
    }

    @DoNotInline
    fun setViewHeight(rv: RemoteViews, viewId: Int, height: Dimension) {
        when (height) {
            is Dimension.Wrap -> {
                rv.setViewLayoutHeight(viewId, WRAP_CONTENT.toFloat(), COMPLEX_UNIT_PX)
            }
            is Dimension.Expand -> rv.setViewLayoutHeight(viewId, 0f, COMPLEX_UNIT_PX)
            is Dimension.Dp -> rv.setViewLayoutHeight(viewId, height.dp.value, COMPLEX_UNIT_DIP)
            is Dimension.Resource -> rv.setViewLayoutHeightDimen(viewId, height.res)
            Dimension.Fill -> {
                rv.setViewLayoutHeight(viewId, MATCH_PARENT.toFloat(), COMPLEX_UNIT_PX)
            }
        }.let {}
    }

    @DoNotInline
    fun applyRoundedCorners(rv: RemoteViews, viewId: Int, radius: Dimension) {
        rv.setViewClipToOutline(viewId, true)
        when (radius) {
            is Dimension.Dp -> {
                rv.setViewOutlinePreferredRadius(viewId, radius.dp.value, COMPLEX_UNIT_DIP)
            }
            is Dimension.Resource -> {
                rv.setViewOutlinePreferredRadiusDimen(viewId, radius.res)
            }
            else -> error("Rounded corners should not be ${radius.javaClass.canonicalName}")
        }
    }
}