/*
* 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.view.Gravity
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.DoNotInline
import androidx.annotation.LayoutRes
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.widget.RemoteViewsCompat.setLinearLayoutGravity
import androidx.glance.Emittable
import androidx.glance.EmittableButton
import androidx.glance.EmittableImage
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.lazy.EmittableLazyColumn
import androidx.glance.appwidget.lazy.EmittableLazyListItem
import androidx.glance.appwidget.lazy.EmittableLazyVerticalGrid
import androidx.glance.appwidget.lazy.EmittableLazyVerticalGridListItem
import androidx.glance.appwidget.translators.translateEmittableCheckBox
import androidx.glance.appwidget.translators.translateEmittableCircularProgressIndicator
import androidx.glance.appwidget.translators.translateEmittableImage
import androidx.glance.appwidget.translators.translateEmittableLazyColumn
import androidx.glance.appwidget.translators.translateEmittableLazyListItem
import androidx.glance.appwidget.translators.translateEmittableLazyVerticalGrid
import androidx.glance.appwidget.translators.translateEmittableLazyVerticalGridListItem
import androidx.glance.appwidget.translators.translateEmittableLinearProgressIndicator
import androidx.glance.appwidget.translators.translateEmittableRadioButton
import androidx.glance.appwidget.translators.translateEmittableSwitch
import androidx.glance.appwidget.translators.translateEmittableText
import androidx.glance.layout.Alignment
import androidx.glance.layout.EmittableBox
import androidx.glance.layout.EmittableColumn
import androidx.glance.layout.EmittableRow
import androidx.glance.layout.EmittableSpacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.EmittableText
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
internal fun translateComposition(
context: Context,
appWidgetId: Int,
element: RemoteViewsRoot,
layoutConfiguration: LayoutConfiguration?,
rootViewIndex: Int,
layoutSize: DpSize,
) =
translateComposition(
TranslationContext(
context,
appWidgetId,
context.isRtl,
layoutConfiguration,
itemPosition = -1,
layoutSize = layoutSize,
),
element.children,
rootViewIndex,
)
@VisibleForTesting
internal var forceRtl: Boolean? = null
private val Context.isRtl: Boolean
get() = forceRtl
?: (resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL)
internal fun translateComposition(
translationContext: TranslationContext,
children: List<Emittable>,
rootViewIndex: Int
): RemoteViews {
require(children.size == 1) {
"The root of the tree must have exactly one child. " +
"The normalization of the composition tree failed."
}
val child = children.first()
val remoteViewsInfo = createRootView(translationContext, child.modifier, rootViewIndex)
val rv = remoteViewsInfo.remoteViews
rv.translateChild(translationContext.forRoot(root = remoteViewsInfo), child)
return rv
}
internal data class TranslationContext(
val context: Context,
val appWidgetId: Int,
val isRtl: Boolean,
val layoutConfiguration: LayoutConfiguration?,
val itemPosition: Int,
val isLazyCollectionDescendant: Boolean = false,
val lastViewId: AtomicInteger = AtomicInteger(0),
val parentContext: InsertedViewInfo = InsertedViewInfo(),
val isBackgroundSpecified: AtomicBoolean = AtomicBoolean(false),
val layoutSize: DpSize = DpSize.Zero,
val layoutCollectionViewId: Int = View.NO_ID,
val layoutCollectionItemId: Int = -1,
val canUseSelectableGroup: Boolean = false,
val actionTargetId: Int? = null,
val isAdapterView: Boolean = false,
val isCompoundButton: Boolean = false,
) {
fun nextViewId() = lastViewId.incrementAndGet()
fun forChild(parent: InsertedViewInfo, pos: Int): TranslationContext =
copy(itemPosition = pos, parentContext = parent)
fun forRoot(root: RemoteViewsInfo): TranslationContext =
forChild(pos = 0, parent = root.view)
fun resetViewId(newViewId: Int = 0) = copy(lastViewId = AtomicInteger(newViewId))
fun forLazyCollection(viewId: Int) =
copy(isLazyCollectionDescendant = true, layoutCollectionViewId = viewId)
fun forLazyViewItem(itemId: Int, newViewId: Int = 0) =
copy(lastViewId = AtomicInteger(newViewId), layoutCollectionViewId = itemId)
fun canUseSelectableGroup() = copy(canUseSelectableGroup = true)
fun forActionTargetId(viewId: Int) = copy(actionTargetId = viewId)
fun forAdapterView() = copy(isAdapterView = true)
fun forCompoundButton() = copy(isCompoundButton = true)
}
internal fun RemoteViews.translateChild(
translationContext: TranslationContext,
element: Emittable
) {
when (element) {
is EmittableBox -> translateEmittableBox(translationContext, element)
is EmittableButton -> translateEmittableButton(translationContext, element)
is EmittableRow -> translateEmittableRow(translationContext, element)
is EmittableColumn -> translateEmittableColumn(translationContext, element)
is EmittableText -> translateEmittableText(translationContext, element)
is EmittableLazyListItem -> translateEmittableLazyListItem(translationContext, element)
is EmittableLazyColumn -> translateEmittableLazyColumn(translationContext, element)
is EmittableAndroidRemoteViews -> {
translateEmittableAndroidRemoteViews(translationContext, element)
}
is EmittableCheckBox -> translateEmittableCheckBox(translationContext, element)
is EmittableSpacer -> translateEmittableSpacer(translationContext, element)
is EmittableSwitch -> translateEmittableSwitch(translationContext, element)
is EmittableImage -> translateEmittableImage(translationContext, element)
is EmittableLinearProgressIndicator -> {
translateEmittableLinearProgressIndicator(translationContext, element)
}
is EmittableCircularProgressIndicator -> {
translateEmittableCircularProgressIndicator(translationContext, element)
}
is EmittableLazyVerticalGrid -> {
translateEmittableLazyVerticalGrid(translationContext, element)
}
is EmittableLazyVerticalGridListItem -> {
translateEmittableLazyVerticalGridListItem(translationContext, element)
}
is EmittableRadioButton -> translateEmittableRadioButton(translationContext, element)
else -> {
throw IllegalArgumentException(
"Unknown element type ${element.javaClass.canonicalName}"
)
}
}
}
internal fun remoteViews(translationContext: TranslationContext, @LayoutRes layoutId: Int) =
RemoteViews(translationContext.context.packageName, layoutId)
internal fun Alignment.Horizontal.toGravity(): Int =
when (this) {
Alignment.Horizontal.Start -> Gravity.START
Alignment.Horizontal.End -> Gravity.END
Alignment.Horizontal.CenterHorizontally -> Gravity.CENTER_HORIZONTAL
else -> {
Log.w(GlanceAppWidgetTag, "Unknown horizontal alignment: $this")
Gravity.START
}
}
internal fun Alignment.Vertical.toGravity(): Int =
when (this) {
Alignment.Vertical.Top -> Gravity.TOP
Alignment.Vertical.Bottom -> Gravity.BOTTOM
Alignment.Vertical.CenterVertically -> Gravity.CENTER_VERTICAL
else -> {
Log.w(GlanceAppWidgetTag, "Unknown vertical alignment: $this")
Gravity.TOP
}
}
internal fun Alignment.toGravity() = horizontal.toGravity() or vertical.toGravity()
private fun RemoteViews.translateEmittableBox(
translationContext: TranslationContext,
element: EmittableBox
) {
val viewDef = insertContainerView(
translationContext,
LayoutType.Box,
element.children.size,
element.modifier,
element.contentAlignment.horizontal,
element.contentAlignment.vertical,
)
applyModifiers(
translationContext,
this,
element.modifier,
viewDef
)
element.children.forEach {
it.modifier = it.modifier.then(AlignmentModifier(element.contentAlignment))
}
setChildren(
translationContext,
viewDef,
element.children
)
}
private fun RemoteViews.translateEmittableRow(
translationContext: TranslationContext,
element: EmittableRow
) {
val layoutType = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && element.modifier.isSelectableGroup
) {
LayoutType.RadioRow
} else {
LayoutType.Row
}
val viewDef = insertContainerView(
translationContext,
layoutType,
element.children.size,
element.modifier,
horizontalAlignment = null,
verticalAlignment = element.verticalAlignment,
)
setLinearLayoutGravity(
viewDef.mainViewId,
Alignment(element.horizontalAlignment, element.verticalAlignment).toGravity()
)
applyModifiers(
translationContext.canUseSelectableGroup(),
this,
element.modifier,
viewDef
)
setChildren(
translationContext,
viewDef,
element.children
)
if (element.modifier.isSelectableGroup) checkSelectableGroupChildren(element.children)
}
private fun RemoteViews.translateEmittableColumn(
translationContext: TranslationContext,
element: EmittableColumn
) {
val layoutType = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && element.modifier.isSelectableGroup
) {
LayoutType.RadioColumn
} else {
LayoutType.Column
}
val viewDef = insertContainerView(
translationContext,
layoutType,
element.children.size,
element.modifier,
horizontalAlignment = element.horizontalAlignment,
verticalAlignment = null,
)
setLinearLayoutGravity(
viewDef.mainViewId,
Alignment(element.horizontalAlignment, element.verticalAlignment).toGravity()
)
applyModifiers(
translationContext.canUseSelectableGroup(),
this,
element.modifier,
viewDef
)
setChildren(
translationContext,
viewDef,
element.children
)
if (element.modifier.isSelectableGroup) checkSelectableGroupChildren(element.children)
}
private fun checkSelectableGroupChildren(children: List<Emittable>) {
check(children.count { it is EmittableRadioButton && it.checked } <= 1) {
"When using GlanceModifier.selectableGroup(), no more than one RadioButton " +
"may be checked at a time."
}
}
private fun RemoteViews.translateEmittableAndroidRemoteViews(
translationContext: TranslationContext,
element: EmittableAndroidRemoteViews
) {
val rv = if (element.children.isEmpty()) {
element.remoteViews
} else {
check(element.containerViewId != View.NO_ID) {
"To add children to an `AndroidRemoteViews`, its `containerViewId` must be set."
}
element.remoteViews.copy().apply {
removeAllViews(element.containerViewId)
element.children.forEachIndexed { index, child ->
val rvInfo = createRootView(translationContext, child.modifier, index)
val rv = rvInfo.remoteViews
rv.translateChild(translationContext.forRoot(rvInfo), child)
addChildView(element.containerViewId, rv, index)
}
}
}
val viewDef = insertView(translationContext, LayoutType.Frame, element.modifier)
applyModifiers(translationContext, this, element.modifier, viewDef)
removeAllViews(viewDef.mainViewId)
addChildView(viewDef.mainViewId, rv, stableId = 0)
}
private fun RemoteViews.translateEmittableButton(
translationContext: TranslationContext,
element: EmittableButton
) {
// Separate the button into a wrapper and the text, this allows us to set the color of the text
// background, while maintaining the ripple for the click indicator.
// TODO: add Image button
val content = EmittableText().apply {
text = element.text
style = element.style
maxLines = element.maxLines
modifier =
GlanceModifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp)
.doNotUnsetAction()
}
translateEmittableText(translationContext, content)
}
private fun RemoteViews.translateEmittableSpacer(
translationContext: TranslationContext,
element: EmittableSpacer
) {
val viewDef = insertView(translationContext, LayoutType.Frame, element.modifier)
applyModifiers(translationContext, this, element.modifier, viewDef)
}
// Sets the emittables as children to the view. This first remove any previously added view, the
// add a view per child, with a stable id if of Android S+. Currently the stable id is the index
// of the child in the iterable.
internal fun RemoteViews.setChildren(
translationContext: TranslationContext,
parentDef: InsertedViewInfo,
children: Iterable<Emittable>
) {
children.forEachIndexed { index, child ->
translateChild(
translationContext.forChild(parent = parentDef, pos = index),
child,
)
}
}
/**
* Add stable view if on Android S+, otherwise simply add the view.
*/
internal fun RemoteViews.addChildView(viewId: Int, childView: RemoteViews, stableId: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RemoteViewsTranslatorApi31Impl.addChildView(this, viewId, childView, stableId)
return
}
addView(viewId, childView)
}
/**
* Copy a RemoteViews (the exact method depends on the version of Android)
*/
@Suppress("DEPRECATION") // RemoteViews.clone must be used before Android P.
private fun RemoteViews.copy(): RemoteViews =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
RemoteViewsTranslatorApi28Impl.copyRemoteViews(this)
} else {
clone()
}
@RequiresApi(Build.VERSION_CODES.P)
private object RemoteViewsTranslatorApi28Impl {
@DoNotInline
fun copyRemoteViews(rv: RemoteViews) = RemoteViews(rv)
}
@RequiresApi(Build.VERSION_CODES.S)
private object RemoteViewsTranslatorApi31Impl {
@DoNotInline
fun addChildView(rv: RemoteViews, viewId: Int, childView: RemoteViews, stableId: Int) {
rv.addStableView(viewId, childView, stableId)
}
}