TileServiceViewAdapter.kt

/*
 * Copyright 2023 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.wear.tiles.tooling

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.wear.protolayout.DeviceParametersBuilders
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.StateBuilders
import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.RequestBuilders.ResourcesRequest
import androidx.wear.tiles.renderer.TileRenderer
import androidx.wear.tiles.timeline.TilesTimelineCache
import androidx.wear.tiles.tooling.preview.TilePreviewData
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import kotlin.math.roundToInt

private const val TOOLS_NS_URI = "http://schemas.android.com/tools"

/**
 * A method extending functionality of [Class.getDeclaredMethod] allowing to finding the methods
 * (including non-public) declared in the superclasses as well.
 */
internal fun Class<out Any>.findMethod(
    name: String,
    vararg parameterTypes: Class<out Any>
): Method {
    var currentClass: Class<out Any>? = this
    while (currentClass != null) {
        try {
            return currentClass.getDeclaredMethod(name, *parameterTypes)
        } catch (_: NoSuchMethodException) { }
        currentClass = currentClass.superclass
    }
    val methodSignature = "$name(${parameterTypes.joinToString { ", " }})"
    throw NoSuchMethodException(
        "Could not find method $methodSignature neither in $this nor in its superclasses.")
}

/**
 * View adapter that renders a tile preview from a [TilePreviewData]. The preview data is found by
 * invoking the method whose FQN is set in the `tools:tilePreviewMethodFqn` attribute.
 */
internal class TileServiceViewAdapter(context: Context, attrs: AttributeSet) :
    FrameLayout(context, attrs) {

    private val executor = ContextCompat.getMainExecutor(context)

    init {
        init(attrs)
    }

    private fun init(attrs: AttributeSet) {
        val tilePreviewMethodFqn = attrs.getAttributeValue(TOOLS_NS_URI, "tilePreviewMethodFqn")
            ?: return

        init(tilePreviewMethodFqn)
    }

    internal fun init(tilePreviewMethodFqn: String) {
        val tilePreview = getTilePreview(tilePreviewMethodFqn) ?: return
        lateinit var tileRenderer: TileRenderer
        tileRenderer = TileRenderer(context, executor) { newState ->
            tileRenderer.previewTile(tilePreview, newState)
        }
        tileRenderer.previewTile(tilePreview)
    }

    private fun TileRenderer.previewTile(
        tilePreview: TilePreviewData,
        currentState: StateBuilders.State? = null
    ) {
        val deviceParams = context.buildDeviceParameters()
        val tileRequest = RequestBuilders.TileRequest
            .Builder()
            .apply {
                currentState?.let { setCurrentState(it) }
            }
            .setDeviceConfiguration(deviceParams)
            .build()

        val tile = tilePreview.onTileRequest(tileRequest).also { tile ->
            tile.state?.let { setState(it.keyToValueMapping) }
        }
        val layout = tile.tileTimeline?.getCurrentLayout() ?: return

        val resourcesRequest = ResourcesRequest.Builder()
            .setDeviceConfiguration(deviceParams)
            .setVersion(tile.resourcesVersion)
            .build()
        val resources = tilePreview.onTileResourceRequest(resourcesRequest)

        val inflateFuture = inflateAsync(layout, resources, this@TileServiceViewAdapter)
        inflateFuture.addListener({
            inflateFuture.get()?.let {
                (it.layoutParams as LayoutParams).gravity = Gravity.CENTER
            }
        }, executor)
    }

    @SuppressLint("BanUncheckedReflection")
    internal fun getTilePreview(tilePreviewMethodFqn: String): TilePreviewData? {
        val className = tilePreviewMethodFqn.substringBeforeLast('.')
        val methodName = tilePreviewMethodFqn.substringAfterLast('.')

        val methods = Class.forName(className).declaredMethods.filter { it.name == methodName }
        methods.firstOrNull {
            it.parameterCount == 1 && it.parameters.first().type == Context::class.java
        }?.let { methodWithContextParameter ->
            return invokeTilePreviewMethod(methodWithContextParameter, context)
        }

        return methods.firstOrNull {
            it.name == methodName && it.parameterCount == 0
        }?.let { methodWithoutContextParameter ->
            return invokeTilePreviewMethod(methodWithoutContextParameter)
        }
    }

    @SuppressLint("BanUncheckedReflection")
    private fun invokeTilePreviewMethod(method: Method, vararg args: Any?): TilePreviewData? {
        method.isAccessible = true
        return if (Modifier.isStatic(method.modifiers)) {
            method.invoke(null, *args) as? TilePreviewData
        } else {
            val instance = method.declaringClass.getConstructor().newInstance()
            method.invoke(instance, *args) as? TilePreviewData
        }
    }
}

internal fun TimelineBuilders.Timeline?.getCurrentLayout(): LayoutElementBuilders.Layout? {
    val now = System.currentTimeMillis()
    return this?.let {
        val cache = TilesTimelineCache(it)
        cache.findTileTimelineEntryForTime(now) ?: cache.findClosestTileTimelineEntry(now)
    }?.layout
}

/**
 * Creates an instance of [DeviceParametersBuilders.DeviceParameters] from the [Context].
 */
internal fun Context.buildDeviceParameters(): DeviceParametersBuilders.DeviceParameters {
    val displayMetrics = resources.displayMetrics
    val isScreenRound = resources.configuration.isScreenRound
    return DeviceParametersBuilders.DeviceParameters.Builder()
        .setScreenWidthDp(
            (displayMetrics.widthPixels / displayMetrics.density).roundToInt())
        .setScreenHeightDp(
            (displayMetrics.heightPixels / displayMetrics.density).roundToInt())
        .setScreenDensity(displayMetrics.density)
        .setScreenShape(
            if (isScreenRound) DeviceParametersBuilders.SCREEN_SHAPE_ROUND
            else DeviceParametersBuilders.SCREEN_SHAPE_RECT
        )
        .setDevicePlatform(DeviceParametersBuilders.DEVICE_PLATFORM_WEAR_OS)
        .setFontScale(resources.configuration.fontScale)
        .build()
}