AppWidgetManagerCompat.kt

@file:JvmName("AppWidgetManagerCompat")

/*
 * 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.core.widget

import android.appwidget.AppWidgetManager
import android.content.res.Resources
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import android.util.SizeF
import android.widget.RemoteViews
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.core.util.SizeFCompat
import kotlin.math.ceil

/** Returns whether this size is approximately at least as big as [other] in all dimensions. */
internal infix fun SizeFCompat.approxDominates(other: SizeFCompat): Boolean {
    return ceil(width) + 1 >= other.width && ceil(height) + 1 >= other.height
}

internal val SizeFCompat.area: Float
    get() = width * height

/**
 * Updates the app widget with [appWidgetId], creating a [RemoteViews] for each size assigned to
 * the app widget by [AppWidgetManager], invoking [factory] to create each alternative view.
 *
 * This provides ["exact" sizing](https://developer.android.com/guide/topics/appwidgets/layouts#provide-exact-layouts)
 * , which allows you to tailor your app widget appearance to the exact size at which it is
 * displayed. If you are only concerned with a small number of size thresholds, it is preferable
 * to use "responsive" sizing by providing a fixed set of sizes that your app widget supports.
 *
 * As your [factory] may be invoked multiple times, if there is expensive computation of state that
 * is shared among each size, it is recommended to perform that computation before calling this
 * and cache the results as necessary.
 *
 * To handle resizing of your app widget, it is necessary to call this function during both
 * [android.appwidget.AppWidgetProvider.onUpdate] and
 * [android.appwidget.AppWidgetProvider.onAppWidgetOptionsChanged].
 *
 * @param appWidgetId the id of the app widget
 * @param factory a function to create a [RemoteViews] for a given width and height (in dp)
 */
public fun AppWidgetManager.updateAppWidget(
    appWidgetId: Int,
    factory: (SizeFCompat) -> RemoteViews
) {
    updateAppWidget(appWidgetId, createExactSizeAppWidget(this, appWidgetId, factory))
}

/**
 * Creates a [RemoteViews] associated with each size assigned to the app widget by
 * [AppWidgetManager], invoking [factory] to create each alternative view.
 *
 * This provides ["exact" sizing](https://developer.android.com/guide/topics/appwidgets/layouts#provide-exact-layouts)
 * , which allows you to tailor your app widget appearance to the exact size at which it is
 * displayed. If you are only concerned with a small number of size thresholds, it is preferable
 * to use "responsive" sizing by providing a fixed set of sizes that your app widget supports.
 *
 * As your [factory] may be invoked multiple times, if there is expensive computation of state that
 * is shared among each size, it is recommended to perform that computation before calling this
 * and cache the results as necessary.
 *
 * To handle resizing of your app widget, it is necessary to call [AppWidgetManager.updateAppWidget]
 * during both [android.appwidget.AppWidgetProvider.onUpdate] and
 * [android.appwidget.AppWidgetProvider.onAppWidgetOptionsChanged].
 *
 * @param appWidgetManager the [AppWidgetManager] to provide information about [appWidgetId]
 * @param appWidgetId the id of the app widget
 * @param factory a function to create a [RemoteViews] for a given width and height (in dp)
 */
public fun createExactSizeAppWidget(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    appWidgetManager.requireValidAppWidgetId(appWidgetId)
    return when {
        SDK_INT >= 31 -> {
            AppWidgetManagerApi31Impl.createExactSizeAppWidget(
                appWidgetManager,
                appWidgetId,
                factory
            )
        }
        else -> createExactSizeAppWidgetInner(appWidgetManager, appWidgetId, factory)
    }
}

/**
 * Updates the app widget with [appWidgetId], creating a [RemoteViews] for each size provided in
 * [dpSizes].
 *
 * This provides ["responsive" sizing](https://developer.android.com/guide/topics/appwidgets/layouts#provide-responsive-layouts)
 * , which allows for smoother resizing and a more consistent experience across different host
 * configurations.
 *
 * As your [factory] may be invoked multiple times, if there is expensive computation of state that
 * is shared among each size, it is recommended to perform that computation before calling this
 * and cache the results as necessary.
 *
 * To handle resizing of your app widget, it is necessary to call this function during both
 * [android.appwidget.AppWidgetProvider.onUpdate] and
 * [android.appwidget.AppWidgetProvider.onAppWidgetOptionsChanged]. If your app's minSdk is 31 or
 * higher, it is only necessary to call this function during `onUpdate`.
 *
 * @param appWidgetId the id of the app widget
 * @param dpSizes a collection of sizes (in dp) that your app widget supports. Must not be empty or
 * contain more than 16 elements.
 * @param factory a function to create a [RemoteViews] for a given width and height (in dp). It is
 * guaranteed that [factory] will only ever be called with the values provided in [dpSizes].
 */
public fun AppWidgetManager.updateAppWidget(
    appWidgetId: Int,
    dpSizes: Collection<SizeFCompat>,
    factory: (SizeFCompat) -> RemoteViews
) {
    updateAppWidget(appWidgetId, createResponsiveSizeAppWidget(this, appWidgetId, dpSizes, factory))
}

/**
 * Creating a [RemoteViews] associated with each size provided in [dpSizes].
 *
 * This provides ["responsive" sizing](https://developer.android.com/guide/topics/appwidgets/layouts#provide-responsive-layouts)
 * , which allows for smoother resizing and a more consistent experience across different host
 * configurations.
 *
 * As your [factory] may be invoked multiple times, if there is expensive computation of state that
 * is shared among each size, it is recommended to perform that computation before calling this
 * and cache the results as necessary.
 *
 * To handle resizing of your app widget, it is necessary to call [AppWidgetManager.updateAppWidget]
 * during both [android.appwidget.AppWidgetProvider.onUpdate] and
 * [android.appwidget.AppWidgetProvider.onAppWidgetOptionsChanged]. If your app's minSdk is 31 or
 * higher, it is only necessary to call `updateAppWidget` during `onUpdate`.
 *
 * @param appWidgetManager the [AppWidgetManager] to provide information about [appWidgetId]
 * @param appWidgetId the id of the app widget
 * @param dpSizes a collection of sizes (in dp) that your app widget supports. Must not be empty or
 * contain more than 16 elements.
 * @param factory a function to create a [RemoteViews] for a given width and height (in dp). It is
 * guaranteed that [factory] will only ever be called with the values provided in [dpSizes].
 */
public fun createResponsiveSizeAppWidget(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    dpSizes: Collection<SizeFCompat>,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    appWidgetManager.requireValidAppWidgetId(appWidgetId)
    require(dpSizes.isNotEmpty()) { "Sizes cannot be empty" }
    require(dpSizes.size <= 16) { "At most 16 sizes may be provided" }
    return when {
        SDK_INT >= 31 -> AppWidgetManagerApi31Impl.createResponsiveSizeAppWidget(dpSizes, factory)
        else -> createResponsiveSizeAppWidgetInner(appWidgetManager, appWidgetId, dpSizes, factory)
    }
}

private fun AppWidgetManager.requireValidAppWidgetId(appWidgetId: Int) {
    requireNotNull(getAppWidgetInfo(appWidgetId)) { "Invalid app widget id: $appWidgetId" }
}

@RequiresApi(31)
@Suppress("DEPRECATION")
private object AppWidgetManagerApi31Impl {
    @DoNotInline
    fun createExactSizeAppWidget(
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int,
        factory: (SizeFCompat) -> RemoteViews
    ): RemoteViews {
        val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
        val sizes = options.getParcelableArrayList<SizeF>(AppWidgetManager.OPTION_APPWIDGET_SIZES)
        if (sizes.isNullOrEmpty()) {
            Log.w(
                LogTag,
                "App widget SizeF sizes not found in the options bundle, falling back to the " +
                    "min/max sizes"
            )
            return createExactSizeAppWidgetInner(appWidgetManager, appWidgetId, factory)
        }
        return RemoteViews(sizes.associateWith { factory(it.toSizeFCompat()) })
    }

    @DoNotInline
    fun createResponsiveSizeAppWidget(
        dpSizes: Collection<SizeFCompat>,
        factory: (SizeFCompat) -> RemoteViews
    ): RemoteViews {
        return RemoteViews(dpSizes.associate { it.toSizeF() to factory(it) })
    }

    private fun SizeF.toSizeFCompat() = SizeFCompat.toSizeFCompat(this)
}

internal fun createExactSizeAppWidgetInner(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    val (landscapeSize, portraitSize) =
        getSizesFromOptionsBundle(appWidgetManager, appWidgetId)
            ?: run {
                Log.w(
                    LogTag,
                    "App widget sizes not found in the options bundle, falling back to the " +
                        "provider size"
                )
                return createAppWidgetFromProviderInfo(appWidgetManager, appWidgetId, factory)
            }
    return createAppWidget(landscapeSize = landscapeSize, portraitSize = portraitSize, factory)
}

internal fun createResponsiveSizeAppWidgetInner(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    sizes: Collection<SizeFCompat>,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    val minSize = sizes.minByOrNull { it.area } ?: error("Sizes cannot be empty")
    val (landscapeSize, portraitSize) =
        getSizesFromOptionsBundle(appWidgetManager, appWidgetId)
            ?: run {
                Log.w(
                    LogTag,
                    "App widget sizes not found in the options bundle, falling back to the " +
                        "smallest supported size ($minSize)"
                )
                LandscapePortraitSizes(minSize, minSize)
            }
    val effectiveLandscapeSize =
        sizes.filter { landscapeSize approxDominates it }.maxByOrNull { it.area } ?: minSize
    val effectivePortraitSize =
        sizes.filter { portraitSize approxDominates it }.maxByOrNull { it.area } ?: minSize
    return createAppWidget(
        landscapeSize = effectiveLandscapeSize,
        portraitSize = effectivePortraitSize,
        factory
    )
}

private fun createAppWidget(
    landscapeSize: SizeFCompat,
    portraitSize: SizeFCompat,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    return if (landscapeSize == portraitSize) {
        factory(landscapeSize)
    } else {
        RemoteViews(
            /* landscape= */ factory(landscapeSize),
            /* portrait= */ factory(portraitSize)
        )
    }
}

private fun getSizesFromOptionsBundle(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int
): LandscapePortraitSizes? {
    val options = appWidgetManager.getAppWidgetOptions(appWidgetId)

    val portWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, -1)
    val portHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, -1)
    if (portWidthDp < 0 || portHeightDp < 0) return null

    val landWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, -1)
    val landHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, -1)
    if (landWidthDp < 0 || landHeightDp < 0) return null

    return LandscapePortraitSizes(
        landscape = SizeFCompat(landWidthDp.toFloat(), landHeightDp.toFloat()),
        portrait = SizeFCompat(portWidthDp.toFloat(), portHeightDp.toFloat())
    )
}

internal fun createAppWidgetFromProviderInfo(
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    factory: (SizeFCompat) -> RemoteViews
): RemoteViews {
    return factory(appWidgetManager.getSizeFromProviderInfo(appWidgetId))
}

internal fun AppWidgetManager.getSizeFromProviderInfo(appWidgetId: Int): SizeFCompat {
    val providerInfo = getAppWidgetInfo(appWidgetId)
    fun pxToDp(value: Int) = (value / Resources.getSystem().displayMetrics.density)
    val width = pxToDp(providerInfo.minWidth)
    val height = pxToDp(providerInfo.minHeight)
    return SizeFCompat(width, height)
}

internal data class LandscapePortraitSizes(val landscape: SizeFCompat, val portrait: SizeFCompat)

private const val LogTag = "AppWidgetManagerCompat"