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
            )
        }
        SDK_INT >= 16 -> {
            AppWidgetManagerApi16Impl.createExactSizeAppWidget(
                appWidgetManager,
                appWidgetId,
                factory
            )
        }
        else -> createAppWidgetFromProviderInfo(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)
        SDK_INT >= 16 -> {
            AppWidgetManagerApi16Impl.createResponsiveSizeAppWidget(
                appWidgetManager,
                appWidgetId,
                dpSizes,
                factory
            )
        }
        else -> createAppWidgetFromProviderInfo(appWidgetManager, appWidgetId, 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 AppWidgetManagerApi16Impl.createExactSizeAppWidget(
                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)
}

@RequiresApi(16)
private object AppWidgetManagerApi16Impl {
    @DoNotInline
    fun createExactSizeAppWidget(
        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)
    }

    @DoNotInline
    fun createResponsiveSizeAppWidget(
        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"