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.color.DayNightColorProvider
import androidx.glance.layout.HeightModifier
import androidx.glance.layout.PaddingModifier
import androidx.glance.layout.WidthModifier
import androidx.glance.semantics.SemanticsModifier
import androidx.glance.semantics.SemanticsProperties
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
    var enabled: EnabledModifier? = null
    var clipToOutline: ClipToOutlineModifier? = null
    var semanticsModifier: SemanticsModifier? = 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.
            }
            is SelectableGroupModifier -> {
                if (!translationContext.canUseSelectableGroup) {
                    error(
                        "GlanceModifier.selectableGroup() can only be used on Row or Column " +
                        "composables."
                    )
                }
            }
            is AlignmentModifier -> {
                // This modifier is handled somewhere else.
            }
            is ClipToOutlineModifier -> clipToOutline = modifier
            is EnabledModifier -> enabled = modifier
            is SemanticsModifier -> semanticsModifier = modifier
            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)
        )
    }
    clipToOutline?.let {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            rv.setBoolean(viewDef.mainViewId, "setClipToOutline", true)
        }
    }
    enabled?.let {
        rv.setBoolean(viewDef.mainViewId, "setEnabled", it.enabled)
    }
    semanticsModifier?.let { semantics ->
        val contentDescription: List<String>? =
            semantics.configuration.getOrNull(SemanticsProperties.ContentDescription)
        if (contentDescription != null) {
            rv.setContentDescription(viewDef.mainViewId, contentDescription.joinToString())
        }
    }
    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 & Sv2
    if (Build.VERSION.SDK_INT < 33 &&
        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 & Sv2
    if (Build.VERSION.SDK_INT < 33 &&
        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.getColor(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}")
        }
    }
}