diff --git a/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt b/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
index 90ebd8a5..01fc9c72 100644
--- a/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
+++ b/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt
@@ -328,9 +328,14 @@ class Paparazzi @JvmOverloads constructor(
     try {
       areCallbacksRunningField.setBoolean(choreographer, true)

-      // https://android.googlesource.com/platform/frameworks/layoutlib/+/d58aa4703369e109b24419548f38b422d5a44738/bridge/src/com/android/layoutlib/bridge/BridgeRenderSession.java#171
-      // BridgeRenderSession.executeCallbacks aggressively tears down the main Looper and BridgeContext, so we call the static delegates ourselves.
-      Handler_Delegate.executeCallbacks()
+      // Avoid ConcurrentModificationException in
+      // RenderAction.currentContext.sessionInteractiveData.handlerMessageQueue.runnablesMap which is a WeakHashMap
+      // https://android.googlesource.com/platform/tools/adt/idea/+/c331c9b2f4334748c55c29adec3ad1cd67e45df2/designer/src/com/android/tools/idea/uibuilder/scene/LayoutlibSceneManager.java#1558
+      synchronized(this) {
+        // https://android.googlesource.com/platform/frameworks/layoutlib/+/d58aa4703369e109b24419548f38b422d5a44738/bridge/src/com/android/layoutlib/bridge/BridgeRenderSession.java#171
+        // BridgeRenderSession.executeCallbacks aggressively tears down the main Looper and BridgeContext, so we call the static delegates ourselves.
+        Handler_Delegate.executeCallbacks()
+      }
       val currentTimeMs = SystemClock_Delegate.uptimeMillis()
       val choreographerCallbacks =
         RenderAction.getCurrentContext().sessionInteractiveData.choreographerCallbacks

ConcurrentModificationException when running a lot of tests at once
**Description**
We have a lot of modules, so we run our tests in parallel all at once. We have about 10 modules using paparazzi. Occasionally, the tests for Paparazzi will fail with:

```
java.util.ConcurrentModificationException
	at java.base/java.util.WeakHashMap.forEach(WeakHashMap.java:1031)
	at com.android.layoutlib.bridge.util.HandlerMessageQueue.extractFirst(HandlerMessageQueue.java:73)
	at android.os.Handler_Delegate.executeCallbacks(Handler_Delegate.java:95)
	at app.cash.paparazzi.Paparazzi.withTime(Paparazzi.kt:330)
	at app.cash.paparazzi.Paparazzi.takeSnapshots(Paparazzi.kt:295)
	at app.cash.paparazzi.Paparazzi.snapshot(Paparazzi.kt:211)
	at app.cash.paparazzi.Paparazzi.snapshot(Paparazzi.kt:204)
	at app.cash.paparazzi.Paparazzi.snapshot$default(Paparazzi.kt:195)
```

Seems as though this line:
```kotlin
Handler_Delegate.executeCallbacks()
```
Cannot handle multiple calls at once. My best suggestion is to synchronize this block so that it allows the concurrency to succeed.

**Steps to Reproduce**
Can't share my work repo. I imagine a project with a few modules, and running `testDebugUnitTest` on the top level project.

**Expected behavior**
Should successfully run.

**Additional information:**
- Paparazzi Version: 1.0.0
- OS: macOS Monterey 12.6
- Compile SDK: 32
- Gradle Version: 7.5
- Android Gradle Plugin Version: 7.2.1

JDK:
```
openjdk 11.0.16.1 2022-08-12 LTS
OpenJDK Runtime Environment Corretto-11.0.16.9.1 (build 11.0.16.1+9-LTS)
OpenJDK 64-Bit Server VM Corretto-11.0.16.9.1 (build 11.0.16.1+9-LTS, mixed mode)
```

/*
 * Copyright (C) 2014 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 app.cash.paparazzi

import android.content.res.Configuration
import com.android.ide.common.rendering.api.HardwareConfig
import com.android.ide.common.resources.configuration.CountryCodeQualifier
import com.android.ide.common.resources.configuration.DensityQualifier
import com.android.ide.common.resources.configuration.FolderConfiguration
import com.android.ide.common.resources.configuration.KeyboardStateQualifier
import com.android.ide.common.resources.configuration.LayoutDirectionQualifier
import com.android.ide.common.resources.configuration.LocaleQualifier
import com.android.ide.common.resources.configuration.NavigationMethodQualifier
import com.android.ide.common.resources.configuration.NetworkCodeQualifier
import com.android.ide.common.resources.configuration.NightModeQualifier
import com.android.ide.common.resources.configuration.ScreenDimensionQualifier
import com.android.ide.common.resources.configuration.ScreenOrientationQualifier
import com.android.ide.common.resources.configuration.ScreenRatioQualifier
import com.android.ide.common.resources.configuration.ScreenSizeQualifier
import com.android.ide.common.resources.configuration.TextInputMethodQualifier
import com.android.ide.common.resources.configuration.TouchScreenQualifier
import com.android.ide.common.resources.configuration.UiModeQualifier
import com.android.ide.common.resources.configuration.VersionQualifier
import com.android.resources.Density
import com.android.resources.Keyboard
import com.android.resources.KeyboardState
import com.android.resources.LayoutDirection
import com.android.resources.Navigation
import com.android.resources.NightMode
import com.android.resources.ScreenOrientation
import com.android.resources.ScreenRatio
import com.android.resources.ScreenRound
import com.android.resources.ScreenSize
import com.android.resources.TouchScreen
import com.android.resources.UiMode
import com.google.android.collect.Maps
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.util.Properties

/**
 * Provides [FolderConfiguration] and [HardwareConfig] for various devices. Also provides utility
 * methods to parse `build.prop` and `attrs.xml` to generate the appropriate maps.
 *
 * Defaults are for a Nexus 4 device.
 */
data class DeviceConfig(
  val screenHeight: Int = 1280,
  val screenWidth: Int = 768,
  val xdpi: Int = 320,
  val ydpi: Int = 320,
  val orientation: ScreenOrientation = ScreenOrientation.PORTRAIT,
  val uiMode: UiMode = UiMode.NORMAL,
  val nightMode: NightMode = NightMode.NOTNIGHT,
  val density: Density = Density.XHIGH,
  val fontScale: Float = 1f,
  val layoutDirection: LayoutDirection = LayoutDirection.LTR,
  val locale: String? = null,
  val ratio: ScreenRatio = ScreenRatio.NOTLONG,
  val size: ScreenSize = ScreenSize.NORMAL,
  val keyboard: Keyboard = Keyboard.NOKEY,
  val touchScreen: TouchScreen = TouchScreen.FINGER,
  val keyboardState: KeyboardState = KeyboardState.SOFT,
  val softButtons: Boolean = true,
  val navigation: Navigation = Navigation.NONAV,
  val screenRound: com.android.resources.ScreenRound? = null,
  val released: String = "November 13, 2012"
) {
  val folderConfiguration: FolderConfiguration
    get() = FolderConfiguration.createDefault()
      .apply {
        densityQualifier = DensityQualifier(density)
        navigationMethodQualifier = NavigationMethodQualifier(navigation)
        screenDimensionQualifier = when {
          screenWidth > screenHeight -> ScreenDimensionQualifier(screenWidth, screenHeight)
          else -> ScreenDimensionQualifier(screenHeight, screenWidth)
        }
        screenRatioQualifier = ScreenRatioQualifier(ratio)
        screenSizeQualifier = ScreenSizeQualifier(size)
        textInputMethodQualifier = TextInputMethodQualifier(keyboard)
        touchTypeQualifier = TouchScreenQualifier(touchScreen)
        keyboardStateQualifier = KeyboardStateQualifier(keyboardState)
        screenOrientationQualifier = ScreenOrientationQualifier(orientation)

        updateScreenWidthAndHeight()
        uiModeQualifier = UiModeQualifier(uiMode)
        nightModeQualifier = NightModeQualifier(nightMode)
        countryCodeQualifier = CountryCodeQualifier()
        layoutDirectionQualifier = LayoutDirectionQualifier(layoutDirection)
        networkCodeQualifier = NetworkCodeQualifier()
        localeQualifier = if (locale != null) LocaleQualifier.getQualifier(locale) else LocaleQualifier(LocaleQualifier.FAKE_VALUE)
        versionQualifier = VersionQualifier()
      }

  val hardwareConfig: HardwareConfig
    get() = HardwareConfig(
      screenWidth, screenHeight, density, xdpi.toFloat(), ydpi.toFloat(), size,
      orientation, screenRound, softButtons
    )

  val uiModeMask: Int
    get() {
      val nightMask = if (nightMode == NightMode.NIGHT) {
        Configuration.UI_MODE_NIGHT_YES
      } else {
        Configuration.UI_MODE_NIGHT_NO
      }
      val typeMask = when (uiMode) {
        UiMode.NORMAL -> Configuration.UI_MODE_TYPE_NORMAL
        UiMode.DESK -> Configuration.UI_MODE_TYPE_DESK
        UiMode.CAR -> Configuration.UI_MODE_TYPE_CAR
        UiMode.TELEVISION -> Configuration.UI_MODE_TYPE_TELEVISION
        UiMode.APPLIANCE -> Configuration.UI_MODE_TYPE_APPLIANCE
        UiMode.WATCH -> Configuration.UI_MODE_TYPE_WATCH
        UiMode.VR_HEADSET -> Configuration.UI_MODE_TYPE_VR_HEADSET
      }
      return nightMask or typeMask
    }

  /**
   * Device specs per:
   * https://android.googlesource.com/platform/tools/base/+/mirror-goog-studio-master-dev/sdklib/src/main/java/com/android/sdklib/devices/nexus.xml
   *
   * Release dates obtained from Wikipedia.
   */

  companion object {
    @JvmField
    val NEXUS_4 = DeviceConfig()

    @JvmField
    val NEXUS_5 = DeviceConfig(
      screenHeight = 1920,
      screenWidth = 1080,
      xdpi = 445,
      ydpi = 445,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.XXHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 31, 2013"
    )

    @JvmField
    val NEXUS_7 = DeviceConfig(
      screenHeight = 1920,
      screenWidth = 1200,
      xdpi = 323,
      ydpi = 323,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.XHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.LARGE,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "July 26, 2013"
    )

    @JvmField
    val NEXUS_10 = DeviceConfig(
      screenHeight = 1600,
      screenWidth = 2560,
      xdpi = 300,
      ydpi = 300,
      orientation = ScreenOrientation.LANDSCAPE,
      density = Density.XHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.XLARGE,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "November 13, 2012"
    )

    @JvmField
    val NEXUS_5_LAND = DeviceConfig(
      screenHeight = 1080,
      screenWidth = 1920,
      xdpi = 445,
      ydpi = 445,
      orientation = ScreenOrientation.LANDSCAPE,
      density = Density.XXHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 31, 2013"
    )

    @JvmField
    val NEXUS_7_2012 = DeviceConfig(
      screenHeight = 1280,
      screenWidth = 800,
      xdpi = 195,
      ydpi = 200,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.TV,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.LARGE,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "July 13, 2012"
    )

    @JvmField
    val PIXEL_C = DeviceConfig(
      screenHeight = 1800,
      screenWidth = 2560,
      xdpi = 308,
      ydpi = 308,
      orientation = ScreenOrientation.LANDSCAPE,
      density = Density.XHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.XLARGE,
      keyboard = Keyboard.QWERTY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "December 8, 2015"
    )

    @JvmField
    val PIXEL = DeviceConfig(
      screenHeight = 1920,
      screenWidth = 1080,
      xdpi = 440,
      ydpi = 440,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_420,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 20, 2016"
    )

    @JvmField
    val PIXEL_XL = DeviceConfig(
      screenHeight = 2560,
      screenWidth = 1440,
      xdpi = 534,
      ydpi = 534,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_560,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 20, 2016"
    )

    @JvmField
    val PIXEL_2 = DeviceConfig(
      screenHeight = 1920,
      screenWidth = 1080,
      xdpi = 442,
      ydpi = 443,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_420,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 19, 2017"
    )

    @JvmField
    val PIXEL_2_XL = DeviceConfig(
      screenHeight = 2880,
      screenWidth = 1440,
      xdpi = 537,
      ydpi = 537,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_560,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 19, 2017"
    )

    @JvmField
    val PIXEL_3 = DeviceConfig(
      screenHeight = 2160,
      screenWidth = 1080,
      xdpi = 442,
      ydpi = 442,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_440,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 18, 2018"
    )

    @JvmField
    val PIXEL_3_XL = DeviceConfig(
      screenHeight = 2960,
      screenWidth = 1440,
      xdpi = 522,
      ydpi = 522,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_560,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 18, 2018"
    )

    @JvmField
    val PIXEL_3A = DeviceConfig(
      screenHeight = 2220,
      screenWidth = 1080,
      xdpi = 442,
      ydpi = 444,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_440,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "May 7, 2019"
    )

    @JvmField
    val PIXEL_3A_XL = DeviceConfig(
      screenHeight = 2160,
      screenWidth = 1080,
      xdpi = 397,
      ydpi = 400,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_400,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "May 7, 2019"
    )

    @JvmField
    val PIXEL_4 = DeviceConfig(
      screenHeight = 2280,
      screenWidth = 1080,
      xdpi = 444,
      ydpi = 444,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_440,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 24, 2019"
    )

    @JvmField
    val PIXEL_4_XL = DeviceConfig(
      screenHeight = 3040,
      screenWidth = 1440,
      xdpi = 537,
      ydpi = 537,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_560,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 24, 2019"
    )

    @JvmField
    val PIXEL_4A = DeviceConfig(
      screenHeight = 2340,
      screenWidth = 1080,
      xdpi = 442,
      ydpi = 444,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_440,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "August 20, 2020"
    )

    @JvmField
    val PIXEL_5 = DeviceConfig(
      screenHeight = 2340,
      screenWidth = 1080,
      xdpi = 442,
      ydpi = 444,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_440,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 15, 2020"
    )

    // https://android.googlesource.com/platform/tools/base/+/mirror-goog-studio-master-dev/sdklib/src/main/java/com/android/sdklib/devices/wear.xml
    @JvmField
    val WEAR_OS_SMALL_ROUND: DeviceConfig = DeviceConfig(
      screenHeight = 384,
      screenWidth = 384,
      xdpi = 320,
      ydpi = 320,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.XHIGH,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.SMALL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.HIDDEN,
      softButtons = false,
      navigation = Navigation.NONAV,
      screenRound = ScreenRound.ROUND,
      released = "June 7, 2014"
    )

    // https://android.googlesource.com/platform/tools/base/+/mirror-goog-studio-master-dev/sdklib/src/main/java/com/android/sdklib/devices/wear.xml
    @JvmField
    val WEAR_OS_SQUARE: DeviceConfig = DeviceConfig(
      screenHeight = 280,
      screenWidth = 280,
      xdpi = 240,
      ydpi = 240,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.HIGH,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.SMALL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.HIDDEN,
      softButtons = false,
      navigation = Navigation.NONAV,
      screenRound = ScreenRound.NOTROUND,
      released = "June 7, 2014"
    )

    // https://www.techidence.com/galaxy-watch4-features-reviews-and-price/
    val GALAXY_WATCH4_CLASSIC_LARGE: DeviceConfig = DeviceConfig(
      screenHeight = 454,
      screenWidth = 454,
      xdpi = 320,
      ydpi = 320,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.XHIGH,
      ratio = ScreenRatio.NOTLONG,
      size = ScreenSize.SMALL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.HIDDEN,
      softButtons = false,
      navigation = Navigation.NONAV,
      screenRound = ScreenRound.ROUND,
      released = "October 15, 2020"
    )

    @JvmField
    val PIXEL_6 = DeviceConfig(
      screenHeight = 2400,
      screenWidth = 1080,
      xdpi = 406,
      ydpi = 411,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_420,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 28, 2021"
    )

    @JvmField
    val PIXEL_6_PRO = DeviceConfig(
      screenHeight = 3120,
      screenWidth = 1440,
      xdpi = 512,
      ydpi = 512,
      orientation = ScreenOrientation.PORTRAIT,
      density = Density.DPI_560,
      ratio = ScreenRatio.LONG,
      size = ScreenSize.NORMAL,
      keyboard = Keyboard.NOKEY,
      touchScreen = TouchScreen.FINGER,
      keyboardState = KeyboardState.SOFT,
      softButtons = true,
      navigation = Navigation.NONAV,
      released = "October 28, 2021"
    )

    private const val TAG_ATTR = "attr"
    private const val TAG_ENUM = "enum"
    private const val TAG_FLAG = "flag"
    private const val ATTR_NAME = "name"
    private const val ATTR_VALUE = "value"

    @Throws(IOException::class)
    fun loadProperties(path: File): Map<String, String> {
      val p = Properties()
      val map = Maps.newHashMap<String, String>()
      p.load(FileInputStream(path))
      for (key in p.stringPropertyNames()) {
        map[key] = p.getProperty(key)
      }
      return map
    }

    @Throws(IOException::class, XmlPullParserException::class)
    fun getEnumMap(path: File): Map<String, Map<String, Int>> {
      val map = mutableMapOf<String, MutableMap<String, Int>>()

      val xmlPullParser = XmlPullParserFactory.newInstance()
        .newPullParser()
      xmlPullParser.setInput(FileInputStream(path), null)
      var eventType = xmlPullParser.eventType
      var attr: String? = null
      while (eventType != XmlPullParser.END_DOCUMENT) {
        if (eventType == XmlPullParser.START_TAG) {
          if (TAG_ATTR == xmlPullParser.name) {
            attr = xmlPullParser.getAttributeValue(null, ATTR_NAME)
          } else if (TAG_ENUM == xmlPullParser.name || TAG_FLAG == xmlPullParser.name) {
            val name = xmlPullParser.getAttributeValue(null, ATTR_NAME)
            val value = xmlPullParser.getAttributeValue(null, ATTR_VALUE)
            // Integer.decode cannot handle "ffffffff", see JDK issue 6624867
            val i = (java.lang.Long.decode(value) as Long).toInt()
            require(attr != null)
            var attributeMap: MutableMap<String, Int>? = map[attr]
            if (attributeMap == null) {
              attributeMap = Maps.newHashMap()
              map[attr] = attributeMap
            }
            attributeMap!![name] = i
          }
        } else if (eventType == XmlPullParser.END_TAG) {
          if (TAG_ATTR == xmlPullParser.name) {
            attr = null
          }
        }
        eventType = xmlPullParser.next()
      }

      return map
    }
  }
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

import java.io.File
import java.io.FileNotFoundException
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Locale
import kotlin.io.path.exists

data class Environment(
  val platformDir: String,
  val appTestDir: String,
  val resDir: String,
  val assetsDir: String,
  val packageName: String,
  val compileSdkVersion: Int,
  val resourcePackageNames: List<String>
) {
  init {
    val platformDirPath = Path.of(platformDir)
    if (!platformDirPath.exists()) {
      val elements = platformDirPath.nameCount
      val platform = platformDirPath.subpath(elements - 1, elements)
      val platformVersion = platform.toString().split("-").last()
      throw FileNotFoundException("Missing platform version $platformVersion. Install with sdkmanager --install \"platforms;$platform\"")
    }
  }
}

@Suppress("unused")
fun androidHome() = System.getenv("ANDROID_SDK_ROOT")
  ?: System.getenv("ANDROID_HOME")
  ?: androidSdkPath()

fun detectEnvironment(): Environment {
  checkInstalledJvm()

  val resourcesFile = File(System.getProperty("paparazzi.test.resources"))
  val configLines = resourcesFile.readLines()

  val appTestDir = Paths.get(System.getProperty("paparazzi.build.dir"))
  val androidHome = Paths.get(androidHome())
  return Environment(
    platformDir = androidHome.resolve(configLines[3]).toString(),
    appTestDir = appTestDir.toString(),
    resDir = appTestDir.resolve(configLines[1]).toString(),
    assetsDir = appTestDir.resolve(configLines[4]).toString(),
    packageName = configLines[0],
    compileSdkVersion = configLines[2].toInt(),
    resourcePackageNames = configLines[5].split(",")
  )
}

private fun androidSdkPath(): String {
  val osName = System.getProperty("os.name").lowercase(Locale.US)
  val sdkPathDir = if (osName.startsWith("windows")) {
    "\\AppData\\Local\\Android\\Sdk"
  } else if (osName.startsWith("mac")) {
    "/Library/Android/sdk"
  } else {
    "/Android/Sdk"
  }
  val homeDir = System.getProperty("user.home")
  return homeDir + sdkPathDir
}

private fun checkInstalledJvm() {
  val feature = try {
    // Runtime#version() only available as of Java 9.
    val version = Runtime::class.java.getMethod("version").invoke(null)
    // Runtime.Version#feature() only available as of Java 10.
    version.javaClass.getMethod("feature").invoke(version) as Int
  } catch (e: NoSuchMethodException) {
    -1
  }

  if (feature < 11) {
    throw IllegalStateException(
      "Unsupported JRE detected! Please install and run Paparazzi test suites on JDK 11+."
    )
  }
}

package app.cash.paparazzi

object Flags {
  const val DEBUG_LINKED_OBJECTS = "app.cash.paparazzi.debug.linked.objects"
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

import app.cash.paparazzi.SnapshotHandler.FrameHandler
import app.cash.paparazzi.internal.PaparazziJson
import com.google.common.base.CharMatcher
import okio.BufferedSink
import okio.HashingSink
import okio.blackholeSink
import okio.buffer
import okio.sink
import okio.source
import org.jcodec.api.awt.AWTSequenceEncoder
import java.awt.image.BufferedImage
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import javax.imageio.ImageIO

/**
 * Creates an HTML report that avoids writing files that have already been written.
 *
 * Images and videos are named by hashes of their contents. Paparazzi won't write two images or videos with the same
 * contents. Note that the images/ directory includes the individual frames of each video.
 *
 * Runs are named by their date.
 *
 * ```
 * images
 *   088c60580f06efa95c37fd8e754074729ee74a06.png
 *   93f9a81cb594280f4b3898d90dfad8c8ea969b01.png
 *   22d37abd0841ba2a8d0bd635954baf7cbfaa269b.png
 *   a4769e43cc5901ef28c0d46c46a44ea6429cbccc.png
 * videos
 *   d1cddc5da2224053f2af51f4e69a76de4e61fc41.mov
 * runs
 *   20190626002322_b9854e.js
 *   20190626002345_b1e882.js
 * index.html
 * index.js
 * paparazzi.js
 * ```
 */
class HtmlReportWriter @JvmOverloads constructor(
  private val runName: String = defaultRunName(),
  private val rootDirectory: File = File("${System.getProperty("paparazzi.build.dir", "build")}/reports/paparazzi"),
  snapshotRootDirectory: File = File("src/test/snapshots")
) : SnapshotHandler {
  private val runsDirectory: File = File(rootDirectory, "runs")
  private val imagesDirectory: File = File(rootDirectory, "images")
  private val videosDirectory: File = File(rootDirectory, "videos")

  private val goldenImagesDirectory = File(snapshotRootDirectory, "images")
  private val goldenVideosDirectory = File(snapshotRootDirectory, "videos")

  private val shots = mutableListOf<Snapshot>()

  private val isRecording: Boolean =
    System.getProperty("paparazzi.test.record")?.toBoolean() == true

  init {
    runsDirectory.mkdirs()
    imagesDirectory.mkdirs()
    videosDirectory.mkdirs()
    writeStaticFiles()
    writeRunJs()
    writeIndexJs()
  }

  override fun newFrameHandler(
    snapshot: Snapshot,
    frameCount: Int,
    fps: Int
  ): FrameHandler {
    return object : FrameHandler {
      val hashes = mutableListOf<String>()

      override fun handle(image: BufferedImage) {
        hashes += writeImage(image)
      }

      override fun close() {
        if (hashes.isEmpty()) return

        val shot = if (hashes.size == 1) {
          val original = File(imagesDirectory, "${hashes[0]}.png")
          if (isRecording) {
            val goldenFile = File(goldenImagesDirectory, snapshot.toFileName("_", "png"))
            original.copyTo(goldenFile, overwrite = true)
          }
          snapshot.copy(file = original.toJsonPath())
        } else {
          val hash = writeVideo(hashes, fps)

          if (isRecording) {
            for ((index, frameHash) in hashes.withIndex()) {
              val originalFrame = File(imagesDirectory, "$frameHash.png")
              val frameSnapshot = snapshot.copy(name = "${snapshot.name} $index")
              val goldenFile = File(goldenImagesDirectory, frameSnapshot.toFileName("_", "png"))
              if (!goldenFile.exists()) {
                originalFrame.copyTo(goldenFile)
              }
            }
          }
          val original = File(videosDirectory, "$hash.mov")
          if (isRecording) {
            val goldenFile = File(goldenVideosDirectory, snapshot.toFileName("_", "mov"))
            if (!goldenFile.exists()) {
              original.copyTo(goldenFile)
            }
          }
          snapshot.copy(file = original.toJsonPath())
        }

        shots += shot
      }
    }
  }

  /** Returns the hash of the image. */
  private fun writeImage(image: BufferedImage): String {
    val hash = hash(image)
    val file = File(imagesDirectory, "$hash.png")
    if (!file.exists()) {
      file.writeAtomically(image)
    }
    return hash
  }

  /** Returns a SHA-1 hash of the pixels of [image]. */
  private fun hash(image: BufferedImage): String {
    val hashingSink = HashingSink.sha1(blackholeSink())
    hashingSink.buffer().use { sink ->
      for (y in 0 until image.height) {
        for (x in 0 until image.width) {
          sink.writeInt(image.getRGB(x, y))
        }
      }
    }
    return hashingSink.hash.hex()
  }

  private fun writeVideo(
    frameHashes: List<String>,
    fps: Int
  ): String {
    val hash = hash(frameHashes)
    val file = File(videosDirectory, "$hash.mov")
    if (!file.exists()) {
      val tmpFile = File(videosDirectory, "$hash.mov.tmp")
      val encoder = AWTSequenceEncoder.createSequenceEncoder(tmpFile, fps)
      for (frameHash in frameHashes) {
        val frame = ImageIO.read(File(imagesDirectory, "$frameHash.png"))
        encoder.encodeImage(frame)
      }
      encoder.finish()
      tmpFile.renameTo(file)
    }
    return hash
  }

  /** Returns a SHA-1 hash of [lines]. */
  private fun hash(lines: List<String>): String {
    val hashingSink = HashingSink.sha1(blackholeSink())
    hashingSink.buffer().use { sink ->
      for (hash in lines) {
        sink.writeUtf8(hash)
        sink.writeUtf8("\n")
      }
    }
    return hashingSink.hash.hex()
  }

  /** Release all resources and block until everything has been written to the file system. */
  override fun close() {
    writeRunJs()
  }

  /**
   * Emits the all runs index, which reads like JSON with an executable header.
   *
   * ```
   * window.all_runs = [
   *   "20190319153912aaab",
   *   "20190319153917bcfe"
   * ];
   * ```
   */
  private fun writeIndexJs() {
    val runNames = mutableListOf<String>()
    val runs = runsDirectory.list().sorted()
    for (run in runs) {
      if (run.endsWith(".js")) {
        runNames += run.substring(0, run.length - 3)
      }
    }

    File(rootDirectory, "index.js").writeAtomically {
      writeUtf8("window.all_runs = ")
      PaparazziJson.listOfStringsAdapter.toJson(this, runNames)
      writeUtf8(";")
    }
  }

  /**
   * Emits a run index, which reads like JSON with an executable header.
   *
   * ```
   * window.runs["20190319153912aaab"] = [
   *   {
   *     "name": "loading",
   *     "testName": "app.cash.CelebrityTest#testSettings",
   *     "timestamp": "2019-03-20T10:27:43Z",
   *     "tags": ["redesign"],
   *     "file": "loading.png"
   *   },
   *   {
   *     "name": "error",
   *     "testName": "app.cash.CelebrityTest#testSettings",
   *     "timestamp": "2019-03-20T10:27:43Z",
   *     "tags": ["redesign"],
   *     "file": "error.png"
   *   }
   * ];
   * ```
   */
  private fun writeRunJs() {
    val runJs = File(runsDirectory, "${runName.sanitizeForFilename()}.js")
    runJs.writeAtomically {
      writeUtf8("window.runs[\"$runName\"] = ")
      PaparazziJson.listOfShotsAdapter.toJson(this, shots)
      writeUtf8(";")
    }
  }

  private fun writeStaticFiles() {
    for (staticFile in listOf("index.html", "paparazzi.js")) {
      File(rootDirectory, staticFile).writeAtomically {
        writeAll(HtmlReportWriter::class.java.classLoader.getResourceAsStream(staticFile).source())
      }
    }
  }

  private fun File.writeAtomically(bufferedImage: BufferedImage) {
    val tmpFile = File(parentFile, "$name.tmp")
    ImageIO.write(bufferedImage, "PNG", tmpFile)
    delete()
    tmpFile.renameTo(this)
  }

  private fun File.writeAtomically(writerAction: BufferedSink.() -> Unit) {
    val tmpFile = File(parentFile, "$name.tmp")
    tmpFile.sink()
      .buffer()
      .use { sink ->
        sink.writerAction()
      }
    delete()
    tmpFile.renameTo(this)
  }

  private fun File.toJsonPath(): String = relativeTo(rootDirectory).invariantSeparatorsPath
}

internal fun defaultRunName(): String {
  val now = Date()
  val timestamp = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).format(now)
  val token = UUID.randomUUID().toString().substring(0, 6)
  return "${timestamp}_$token"
}

internal val filenameSafeChars = CharMatcher.inRange('a', 'z')
  .or(CharMatcher.inRange('0', '9'))
  .or(CharMatcher.anyOf("_-.~@^()[]{}:;,"))

internal fun String.sanitizeForFilename(): String? {
  return filenameSafeChars.negate().replaceFrom(toLowerCase(Locale.US), '_')
}

/*
 * Copyright (C) 2022 Block, Inc.
 *
 * 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 app.cash.paparazzi

import android.animation.Animator.AnimatorListener
import android.animation.ValueAnimator
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

/**
 * Sets animation duration scale to 0 so all animations run instantly. Use this with Paparazzi to
 * skip animations and snapshot their terminal state.
 *
 * Note that animation side effects are still performed, including calls like
 * [AnimatorListener.onAnimationEnd]. This way views from fade-ins and moves are rendered as they
 * do when the animations complete.
 */
class InstantAnimationsRule : TestRule {
  private val getDurationScale = ValueAnimator::class.java.getDeclaredMethod(
    "getDurationScale"
  )

  private val setDurationScale = ValueAnimator::class.java.getDeclaredMethod(
    "setDurationScale",
    Float::class.javaPrimitiveType
  )

  private var durationScale: Float
    get() {
      return getDurationScale.invoke(null) as Float
    }
    set(value) {
      setDurationScale.invoke(null, value)
    }

  override fun apply(base: Statement, description: Description): Statement {
    return object : Statement() {
      override fun evaluate() {
        val scaleBefore = durationScale
        durationScale = 0.0f
        try {
          base.evaluate()
        } finally {
          durationScale = scaleBefore
        }
      }
    }
  }
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

import android.animation.AnimationHandler
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.os.Handler_Delegate
import android.os.SystemClock_Delegate
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.BridgeInflater
import android.view.Choreographer
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
import androidx.annotation.LayoutRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Recomposer
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import app.cash.paparazzi.agent.AgentTestRule
import app.cash.paparazzi.agent.InterceptorRegistrar
import app.cash.paparazzi.internal.ChoreographerDelegateInterceptor
import app.cash.paparazzi.internal.EditModeInterceptor
import app.cash.paparazzi.internal.IInputMethodManagerInterceptor
import app.cash.paparazzi.internal.ImageUtils
import app.cash.paparazzi.internal.MatrixMatrixMultiplicationInterceptor
import app.cash.paparazzi.internal.MatrixVectorMultiplicationInterceptor
import app.cash.paparazzi.internal.PaparazziCallback
import app.cash.paparazzi.internal.PaparazziLogger
import app.cash.paparazzi.internal.Renderer
import app.cash.paparazzi.internal.ResourcesInterceptor
import app.cash.paparazzi.internal.ServiceManagerInterceptor
import app.cash.paparazzi.internal.SessionParamsBuilder
import app.cash.paparazzi.internal.parsers.LayoutPullParser
import com.android.ide.common.rendering.api.RenderSession
import com.android.ide.common.rendering.api.Result
import com.android.ide.common.rendering.api.Result.Status.ERROR_UNKNOWN
import com.android.ide.common.rendering.api.SessionParams
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
import com.android.internal.lang.System_Delegate
import com.android.layoutlib.bridge.Bridge
import com.android.layoutlib.bridge.Bridge.cleanupThread
import com.android.layoutlib.bridge.Bridge.prepareThread
import com.android.layoutlib.bridge.BridgeRenderSession
import com.android.layoutlib.bridge.impl.RenderAction
import com.android.layoutlib.bridge.impl.RenderSessionImpl
import com.android.resources.ScreenRound
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.awt.geom.Ellipse2D
import java.awt.image.BufferedImage
import java.util.Date
import java.util.concurrent.TimeUnit
import kotlin.coroutines.ContinuationInterceptor

class Paparazzi @JvmOverloads constructor(
  private val environment: Environment = detectEnvironment(),
  private val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5,
  private val theme: String = "android:Theme.Material.NoActionBar.Fullscreen",
  private val renderingMode: RenderingMode = RenderingMode.NORMAL,
  private val appCompatEnabled: Boolean = true,
  private val maxPercentDifference: Double = 0.1,
  private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference),
  private val renderExtensions: Set<RenderExtension> = setOf(),
  private val supportsRtl: Boolean = false
) : TestRule {
  private val logger = PaparazziLogger()
  private lateinit var renderSession: RenderSessionImpl
  private lateinit var bridgeRenderSession: RenderSession
  private var testName: TestName? = null

  val layoutInflater: LayoutInflater
    get() = RenderAction.getCurrentContext().getSystemService("layout_inflater") as BridgeInflater

  val resources: Resources
    get() = RenderAction.getCurrentContext().resources

  val context: Context
    get() = RenderAction.getCurrentContext()

  private val contentRoot = """
        |<?xml version="1.0" encoding="utf-8"?>
        |<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        |              android:layout_width="match_parent"
        |              android:layout_height="match_parent"/>
  """.trimMargin()

  override fun apply(
    base: Statement,
    description: Description
  ): Statement {
    val statement = object : Statement() {
      override fun evaluate() {
        prepare(description)
        try {
          base.evaluate()
        } finally {
          close()
          logger.assertNoErrors()
        }
      }
    }

    return if (!isInitialized) {
      registerFontLookupInterceptionIfResourceCompatDetected()
      registerViewEditModeInterception()
      registerMatrixMultiplyInterception()
      registerChoreographerDelegateInterception()
      registerServiceManagerInterception()
      registerIInputMethodManagerInterception()

      val outerRule = AgentTestRule()
      outerRule.apply(statement, description)
    } else {
      statement
    }
  }

  fun prepare(description: Description) {
    forcePlatformSdkVersion(environment.compileSdkVersion)

    val layoutlibCallback =
      PaparazziCallback(logger, environment.packageName, environment.resourcePackageNames)
    layoutlibCallback.initResources()

    testName = description.toTestName()

    if (!isInitialized) {
      renderer = Renderer(environment, layoutlibCallback, logger)
      sessionParamsBuilder = renderer.prepare()
    }

    sessionParamsBuilder = sessionParamsBuilder
      .copy(
        layoutPullParser = LayoutPullParser.createFromString(contentRoot),
        deviceConfig = deviceConfig,
        renderingMode = renderingMode,
        supportsRtl = supportsRtl
      )
      .withTheme(theme)

    val sessionParams = sessionParamsBuilder.build()
    renderSession = createRenderSession(sessionParams)
    prepareThread()
    renderSession.init(sessionParams.timeout)
    Bitmap.setDefaultDensity(DisplayMetrics.DENSITY_DEVICE_STABLE)

    // requires LayoutInflater to be created, which is a side-effect of RenderSessionImpl.init()
    if (appCompatEnabled) {
      initializeAppCompatIfPresent()
    }

    bridgeRenderSession = createBridgeSession(renderSession, renderSession.inflate())
  }

  fun close() {
    testName = null
    renderSession.release()
    bridgeRenderSession.dispose()
    cleanupThread()
    snapshotHandler.close()

    renderer.dumpDelegates()
  }

  fun <V : View> inflate(@LayoutRes layoutId: Int): V = layoutInflater.inflate(layoutId, null) as V

  fun snapshot(name: String? = null, composable: @Composable () -> Unit) {
    val hostView = ComposeView(context)
    // During onAttachedToWindow, AbstractComposeView will attempt to resolve its parent's
    // CompositionContext, which requires first finding the "content view", then using that to
    // find a root view with a ViewTreeLifecycleOwner
    val parent = FrameLayout(context).apply { id = android.R.id.content }
    parent.addView(hostView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    PaparazziComposeOwner.register(parent)
    hostView.setContent(composable)

    try {
      snapshot(parent, name)
    } finally {
      forceReleaseComposeReferenceLeaks()
    }
  }

  @JvmOverloads
  fun snapshot(view: View, name: String? = null, offsetMillis: Long = 0L) {
    takeSnapshots(view, name, TimeUnit.MILLISECONDS.toNanos(offsetMillis), -1, 1)
  }

  @JvmOverloads
  fun gif(
    view: View,
    name: String? = null,
    start: Long = 0L,
    end: Long = 500L,
    fps: Int = 30
  ) {
    // Add one to the frame count so we get the last frame. Otherwise a 1 second, 60 FPS animation
    // our 60th frame will be at time 983 ms, and we want our last frame to be 1,000 ms. This gets
    // us 61 frames for a 1 second animation, 121 frames for a 2 second animation, etc.
    val durationMillis = (end - start).toInt()
    val frameCount = (durationMillis * fps) / 1000 + 1
    val startNanos = TimeUnit.MILLISECONDS.toNanos(start)
    takeSnapshots(view, name, startNanos, fps, frameCount)
  }

  fun unsafeUpdateConfig(
    deviceConfig: DeviceConfig? = null,
    theme: String? = null,
    renderingMode: RenderingMode? = null
  ) {
    require(deviceConfig != null || theme != null || renderingMode != null) {
      "Calling unsafeUpdateConfig requires at least one non-null argument."
    }

    renderSession.release()
    bridgeRenderSession.dispose()
    cleanupThread()

    sessionParamsBuilder = sessionParamsBuilder
      .copy(
        // Required to reset underlying parser stream
        layoutPullParser = LayoutPullParser.createFromString(contentRoot)
      )

    if (deviceConfig != null) {
      sessionParamsBuilder = sessionParamsBuilder.copy(deviceConfig = deviceConfig)
    }

    if (theme != null) {
      sessionParamsBuilder = sessionParamsBuilder.withTheme(theme)
    }

    if (renderingMode != null) {
      sessionParamsBuilder = sessionParamsBuilder.copy(renderingMode = renderingMode)
    }

    val sessionParams = sessionParamsBuilder.build()
    renderSession = createRenderSession(sessionParams)
    prepareThread()
    renderSession.init(sessionParams.timeout)
    Bitmap.setDefaultDensity(DisplayMetrics.DENSITY_DEVICE_STABLE)
    bridgeRenderSession = createBridgeSession(renderSession, renderSession.inflate())
  }

  private fun takeSnapshots(
    view: View,
    name: String?,
    startNanos: Long,
    fps: Int,
    frameCount: Int
  ) {
    val snapshot = Snapshot(name, testName!!, Date())

    val frameHandler = snapshotHandler.newFrameHandler(snapshot, frameCount, fps)
    frameHandler.use {
      val viewGroup = bridgeRenderSession.rootViews[0].viewObject as ViewGroup
      val modifiedView = renderExtensions.fold(view) { view, renderExtension ->
        renderExtension.renderView(view)
      }

      System_Delegate.setBootTimeNanos(0L)
      try {
        withTime(0L) {
          // Initialize the choreographer at time=0.
        }

        viewGroup.addView(modifiedView)
        for (frame in 0 until frameCount) {
          val nowNanos = (startNanos + (frame * 1_000_000_000.0 / fps)).toLong()
          withTime(nowNanos) {
            val result = renderSession.render(true)
            if (result.status == ERROR_UNKNOWN) {
              throw result.exception
            }

            val image = bridgeRenderSession.image
            frameHandler.handle(scaleImage(frameImage(image)))
          }
        }
      } finally {
        viewGroup.removeView(modifiedView)
        AnimationHandler.sAnimatorHandler.set(null)
      }
    }
  }

  private fun withTime(
    timeNanos: Long,
    block: () -> Unit
  ) {
    val frameNanos = TIME_OFFSET_NANOS + timeNanos

    // Execute the block at the requested time.
    System_Delegate.setNanosTime(frameNanos)

    val choreographer = Choreographer.getInstance()
    val areCallbacksRunningField = choreographer::class.java.getDeclaredField("mCallbacksRunning")
    areCallbacksRunningField.isAccessible = true

    try {
      areCallbacksRunningField.setBoolean(choreographer, true)

      // https://android.googlesource.com/platform/frameworks/layoutlib/+/d58aa4703369e109b24419548f38b422d5a44738/bridge/src/com/android/layoutlib/bridge/BridgeRenderSession.java#171
      // BridgeRenderSession.executeCallbacks aggressively tears down the main Looper and BridgeContext, so we call the static delegates ourselves.
      Handler_Delegate.executeCallbacks()
      val currentTimeMs = SystemClock_Delegate.uptimeMillis()
      val choreographerCallbacks =
        RenderAction.getCurrentContext().sessionInteractiveData.choreographerCallbacks
      choreographerCallbacks.execute(currentTimeMs, Bridge.getLog())

      block()
    } catch (e: Throwable) {
      Bridge.getLog().error("broken", "Failed executing Choreographer#doFrame", e, null, null)
      throw e
    } finally {
      areCallbacksRunningField.setBoolean(choreographer, false)
    }
  }

  private fun createRenderSession(sessionParams: SessionParams): RenderSessionImpl {
    val renderSession = RenderSessionImpl(sessionParams)
    renderSession.setElapsedFrameTimeNanos(0L)
    RenderSessionImpl::class.java
      .getDeclaredField("mFirstFrameExecuted")
      .apply {
        isAccessible = true
        set(renderSession, true)
      }
    return renderSession
  }

  private fun createBridgeSession(
    renderSession: RenderSessionImpl,
    result: Result
  ): BridgeRenderSession {
    try {
      val bridgeSessionClass = Class.forName("com.android.layoutlib.bridge.BridgeRenderSession")
      val constructor =
        bridgeSessionClass.getDeclaredConstructor(RenderSessionImpl::class.java, Result::class.java)
      constructor.isAccessible = true
      return constructor.newInstance(renderSession, result) as BridgeRenderSession
    } catch (e: Exception) {
      throw RuntimeException(e)
    }
  }

  private fun frameImage(image: BufferedImage): BufferedImage {
    if (deviceConfig.screenRound == ScreenRound.ROUND) {
      val newImage = BufferedImage(image.width, image.height, image.type)
      val g = newImage.createGraphics()
      g.clip = Ellipse2D.Float(0f, 0f, image.height.toFloat(), image.width.toFloat())
      g.drawImage(image, 0, 0, image.width, image.height, null)
      return newImage
    }

    return image
  }

  private fun scaleImage(image: BufferedImage): BufferedImage {
    val scale = ImageUtils.getThumbnailScale(image)
    return ImageUtils.scale(image, scale, scale)
  }

  private fun Description.toTestName(): TestName {
    val fullQualifiedName = className
    val packageName = fullQualifiedName.substringBeforeLast('.', missingDelimiterValue = "")
    val className = fullQualifiedName.substringAfterLast('.')
    return TestName(packageName, className, methodName)
  }

  private fun forcePlatformSdkVersion(compileSdkVersion: Int) {
    val buildVersionClass = try {
      Paparazzi::class.java.classLoader.loadClass("android.os.Build\$VERSION")
    } catch (e: ClassNotFoundException) {
      // Project unit tests don't load Android platform code
      return
    }
    buildVersionClass
      .getFieldReflectively("SDK_INT")
      .setStaticValue(compileSdkVersion)
  }

  private fun initializeAppCompatIfPresent() {
    lateinit var appCompatDelegateClass: Class<*>
    try {
      // See androidx.appcompat.widget.AppCompatDrawableManager#preload()
      val appCompatDrawableManagerClass =
        Class.forName("androidx.appcompat.widget.AppCompatDrawableManager")
      val preloadMethod = appCompatDrawableManagerClass.getMethod("preload")
      preloadMethod.invoke(null)

      appCompatDelegateClass = Class.forName("androidx.appcompat.app.AppCompatDelegate")
    } catch (e: ClassNotFoundException) {
      logger.verbose("AppCompat not found on classpath")
      return
    }

    // See androidx.appcompat.app.AppCompatDelegateImpl#installViewFactory()
    if (layoutInflater.factory == null) {
      layoutInflater.factory2 = object : LayoutInflater.Factory2 {
        override fun onCreateView(
          parent: View?,
          name: String,
          context: Context,
          attrs: AttributeSet
        ): View? {
          val appCompatViewInflaterClass =
            Class.forName("androidx.appcompat.app.AppCompatViewInflater")

          val createViewMethod = appCompatViewInflaterClass
            .getDeclaredMethod(
              "createView",
              View::class.java,
              String::class.java,
              Context::class.java,
              AttributeSet::class.java,
              Boolean::class.javaPrimitiveType,
              Boolean::class.javaPrimitiveType,
              Boolean::class.javaPrimitiveType,
              Boolean::class.javaPrimitiveType
            )
            .apply { isAccessible = true }

          val inheritContext = true
          val readAndroidTheme = true
          val readAppTheme = true
          val wrapContext = true

          val newAppCompatViewInflaterInstance = appCompatViewInflaterClass
            .getConstructor()
            .newInstance()

          return createViewMethod.invoke(
            newAppCompatViewInflaterInstance, parent, name, context, attrs,
            inheritContext, readAndroidTheme, readAppTheme, wrapContext
          ) as View?
        }

        override fun onCreateView(
          name: String,
          context: Context,
          attrs: AttributeSet
        ): View? = onCreateView(null, name, context, attrs)
      }
    } else {
      if (!appCompatDelegateClass.isAssignableFrom(layoutInflater.factory2::class.java)) {
        throw IllegalStateException(
          "The LayoutInflater already has a Factory installed so we can not install AppCompat's"
        )
      }
    }
  }

  /**
   * Current workaround for supporting custom fonts when constructing views in code. This check
   * may be used or expanded to support other cases requiring similar method interception
   * techniques.
   *
   * See:
   * https://github.com/cashapp/paparazzi/issues/119
   * https://issuetracker.google.com/issues/156065472
   */
  private fun registerFontLookupInterceptionIfResourceCompatDetected() {
    try {
      val resourcesCompatClass = Class.forName("androidx.core.content.res.ResourcesCompat")
      InterceptorRegistrar.addMethodInterceptor(
        resourcesCompatClass,
        "getFont",
        ResourcesInterceptor::class.java
      )
    } catch (e: ClassNotFoundException) {
      logger.verbose("ResourceCompat not found on classpath")
    }
  }

  private fun registerServiceManagerInterception() {
    val serviceManager = Class.forName("android.os.ServiceManager")
    InterceptorRegistrar.addMethodInterceptor(
      serviceManager,
      "getServiceOrThrow",
      ServiceManagerInterceptor::class.java
    )
  }

  private fun registerIInputMethodManagerInterception() {
    val iimm = Class.forName("com.android.internal.view.IInputMethodManager\$Stub")
    InterceptorRegistrar.addMethodInterceptor(
      iimm,
      "asInterface",
      IInputMethodManagerInterceptor::class.java
    )
  }

  private fun registerViewEditModeInterception() {
    val viewClass = Class.forName("android.view.View")
    InterceptorRegistrar.addMethodInterceptor(
      viewClass,
      "isInEditMode",
      EditModeInterceptor::class.java
    )
  }

  private fun registerMatrixMultiplyInterception() {
    val matrixClass = Class.forName("android.opengl.Matrix")
    InterceptorRegistrar.addMethodInterceptors(
      matrixClass,
      setOf(
        "multiplyMM" to MatrixMatrixMultiplicationInterceptor::class.java,
        "multiplyMV" to MatrixVectorMultiplicationInterceptor::class.java
      )
    )
  }

  private fun registerChoreographerDelegateInterception() {
    val choreographerDelegateClass = Class.forName("android.view.Choreographer_Delegate")
    InterceptorRegistrar.addMethodInterceptor(
      choreographerDelegateClass,
      "getFrameTimeNanos",
      ChoreographerDelegateInterceptor::class.java
    )
  }

  private fun forceReleaseComposeReferenceLeaks() {
    val snapshotClass = Class.forName("androidx.compose.runtime.snapshots.SnapshotKt")
    val applyObservers = snapshotClass
      .getDeclaredField("applyObservers")
      .apply { isAccessible = true }
      .get(null) as MutableList<*>
    val applyObserver = applyObservers.getOrNull(0)
    if (applyObserver != null) {
      val recomposer = applyObserver.javaClass
        .getDeclaredField("this\$0")
        .apply { isAccessible = true }
        .get(applyObserver) as Recomposer
      val compositionInvalidations = recomposer.javaClass
        .getDeclaredField("compositionInvalidations")
        .apply { isAccessible = true }
        .get(recomposer) as MutableList<*>
      val snapshotInvalidations = recomposer.javaClass
        .getDeclaredField("snapshotInvalidations")
        .apply { isAccessible = true }
        .get(recomposer) as MutableList<*>
      compositionInvalidations.clear()
      snapshotInvalidations.clear()
      applyObservers.clear()
    }

    val dispatcher =
      AndroidUiDispatcher.CurrentThread[ContinuationInterceptor] as AndroidUiDispatcher
    val toRunTrampolined = dispatcher.javaClass
      .getDeclaredField("toRunTrampolined")
      .apply { isAccessible = true }
      .get(dispatcher) as ArrayDeque<*>
    toRunTrampolined.clear()
    // Upon reference leaks being fixed, verify we don't need to reset these values for
    // AndroidUiDispatcher to continue dispatching between tests.
    dispatcher.javaClass
      .getDeclaredField("scheduledTrampolineDispatch")
      .apply { isAccessible = true }
      .set(dispatcher, false)
    dispatcher.javaClass
      .getDeclaredField("scheduledFrameDispatch")
      .apply { isAccessible = true }
      .set(dispatcher, false)
  }

  private class PaparazziComposeOwner private constructor() : LifecycleOwner, SavedStateRegistryOwner {
    private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
    private val savedStateRegistryController = SavedStateRegistryController.create(this)

    override fun getLifecycle(): Lifecycle = lifecycleRegistry
    override val savedStateRegistry: SavedStateRegistry = savedStateRegistryController.savedStateRegistry

    companion object {
      fun register(view: View) {
        val owner = PaparazziComposeOwner()
        owner.savedStateRegistryController.performRestore(null)
        owner.lifecycleRegistry.currentState = Lifecycle.State.CREATED
        ViewTreeLifecycleOwner.set(view, owner)
        view.setViewTreeSavedStateRegistryOwner(owner)
      }
    }
  }

  companion object {
    /** The choreographer doesn't like 0 as a frame time, so start an hour later. */
    internal val TIME_OFFSET_NANOS = TimeUnit.HOURS.toNanos(1L)

    internal lateinit var renderer: Renderer
    internal val isInitialized get() = ::renderer.isInitialized

    internal lateinit var sessionParamsBuilder: SessionParamsBuilder

    private val isVerifying: Boolean =
      System.getProperty("paparazzi.test.verify")?.toBoolean() == true

    private fun determineHandler(maxPercentDifference: Double): SnapshotHandler =
      if (isVerifying) {
        SnapshotVerifier(maxPercentDifference)
      } else {
        HtmlReportWriter()
      }
  }
}

package app.cash.paparazzi

import sun.misc.Unsafe
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.security.AccessController
import java.security.PrivilegedAction

/**
 * Inspired by and ported from:
 * https://github.com/powermock/powermock/commit/fc092c5d7e339d01e079184a2a0e88b5c46fc0e8
 * https://github.com/powermock/powermock/commit/bd92bcc5329c4981cf09dece5c3eafcf92fe49ff
 */
internal fun Class<*>.getFieldReflectively(fieldName: String): Field =
  try {
    this.getDeclaredField(fieldName).also { it.isAccessible = true }
  } catch (e: NoSuchFieldException) {
    throw RuntimeException("Field '$fieldName' was not found in class $name.")
  }

internal fun Field.setStaticValue(value: Any) {
  try {
    this.isAccessible = true
    val isFinalModifierPresent = this.modifiers and Modifier.FINAL == Modifier.FINAL
    if (isFinalModifierPresent) {
      AccessController.doPrivileged<Any?>(
        PrivilegedAction {
          try {
            val unsafe = Unsafe::class.java.getFieldReflectively("theUnsafe").get(null) as Unsafe
            val offset = unsafe.staticFieldOffset(this)
            val base = unsafe.staticFieldBase(this)
            unsafe.setFieldValue(this, base, offset, value)
            null
          } catch (t: Throwable) {
            throw RuntimeException(t)
          }
        }
      )
    } else {
      this.set(null, value)
    }
  } catch (ex: SecurityException) {
    throw RuntimeException(ex)
  } catch (ex: IllegalAccessException) {
    throw RuntimeException(ex)
  } catch (ex: IllegalArgumentException) {
    throw RuntimeException(ex)
  }
}

internal fun Unsafe.setFieldValue(field: Field, base: Any, offset: Long, value: Any) =
  when (field.type) {
    Integer.TYPE -> this.putInt(base, offset, (value as Int))
    java.lang.Short.TYPE -> this.putShort(base, offset, (value as Short))
    java.lang.Long.TYPE -> this.putLong(base, offset, (value as Long))
    java.lang.Byte.TYPE -> this.putByte(base, offset, (value as Byte))
    java.lang.Boolean.TYPE -> this.putBoolean(base, offset, (value as Boolean))
    java.lang.Float.TYPE -> this.putFloat(base, offset, (value as Float))
    java.lang.Double.TYPE -> this.putDouble(base, offset, (value as Double))
    Character.TYPE -> this.putChar(base, offset, (value as Char))
    else -> this.putObject(base, offset, value)
  }

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi

import android.view.View

/**
 * An extension for overlaying additional information on top of each rendered frame.
 */
interface RenderExtension {
  /**
   * Allows this extension to modify the view hierarchy represented by [contentView].
   *
   * Returns the root view of the modified hierarchy.
   */
  fun renderView(contentView: View): View
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

import com.squareup.moshi.JsonClass
import java.util.Date
import java.util.Locale

@JsonClass(generateAdapter = true)
data class Snapshot(
  val name: String?,
  val testName: TestName,
  val timestamp: Date,
  val tags: List<String> = listOf(),
  val file: String? = null
)

internal fun Snapshot.toFileName(
  delimiter: String = "_",
  extension: String
): String {
  val formattedLabel = if (name != null) {
    "$delimiter${name.toLowerCase(Locale.US).replace("\\s".toRegex(), delimiter)}"
  } else {
    ""
  }
  return "${testName.packageName}${delimiter}${testName.className}${delimiter}${testName.methodName}$formattedLabel.$extension"
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

import java.awt.image.BufferedImage
import java.io.Closeable

interface SnapshotHandler : Closeable {
  fun newFrameHandler(
    snapshot: Snapshot,
    frameCount: Int,
    fps: Int
  ): FrameHandler

  interface FrameHandler : Closeable {
    fun handle(image: BufferedImage)
  }
}

/*
 * Copyright (C) 2020 Square, Inc.
 *
 * 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 app.cash.paparazzi

import app.cash.paparazzi.SnapshotHandler.FrameHandler
import app.cash.paparazzi.internal.ImageUtils
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

class SnapshotVerifier @JvmOverloads constructor(
  private val maxPercentDifference: Double,
  rootDirectory: File = File("src/test/snapshots")
) : SnapshotHandler {
  private val imagesDirectory: File = File(rootDirectory, "images")
  private val videosDirectory: File = File(rootDirectory, "videos")

  init {
    imagesDirectory.mkdirs()
    videosDirectory.mkdirs()
  }

  override fun newFrameHandler(
    snapshot: Snapshot,
    frameCount: Int,
    fps: Int
  ): FrameHandler {
    return object : FrameHandler {
      override fun handle(image: BufferedImage) {
        // Note: does not handle videos or its frames at the moment
        val expected = File(imagesDirectory, snapshot.toFileName(extension = "png"))
        if (!expected.exists()) {
          throw AssertionError("File $expected does not exist")
        }

        val goldenImage = ImageIO.read(expected)
        ImageUtils.assertImageSimilar(
          relativePath = expected.path,
          image = image,
          goldenImage = goldenImage,
          maxPercentDifferent = maxPercentDifference
        )
      }

      override fun close() = Unit
    }
  }

  override fun close() = Unit
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi

data class TestName(
  val packageName: String,
  val className: String,
  val methodName: String
)

package app.cash.paparazzi.agent

import net.bytebuddy.agent.ByteBuddyAgent
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

class AgentTestRule : TestRule {
  override fun apply(
    base: Statement,
    description: Description
  ) = object : Statement() {
    override fun evaluate() {
      ByteBuddyAgent.install()
      InterceptorRegistrar.registerMethodInterceptors()
      // interceptors are statically retained until test process finishes, so no need to cleanup
      base.evaluate()
    }
  }
}

package app.cash.paparazzi.agent

import net.bytebuddy.ByteBuddy
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
import net.bytebuddy.implementation.MethodDelegation
import net.bytebuddy.matcher.ElementMatchers

object InterceptorRegistrar {
  private val byteBuddy = ByteBuddy()
  private val methodInterceptors = mutableListOf<() -> Unit>()

  fun addMethodInterceptor(
    receiver: Class<*>,
    methodName: String,
    interceptor: Class<*>
  ) = addMethodInterceptors(receiver, setOf(methodName to interceptor))

  fun addMethodInterceptors(
    receiver: Class<*>,
    methodNamesToInterceptors: Set<Pair<String, Class<*>>>
  ) {
    methodInterceptors += {
      var builder = byteBuddy
        .redefine(receiver)

      methodNamesToInterceptors.forEach {
        builder = builder
          .method(ElementMatchers.named(it.first))
          .intercept(MethodDelegation.to(it.second))
      }

      builder
        .make()
        .load(receiver.classLoader, ClassReloadingStrategy.fromInstalledAgent())
    }
  }

  fun registerMethodInterceptors() {
    methodInterceptors.forEach { it.invoke() }
  }

  fun clearMethodInterceptors() {
    methodInterceptors.clear()
  }
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi.gradle

import app.cash.paparazzi.NATIVE_LIB_VERSION
import app.cash.paparazzi.VERSION
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.LibraryExtension
import com.android.build.gradle.tasks.MergeSourceSetFolders
import com.android.ide.common.symbols.getPackageNameFromManifest
import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.artifacts.type.ArtifactTypeDefinition
import org.gradle.api.attributes.Attribute
import org.gradle.api.file.FileCollection
import org.gradle.api.internal.artifacts.transform.UnzipTransform
import org.gradle.api.logging.LogLevel.LIFECYCLE
import org.gradle.api.plugins.JavaBasePlugin
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.options.Option
import org.gradle.api.tasks.testing.Test
import org.gradle.internal.os.OperatingSystem
import org.gradle.language.base.plugins.LifecycleBasePlugin.VERIFICATION_GROUP
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
import java.util.Locale

@Suppress("unused")
class PaparazziPlugin : Plugin<Project> {
  override fun apply(project: Project) {
    project.afterEvaluate {
      check(!project.plugins.hasPlugin("com.android.application")) {
        error(
          "Currently, Paparazzi only works in Android library -- not application -- modules. " +
            "See https://github.com/cashapp/paparazzi/issues/107"
        )
      }
      check(project.plugins.hasPlugin("com.android.library")) {
        "The Android Gradle library plugin must be applied for Paparazzi to work properly."
      }
    }

    project.plugins.withId("com.android.library") { setupPaparazzi(project) }
  }

  private fun setupPaparazzi(project: Project) {
    project.addTestDependency()
    val nativePlatformFileCollection = project.setupNativePlatformDependency()

    // Create anchor tasks for all variants.
    val verifyVariants = project.tasks.register("verifyPaparazzi")
    val recordVariants = project.tasks.register("recordPaparazzi")

    val variants = project.extensions.getByType(LibraryExtension::class.java)
      .libraryVariants
    variants.all { variant ->
      val variantSlug = variant.name.capitalize(Locale.US)

      val mergeResourcesOutputDir = variant.mergeResourcesProvider.flatMap { it.outputDir }
      val mergeAssetsProvider =
        project.tasks.named("merge${variantSlug}Assets") as TaskProvider<MergeSourceSetFolders>
      val mergeAssetsOutputDir = mergeAssetsProvider.flatMap { it.outputDir }
      val reportOutputDir = project.layout.buildDirectory.dir("reports/paparazzi")
      val snapshotOutputDir = project.layout.projectDirectory.dir("src/test/snapshots")

      val packageAwareArtifacts = project.configurations
        .getByName("${variant.name}RuntimeClasspath")
        .incoming
        .artifactView {
          it.attributes.attribute(ARTIFACT_TYPE_ATTRIBUTE, "android-symbol-with-package-name")
        }
        .artifacts

      val writeResourcesTask = project.tasks.register(
        "preparePaparazzi${variantSlug}Resources",
        PrepareResourcesTask::class.java
      ) { task ->
        val android = project.extensions.getByType(BaseExtension::class.java)
        val nonTransitiveRClassEnabled =
          (project.findProperty("android.nonTransitiveRClass") as? String).toBoolean()

        task.packageName.set(android.packageName())
        task.artifactFiles.from(packageAwareArtifacts.artifactFiles)
        task.nonTransitiveRClassEnabled.set(nonTransitiveRClassEnabled)
        task.mergeResourcesOutput.set(mergeResourcesOutputDir)
        task.targetSdkVersion.set(android.targetSdkVersion())
        task.compileSdkVersion.set(android.compileSdkVersion())
        task.mergeAssetsOutput.set(mergeAssetsOutputDir)
        task.paparazziResources.set(project.layout.buildDirectory.file("intermediates/paparazzi/${variant.name}/resources.txt"))
      }

      val testVariantSlug = variant.unitTestVariant.name.capitalize(Locale.US)

      project.plugins.withType(JavaBasePlugin::class.java) {
        project.tasks.named("compile${testVariantSlug}JavaWithJavac")
          .configure { it.dependsOn(writeResourcesTask) }
      }

      project.plugins.withType(KotlinMultiplatformPluginWrapper::class.java) {
        val multiplatformExtension =
          project.extensions.getByType(KotlinMultiplatformExtension::class.java)
        check(multiplatformExtension.targets.any { target -> target is KotlinAndroidTarget }) {
          "There must be an Android target configured when using Paparazzi with the Kotlin Multiplatform Plugin"
        }
        project.tasks.named("compile${testVariantSlug}KotlinAndroid")
          .configure { it.dependsOn(writeResourcesTask) }
      }

      project.plugins.withType(KotlinAndroidPluginWrapper::class.java) {
        project.tasks.named("compile${testVariantSlug}Kotlin")
          .configure { it.dependsOn(writeResourcesTask) }
      }

      val recordTaskProvider = project.tasks.register("recordPaparazzi$variantSlug", PaparazziTask::class.java) {
        it.group = VERIFICATION_GROUP
      }
      recordVariants.configure { it.dependsOn(recordTaskProvider) }
      val verifyTaskProvider = project.tasks.register("verifyPaparazzi$variantSlug", PaparazziTask::class.java) {
        it.group = VERIFICATION_GROUP
      }
      verifyVariants.configure { it.dependsOn(verifyTaskProvider) }

      val isRecordRun = project.objects.property(Boolean::class.java)
      val isVerifyRun = project.objects.property(Boolean::class.java)

      project.gradle.taskGraph.whenReady { graph ->
        isRecordRun.set(recordTaskProvider.map { graph.hasTask(it) })
        isVerifyRun.set(verifyTaskProvider.map { graph.hasTask(it) })
      }

      val testTaskProvider = project.tasks.named("test$testVariantSlug", Test::class.java) { test ->
        test.systemProperties["paparazzi.test.resources"] =
          writeResourcesTask.flatMap { it.paparazziResources.asFile }.get().path
        test.systemProperties["paparazzi.build.dir"] =
          project.layout.buildDirectory.get().toString()

        test.inputs.dir(mergeResourcesOutputDir)
        test.inputs.dir(mergeAssetsOutputDir)
        test.inputs.files(nativePlatformFileCollection)
          .withPropertyName("paparazzi.nativePlatform")
          .withPathSensitivity(PathSensitivity.NONE)

        test.outputs.dir(reportOutputDir)
        test.outputs.dir(snapshotOutputDir)

        val paparazziProperties = project.properties.filterKeys { it.startsWith("app.cash.paparazzi") }

        @Suppress("ObjectLiteralToLambda")
        // why not a lambda?  See: https://docs.gradle.org/7.2/userguide/validation_problems.html#implementation_unknown
        test.doFirst(object : Action<Task> {
          override fun execute(t: Task) {
            test.systemProperties["paparazzi.platform.data.root"] =
              nativePlatformFileCollection.singleFile.absolutePath
            test.systemProperties["paparazzi.test.record"] = isRecordRun.get()
            test.systemProperties["paparazzi.test.verify"] = isVerifyRun.get()
            test.systemProperties.putAll(paparazziProperties)
          }
        })
      }

      recordTaskProvider.configure { it.dependsOn(testTaskProvider) }
      verifyTaskProvider.configure { it.dependsOn(testTaskProvider) }

      testTaskProvider.configure { test ->
        @Suppress("ObjectLiteralToLambda")
        // why not a lambda?  See: https://docs.gradle.org/7.2/userguide/validation_problems.html#implementation_unknown
        test.doLast(object : Action<Task> {
          override fun execute(t: Task) {
            val uri = reportOutputDir.get().asFile.toPath().resolve("index.html").toUri()
            test.logger.log(LIFECYCLE, "See the Paparazzi report at: $uri")
          }
        })
      }
    }
  }

  open class PaparazziTask : DefaultTask() {
    @Option(option = "tests", description = "Sets test class or method name to be included, '*' is supported.")
    open fun setTestNameIncludePatterns(testNamePattern: List<String>): PaparazziTask {
      project.tasks.withType(Test::class.java).configureEach {
        it.setTestNameIncludePatterns(testNamePattern)
      }
      return this
    }
  }

  private fun Project.setupNativePlatformDependency(): FileCollection {
    val nativePlatformConfiguration = configurations.create("nativePlatform")
    configurations.add(nativePlatformConfiguration)

    val operatingSystem = OperatingSystem.current()
    val nativeLibraryArtifactId = when {
      operatingSystem.isMacOsX -> {
        val osArch = System.getProperty("os.arch").lowercase(Locale.US)
        if (osArch.startsWith("x86")) "macosx" else "macarm"
      }
      operatingSystem.isWindows -> "win"
      else -> "linux"
    }
    nativePlatformConfiguration.dependencies.add(
      dependencies.create("app.cash.paparazzi:layoutlib-native-$nativeLibraryArtifactId:$NATIVE_LIB_VERSION")
    )
    dependencies.registerTransform(UnzipTransform::class.java) { transform ->
      transform.from.attribute(ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.JAR_TYPE)
      transform.to.attribute(ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.DIRECTORY_TYPE)
    }

    return nativePlatformConfiguration
      .incoming
      .artifactView {
        it.attributes.attribute(ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.DIRECTORY_TYPE)
      }
      .files
  }

  private fun Project.addTestDependency() {
    configurations.getByName("testImplementation").dependencies.add(
      dependencies.create("app.cash.paparazzi:paparazzi:$VERSION")
    )
  }

  private fun BaseExtension.packageName(): String {
    namespace?.let { return it }

    // TODO: explore whether AGP 7.x APIs can handle source set filtering
    sourceSets
      .filterNot { it.name.startsWith("androidTest") }
      .map { it.manifest.srcFile }
      .filter { it.exists() }
      .forEach {
        return getPackageNameFromManifest(it)
      }
    throw IllegalStateException("No source sets available")
  }

  private fun BaseExtension.compileSdkVersion(): String {
    return compileSdkVersion!!.substringAfter("android-", DEFAULT_COMPILE_SDK_VERSION.toString())
  }

  private fun BaseExtension.targetSdkVersion(): String {
    return defaultConfig.targetSdkVersion?.apiLevel?.toString()
      ?: DEFAULT_COMPILE_SDK_VERSION.toString()
  }
}

// TODO: Migrate to ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE when Gradle 7.3 is
//  acceptable as the minimum supported version
private val ARTIFACT_TYPE_ATTRIBUTE = Attribute.of("artifactType", String::class.java)
private const val DEFAULT_COMPILE_SDK_VERSION = 32

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi.gradle

import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction

@CacheableTask
abstract class PrepareResourcesTask : DefaultTask() {
  @get:Input
  abstract val packageName: Property<String>

  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  abstract val mergeResourcesOutput: DirectoryProperty

  @get:Input
  abstract val targetSdkVersion: Property<String>

  @get:Input
  abstract val compileSdkVersion: Property<String>

  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  abstract val mergeAssetsOutput: DirectoryProperty

  @get:Input
  abstract val nonTransitiveRClassEnabled: Property<Boolean>

  @get:InputFiles
  @get:PathSensitive(PathSensitivity.NONE)
  abstract val artifactFiles: ConfigurableFileCollection

  @get:OutputFile
  abstract val paparazziResources: RegularFileProperty

  private val buildDirectory = project.layout.buildDirectory

  @TaskAction
  // TODO: figure out why this can't be removed as of Kotlin 1.6+
  @OptIn(ExperimentalStdlibApi::class)
  fun writeResourcesFile() {
    val out = paparazziResources.get().asFile
    out.delete()

    val mainPackage = packageName.get()
    val resourcePackageNames = if (nonTransitiveRClassEnabled.get()) {
      buildList {
        add(mainPackage)
        artifactFiles.files.forEach { file ->
          add(file.useLines { lines -> lines.first() })
        }
      }.joinToString(",")
    } else {
      mainPackage
    }
    val projectDirectory = buildDirectory.get()

    out.bufferedWriter()
      .use {
        it.write(mainPackage)
        it.newLine()
        it.write(projectDirectory.relativize(mergeResourcesOutput.get()))
        it.newLine()
        it.write(targetSdkVersion.get())
        it.newLine()
        // Use compileSdkVersion for system framework resources.
        it.write("platforms/android-${compileSdkVersion.get()}/")
        it.newLine()
        it.write(projectDirectory.relativize(mergeAssetsOutput.get()))
        it.newLine()
        it.write(resourcePackageNames)
        it.newLine()
      }
  }

  private fun Directory.relativize(child: Directory): String {
    return asFile.toPath().relativize(child.asFile.toPath()).toFile().invariantSeparatorsPath
  }
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.accessibility

import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import app.cash.paparazzi.RenderExtension
import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_DESCRIPTION_BACKGROUND_COLOR
import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_RECT_SIZE
import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_RENDER_ALPHA
import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_TEXT_COLOR
import app.cash.paparazzi.accessibility.RenderSettings.DEFAULT_TEXT_SIZE
import app.cash.paparazzi.accessibility.RenderSettings.getColor
import app.cash.paparazzi.accessibility.RenderSettings.toColorInt
import app.cash.paparazzi.accessibility.RenderSettings.withAlpha

class AccessibilityRenderExtension : RenderExtension {
  override fun renderView(
    contentView: View
  ): View {
    val accessibilityViews = contentView.findAccessibilityViews()
    accessibilityViews.forEach { view ->
      val color = getColor(view)
      val colorInt = color.toColorInt()

      val colorDrawable = GradientDrawable(
        GradientDrawable.Orientation.TOP_BOTTOM,
        intArrayOf(colorInt, colorInt)
      ).apply {
        setStroke(2, color.withAlpha(DEFAULT_RENDER_ALPHA * 2).toColorInt())
      }

      view.foreground = view.foreground?.let { drawable ->
        // If there is an existing foreground layer the color on top of it.
        LayerDrawable(arrayOf(drawable, colorDrawable))
      } ?: colorDrawable
    }

    return LinearLayout(contentView.context).apply {
      orientation = LinearLayout.HORIZONTAL
      weightSum = 2f
      layoutParams = ViewGroup.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.MATCH_PARENT
      )

      val contentLayoutParams = contentView.layoutParams ?: generateLayoutParams(null)
      addView(
        contentView,
        LinearLayout.LayoutParams(
          contentLayoutParams.width,
          contentLayoutParams.height,
          1f
        )
      )
      addView(
        buildAccessibilityView(contentView),
        LinearLayout.LayoutParams(
          ViewGroup.LayoutParams.MATCH_PARENT,
          ViewGroup.LayoutParams.MATCH_PARENT,
          1f
        )
      )
    }
  }

  private fun View.findAccessibilityViews(): List<View> {
    val accessibilityViews = mutableListOf<View>()
    if (isImportantForAccessibility && !iterableTextForAccessibility.isNullOrBlank()) {
      accessibilityViews.add(this)
    }

    if (this is ViewGroup) {
      (0 until childCount).forEach {
        accessibilityViews += getChildAt(it).findAccessibilityViews()
      }
    }

    return accessibilityViews
  }

  private fun buildAccessibilityView(contentView: View): View {
    val linearLayout = LinearLayout(contentView.context).apply {
      orientation = LinearLayout.VERTICAL
      setBackgroundColor(DEFAULT_DESCRIPTION_BACKGROUND_COLOR.toColorInt())
    }

    fun renderAccessibility(view: View) {
      if (view.isImportantForAccessibility && !view.iterableTextForAccessibility.isNullOrBlank()) {
        linearLayout.addView(buildAccessibilityRow(view, view.iterableTextForAccessibility))
      }

      if (view is ViewGroup) {
        (0 until view.childCount).forEach {
          renderAccessibility(view.getChildAt(it))
        }
      }
    }

    renderAccessibility(contentView)
    return linearLayout
  }

  private fun buildAccessibilityRow(view: View, iterableTextForAccessibility: CharSequence): View {
    val context = view.context
    val color = getColor(view).toColorInt()
    val margin = view.dip(8)
    val innerMargin = view.dip(4)

    return LinearLayout(context).apply {
      orientation = LinearLayout.HORIZONTAL
      layoutParams = ViewGroup.LayoutParams(
        ViewGroup.LayoutParams.MATCH_PARENT,
        ViewGroup.LayoutParams.WRAP_CONTENT
      )
      setPaddingRelative(margin, innerMargin, margin, innerMargin)

      addView(
        View(context).apply {
          layoutParams = ViewGroup.LayoutParams(dip(DEFAULT_RECT_SIZE), dip(DEFAULT_RECT_SIZE))
          background = GradientDrawable(
            GradientDrawable.Orientation.TOP_BOTTOM,
            intArrayOf(color, color)
          ).apply {
            cornerRadius = dip(DEFAULT_RECT_SIZE / 4f)
          }
          setPaddingRelative(innerMargin, innerMargin, innerMargin, innerMargin)
        }
      )
      addView(
        TextView(context).apply {
          layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
          )
          text = iterableTextForAccessibility
          textSize = DEFAULT_TEXT_SIZE
          setTextColor(DEFAULT_TEXT_COLOR.toColorInt())
          setPaddingRelative(innerMargin, 0, innerMargin, 0)
        }
      )
    }
  }
}

private fun View.dip(value: Float): Float =
  TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    value,
    resources.displayMetrics
  )

private fun View.dip(value: Int): Int = dip(value.toFloat()).toInt()

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.accessibility

import android.view.View
import java.awt.Color

internal object RenderSettings {
  const val DEFAULT_RENDER_ALPHA = 40
  val DEFAULT_RENDER_COLORS = listOf(
    Color.RED,
    Color.GREEN,
    Color.BLUE,
    Color.YELLOW,
    Color.ORANGE,
    Color.MAGENTA,
    Color.CYAN,
    Color.PINK
  )
  val DEFAULT_TEXT_COLOR: Color = Color.BLACK
  val DEFAULT_DESCRIPTION_BACKGROUND_COLOR: Color = Color.WHITE
  const val DEFAULT_TEXT_SIZE: Float = 10f
  const val DEFAULT_RECT_SIZE: Int = 16

  private val colorMap = mutableMapOf<Int, Color>()

  fun getColor(view: View): Color {
    val key = "${view::class.simpleName}(${view.iterableTextForAccessibility})"
    return getColor(key)
  }

  private fun getColor(key: String): Color {
    val hashCode = key.hashCode()
    return colorMap.getOrPut(hashCode) {
      nextColor(hashCode).withAlpha(DEFAULT_RENDER_ALPHA)
    }
  }

  private fun nextColor(hashCode: Int): Color {
    return DEFAULT_RENDER_COLORS[colorIndex(hashCode)]
  }

  private fun colorIndex(hashCode: Int): Int {
    val size = DEFAULT_RENDER_COLORS.size
    val i = hashCode % size
    return if (i < 0) i + size else i
  }

  internal fun Color.toColorInt(): Int =
    android.graphics.Color.argb(alpha, red, green, blue)

  internal fun Color.withAlpha(alpha: Int): Color {
    return Color(red, green, blue, alpha)
  }
}

package app.cash.paparazzi.internal

import android.view.Choreographer
import com.android.internal.lang.System_Delegate

object ChoreographerDelegateInterceptor {
  @Suppress("unused")
  @JvmStatic
  fun intercept(
    choreographer: Choreographer
  ): Long = System_Delegate.nanoTime()
}

package app.cash.paparazzi.internal

import com.android.ide.common.rendering.api.ResourceNamespace
import com.android.ide.common.rendering.api.ResourceReference
import com.android.resources.ResourceType

/**
 * Copied from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/ResourceIdManager.kt;l=100-117
 *
 * Generates and keep track of dynamic resource ids, emulating aapt.
 */
internal class DynamicResourceIdManager {
  private class IdProvider(private val packageByte: Byte) {
    private val counters: ShortArray = ShortArray(ResourceType.values().size) { 0xffff.toShort() }

    fun getNext(type: ResourceType): Int {
      return buildResourceId(packageByte, (type.ordinal + 1).toByte(), --counters[type.ordinal])
    }
  }

  private var nextPackageId = FIRST_PACKAGE_ID

  private val perNamespaceProviders = hashMapOf<ResourceNamespace, IdProvider>()
  private val dynamicToIdMap = hashMapOf<ResourceReference, Int>()
  private val dynamicFromIdMap = hashMapOf<Int, ResourceReference>()

  init {
    perNamespaceProviders[ResourceNamespace.RES_AUTO] = IdProvider(0x7f)
    perNamespaceProviders[ResourceNamespace.ANDROID] = IdProvider(0x01)
  }

  fun findById(id: Int): ResourceReference? = dynamicFromIdMap[id]

  fun getOrGenerateId(resource: ResourceReference): Int {
    val dynamicId = dynamicToIdMap[resource]
    if (dynamicId != null) {
      return dynamicId
    }

    val provider = perNamespaceProviders.getOrPut(resource.namespace) {
      IdProvider(nextPackageId++)
    }
    val newId = provider.getNext(resource.resourceType)

    dynamicToIdMap[resource] = newId
    dynamicFromIdMap[newId] = resource

    return newId
  }
}

private const val FIRST_PACKAGE_ID = 0x02.toByte()

/**
 * Copied from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/res/IdeResourcesUtil.kt;l=1030-1031
 *
 * Build ids following the aapt PPTTEEEE format: 1 byte for package, 1 byte for type, 2 bytes for entry id.
 */
private fun buildResourceId(packageId: Byte, typeId: Byte, entryId: Short) =
  (packageId.toInt() shl 24) or (typeId.toInt() shl 16) or (entryId.toInt() and 0xffff)

package app.cash.paparazzi.internal

object EditModeInterceptor {
  @JvmStatic
  fun intercept(): Boolean = false
}

/*
 * Copyright (C) 2016 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 app.cash.paparazzi.internal

import java.lang.ref.WeakReference

internal object Gc {
  fun gc() {
    // See RuntimeUtil#gc in jlibs (http://jlibs.in/)
    var obj: Any? = Any()
    val ref = WeakReference<Any>(obj)

    obj = null
    while (ref.get() != null) {
      System.gc()
      System.runFinalization()
    }

    System.gc()
    System.runFinalization()
  }
}

package app.cash.paparazzi.internal

import android.os.IBinder
import com.android.internal.view.IInputMethodManager

/**
 * With [ServiceManagerInterceptor] returning null for the service, we must override the logic
 * in [com.android.internal.view.IInputMethodManager.Stub.asInterface] to return the default
 * implementation of [IInputMethodManager].
 */
object IInputMethodManagerInterceptor {
  @Suppress("unused")
  @JvmStatic
  fun interceptAsInterface(@Suppress("UNUSED_PARAMETER") obj: IBinder?): IInputMethodManager =
    IInputMethodManager.Default()
}

/*
 * Copyright (C) 2016 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 app.cash.paparazzi.internal

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import java.awt.AlphaComposite
import java.awt.Color
import java.awt.Graphics2D
import java.awt.RenderingHints.KEY_ANTIALIASING
import java.awt.RenderingHints.KEY_INTERPOLATION
import java.awt.RenderingHints.KEY_RENDERING
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
import java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR
import java.awt.RenderingHints.VALUE_RENDER_QUALITY
import java.awt.image.BufferedImage
import java.awt.image.BufferedImage.TYPE_INT_ARGB
import java.io.File
import java.io.File.separatorChar
import java.io.IOException
import javax.imageio.ImageIO
import kotlin.math.max

/**
 * Utilities related to image processing.
 */
internal object ImageUtils {
  /**
   * Normally, this test will fail when there is a missing thumbnail. However, when
   * you create creating a new test, it's useful to be able to turn this off such that
   * you can generate all the missing thumbnails in one go, rather than having to run
   * the test repeatedly to get to each new render assertion generating its thumbnail.
   */
  private val FAIL_ON_MISSING_THUMBNAIL = true

  private const val THUMBNAIL_SIZE = 1000

  /** Directory where to write the thumbnails and deltas. */
  private val failureDir: File
    get() {
      val workingDirString = System.getProperty("user.dir")
      val failureDir = File(workingDirString, "out/failures")
      failureDir.mkdirs()
      return failureDir
    }

  @Throws(IOException::class)
  fun requireSimilar(
    relativePath: String,
    image: BufferedImage,
    maxPercentDifference: Double
  ) {
    val scale = getThumbnailScale(image)
    val thumbnail = scale(image, scale, scale)

    val `is` = ImageUtils::class.java.classLoader.getResourceAsStream(relativePath)
    if (`is` ==
      null
    ) {
      var message = "Unable to load golden thumbnail: $relativePath\n"
      message = saveImageAndAppendMessage(thumbnail, message, relativePath)
      if (FAIL_ON_MISSING_THUMBNAIL) {
        fail(message)
      } else {
        println(message)
      }
    } else {
      try {
        val goldenImage = ImageIO.read(`is`)
        assertImageSimilar(
          relativePath,
          goldenImage,
          thumbnail,
          maxPercentDifference
        )
      } finally {
        `is`.close()
      }
    }
  }

  @Throws(IOException::class)
  fun assertImageSimilar(
    relativePath: String,
    goldenImage: BufferedImage,
    image: BufferedImage,
    maxPercentDifferent: Double
  ) {
    var goldenImage = goldenImage
    if (goldenImage.type != TYPE_INT_ARGB) {
      val temp = BufferedImage(
        goldenImage.width,
        goldenImage.height,
        TYPE_INT_ARGB
      )
      temp.graphics.drawImage(goldenImage, 0, 0, null)
      goldenImage = temp
    }
    assertEquals(TYPE_INT_ARGB.toLong(), goldenImage.type.toLong())

    val imageWidth = Math.min(goldenImage.width, image.width)
    val imageHeight = Math.min(goldenImage.height, image.height)

    // Blur the images to account for the scenarios where there are pixel
    // differences
    // in where a sharp edge occurs
    // goldenImage = blur(goldenImage, 6);
    // image = blur(image, 6);

    val width = 3 * imageWidth
    val deltaImage = BufferedImage(width, imageHeight, TYPE_INT_ARGB)
    val g = deltaImage.graphics

    // Compute delta map
    var delta: Long = 0
    for (y in 0 until imageHeight) {
      for (x in 0 until imageWidth) {
        val goldenRgb = goldenImage.getRGB(x, y)
        val rgb = image.getRGB(x, y)
        if (goldenRgb == rgb) {
          deltaImage.setRGB(imageWidth + x, y, 0x00808080)
          continue
        }

        // If the pixels have no opacity, don't delta colors at all
        if (goldenRgb and -0x1000000 == 0 && rgb and -0x1000000 == 0) {
          deltaImage.setRGB(imageWidth + x, y, 0x00808080)
          continue
        }

        val deltaR = (rgb and 0xFF0000).ushr(16) - (goldenRgb and 0xFF0000).ushr(16)
        val newR = 128 + deltaR and 0xFF
        val deltaG = (rgb and 0x00FF00).ushr(8) - (goldenRgb and 0x00FF00).ushr(8)
        val newG = 128 + deltaG and 0xFF
        val deltaB = (rgb and 0x0000FF) - (goldenRgb and 0x0000FF)
        val newB = 128 + deltaB and 0xFF

        val avgAlpha =
          ((goldenRgb and -0x1000000).ushr(24) + (rgb and -0x1000000).ushr(24)) / 2 shl 24

        val newRGB = avgAlpha or (newR shl 16) or (newG shl 8) or newB
        deltaImage.setRGB(imageWidth + x, y, newRGB)

        delta += Math.abs(deltaR)
          .toLong()
        delta += Math.abs(deltaG)
          .toLong()
        delta += Math.abs(deltaB)
          .toLong()
      }
    }

    // 3 different colors, 256 color levels
    val total = imageHeight.toLong() * imageWidth.toLong() * 3L * 256L
    val percentDifference = (delta * 100 / total.toDouble()).toFloat()

    var error: String? = null
    val imageName = getName(relativePath)
    if (percentDifference > maxPercentDifferent) {
      error = String.format("Images differ (by %.1f%%)", percentDifference)
    } else if (Math.abs(goldenImage.width - image.width) >= 2) {
      error = "Widths differ too much for " + imageName + ": " +
        goldenImage.width + "x" + goldenImage.height +
        "vs" + image.width + "x" + image.height
    } else if (Math.abs(goldenImage.height - image.height) >= 2) {
      error = "Heights differ too much for " + imageName + ": " +
        goldenImage.width + "x" + goldenImage.height +
        "vs" + image.width + "x" + image.height
    }

    if (error != null) {
      // Expected on the left
      // Golden on the right
      g.drawImage(goldenImage, 0, 0, null)
      g.drawImage(image, 2 * imageWidth, 0, null)

      // Labels
      if (imageWidth > 80) {
        g.color = Color.RED
        g.drawString("Expected", 10, 20)
        g.drawString("Actual", 2 * imageWidth + 10, 20)
      }

      val output = File(failureDir, "delta-$imageName")
      if (output.exists()) {
        val deleted = output.delete()
        assertTrue(deleted)
      }
      ImageIO.write(deltaImage, "PNG", output)
      error += " - see details in file://" + output.path + "\n"
      error = saveImageAndAppendMessage(image, error, relativePath)
      println(error)
      fail(error)
    }

    g.dispose()
  }

  /**
   * Resize the given image
   *
   * @param source the image to be scaled
   * @param xScale x scale
   * @param yScale y scale
   * @return the scaled image
   */
  fun scale(
    source: BufferedImage,
    xScale: Double,
    yScale: Double
  ): BufferedImage {
    var source = source

    var sourceWidth = source.width
    var sourceHeight = source.height
    val destWidth = Math.max(1, (xScale * sourceWidth).toInt())
    val destHeight = Math.max(1, (yScale * sourceHeight).toInt())
    var imageType = source.type
    if (imageType == BufferedImage.TYPE_CUSTOM) {
      imageType = BufferedImage.TYPE_INT_ARGB
    }
    if (xScale > 0.5 && yScale > 0.5) {
      val scaled = BufferedImage(destWidth, destHeight, imageType)
      val g2 = scaled.createGraphics()
      g2.composite = AlphaComposite.Src
      g2.color = Color(0, true)
      g2.fillRect(0, 0, destWidth, destHeight)
      if (xScale == 1.0 && yScale == 1.0) {
        g2.drawImage(source, 0, 0, null)
      } else {
        setRenderingHints(g2)
        g2.drawImage(source, 0, 0, destWidth, destHeight, 0, 0, sourceWidth, sourceHeight, null)
      }
      g2.dispose()
      return scaled
    } else {
      // When creating a thumbnail, using the above code doesn't work very well;
      // you get some visible artifacts, especially for text. Instead use the
      // technique of repeatedly scaling the image into half; this will cause
      // proper averaging of neighboring pixels, and will typically (for the kinds
      // of screen sizes used by this utility method in the layout editor) take
      // about 3-4 iterations to get the result since we are logarithmically reducing
      // the size. Besides, each successive pass in operating on much fewer pixels
      // (a reduction of 4 in each pass).
      //
      // However, we may not be resizing to a size that can be reached exactly by
      // successively diving in half. Therefore, once we're within a factor of 2 of
      // the final size, we can do a resize to the exact target size.
      // However, we can get even better results if we perform this final resize
      // up front. Let's say we're going from width 1000 to a destination width of 85.
      // The first approach would cause a resize from 1000 to 500 to 250 to 125, and
      // then a resize from 125 to 85. That last resize can distort/blur a lot.
      // Instead, we can start with the destination width, 85, and double it
      // successfully until we're close to the initial size: 85, then 170,
      // then 340, and finally 680. (The next one, 1360, is larger than 1000).
      // So, now we *start* the thumbnail operation by resizing from width 1000 to
      // width 680, which will preserve a lot of visual details such as text.
      // Then we can successively resize the image in half, 680 to 340 to 170 to 85.
      // We end up with the expected final size, but we've been doing an exact
      // divide-in-half resizing operation at the end so there is less distortion.

      var iterations = 0 // Number of halving operations to perform after the initial resize
      var nearestWidth = destWidth // Width closest to source width that = 2^x, x is integer
      var nearestHeight = destHeight
      while (nearestWidth < sourceWidth / 2) {
        nearestWidth *= 2
        nearestHeight *= 2
        iterations++
      }

      var scaled = BufferedImage(nearestWidth, nearestHeight, imageType)

      var g2 = scaled.createGraphics()
      setRenderingHints(g2)
      g2.drawImage(source, 0, 0, nearestWidth, nearestHeight, 0, 0, sourceWidth, sourceHeight, null)
      g2.dispose()

      sourceWidth = nearestWidth
      sourceHeight = nearestHeight
      source = scaled

      for (iteration in iterations - 1 downTo 0) {
        val halfWidth = sourceWidth / 2
        val halfHeight = sourceHeight / 2
        scaled = BufferedImage(halfWidth, halfHeight, imageType)
        g2 = scaled.createGraphics()
        setRenderingHints(g2)
        g2.drawImage(source, 0, 0, halfWidth, halfHeight, 0, 0, sourceWidth, sourceHeight, null)
        g2.dispose()

        sourceWidth = halfWidth
        sourceHeight = halfHeight
        source = scaled
        iterations--
      }
      return scaled
    }
  }

  fun getThumbnailScale(image: BufferedImage): Double {
    val maxDimension = max(image.width, image.height)
    return THUMBNAIL_SIZE / maxDimension.toDouble()
  }

  private fun setRenderingHints(g2: Graphics2D) {
    g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR)
    g2.setRenderingHint(KEY_RENDERING, VALUE_RENDER_QUALITY)
    g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
  }

  /**
   * Saves the generated thumbnail image and appends the info message to an initial message
   */
  @Throws(IOException::class)
  private fun saveImageAndAppendMessage(
    image: BufferedImage,
    initialMessage: String,
    relativePath: String
  ): String {
    var initialMessage = initialMessage
    val output = File(
      failureDir,
      getName(relativePath)
    )
    if (output.exists()) {
      val deleted = output.delete()
      assertTrue(deleted)
    }
    ImageIO.write(image, "PNG", output)
    initialMessage += "Thumbnail for current rendering stored at file://" + output.path
    //        initialMessage += "\nRun the following command to accept the changes:\n";
    //        initialMessage += String.format("mv %1$s %2$s", output.getPath(),
    //                ImageUtils.class.getResource(relativePath).getPath());
    // The above has been commented out, since the destination path returned is in out dir
    // and it makes the tests pass without the code being actually checked in.
    return initialMessage
  }

  private fun getName(relativePath: String): String {
    return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1)
  }
}

package app.cash.paparazzi.internal

// Sampled from https://cs.android.com/android/platform/superproject/+/master:external/robolectric-shadows/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java;l=10-67
object MatrixMatrixMultiplicationInterceptor {
  @Suppress("unused")
  @JvmStatic
  fun intercept(
    result: FloatArray,
    resultOffset: Int,
    lhs: FloatArray,
    lhsOffset: Int,
    rhs: FloatArray,
    rhsOffset: Int
  ) {
    require(resultOffset + 16 <= result.size) { "resultOffset + 16 > result.length" }
    require(lhsOffset + 16 <= lhs.size) { "lhsOffset + 16 > lhs.length" }
    require(rhsOffset + 16 <= rhs.size) { "rhsOffset + 16 > rhs.length" }
    for (i in 0..3) {
      val rhs_i0 = rhs[I(i, 0, rhsOffset)]
      var ri0 = lhs[I(0, 0, lhsOffset)] * rhs_i0
      var ri1 = lhs[I(0, 1, lhsOffset)] * rhs_i0
      var ri2 = lhs[I(0, 2, lhsOffset)] * rhs_i0
      var ri3 = lhs[I(0, 3, lhsOffset)] * rhs_i0
      for (j in 1..3) {
        val rhs_ij = rhs[I(i, j, rhsOffset)]
        ri0 += lhs[I(j, 0, lhsOffset)] * rhs_ij
        ri1 += lhs[I(j, 1, lhsOffset)] * rhs_ij
        ri2 += lhs[I(j, 2, lhsOffset)] * rhs_ij
        ri3 += lhs[I(j, 3, lhsOffset)] * rhs_ij
      }
      result[I(i, 0, resultOffset)] = ri0
      result[I(i, 1, resultOffset)] = ri1
      result[I(i, 2, resultOffset)] = ri2
      result[I(i, 3, resultOffset)] = ri3
    }
  }

  @Suppress("FunctionName")
  private fun I(i: Int, j: Int, offset: Int): Int {
    // #define I(_i, _j) ((_j)+ 4*(_i))
    return offset + j + 4 * i
  }
}

package app.cash.paparazzi.internal

// Sampled from https://cs.android.com/android/platform/superproject/+/master:external/robolectric-shadows/shadows/framework/src/main/java/org/robolectric/shadows/ShadowOpenGLMatrix.java;l=69-121
object MatrixVectorMultiplicationInterceptor {
  @Suppress("unused")
  @JvmStatic
  fun intercept(
    resultVec: FloatArray,
    resultVecOffset: Int,
    lhsMat: FloatArray,
    lhsMatOffset: Int,
    rhsVec: FloatArray,
    rhsVecOffset: Int
  ) {
    require(resultVecOffset + 4 <= resultVec.size) { "resultOffset + 4 > result.length" }
    require(lhsMatOffset + 16 <= lhsMat.size) { "lhsOffset + 16 > lhs.length" }
    require(rhsVecOffset + 4 <= rhsVec.size) { "rhsOffset + 4 > rhs.length" }
    val x = rhsVec[rhsVecOffset + 0]
    val y = rhsVec[rhsVecOffset + 1]
    val z = rhsVec[rhsVecOffset + 2]
    val w = rhsVec[rhsVecOffset + 3]
    resultVec[resultVecOffset + 0] =
      lhsMat[I(0, 0, lhsMatOffset)] * x + lhsMat[I(1, 0, lhsMatOffset)] * y + lhsMat[I(2, 0, lhsMatOffset)] * z + lhsMat[I(3, 0, lhsMatOffset)] * w
    resultVec[resultVecOffset + 1] =
      lhsMat[I(0, 1, lhsMatOffset)] * x + lhsMat[I(1, 1, lhsMatOffset)] * y + lhsMat[I(2, 1, lhsMatOffset)] * z + lhsMat[I(3, 1, lhsMatOffset)] * w
    resultVec[resultVecOffset + 2] =
      lhsMat[I(0, 2, lhsMatOffset)] * x + lhsMat[I(1, 2, lhsMatOffset)] * y + lhsMat[I(2, 2, lhsMatOffset)] * z + lhsMat[I(3, 2, lhsMatOffset)] * w
    resultVec[resultVecOffset + 3] =
      lhsMat[I(0, 3, lhsMatOffset)] * x + lhsMat[I(1, 3, lhsMatOffset)] * y + lhsMat[I(2, 3, lhsMatOffset)] * z + lhsMat[I(3, 3, lhsMatOffset)] * w
  }

  @Suppress("FunctionName")
  private fun I(i: Int, j: Int, offset: Int): Int {
    // #define I(_i, _j) ((_j)+ 4*(_i))
    return offset + j + 4 * i
  }
}

/*
 * Copyright (C) 2017 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 app.cash.paparazzi.internal

import com.android.ide.common.rendering.api.AssetRepository
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream

internal class PaparazziAssetRepository(private val assetPath: String) : AssetRepository() {
  @Throws(FileNotFoundException::class)
  private fun open(path: String): InputStream? {
    val asset = File(path)
    return when {
      asset.isFile -> FileInputStream(asset)
      else -> null
    }
  }

  override fun isSupported(): Boolean = true

  @Throws(IOException::class)
  override fun openAsset(
    path: String,
    mode: Int
  ): InputStream? = open("$assetPath/$path")

  @Throws(IOException::class)
  override fun openNonAsset(
    cookie: Int,
    path: String,
    mode: Int
  ): InputStream? = open(path)
}

/*
 * Copyright (C) 2014 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 app.cash.paparazzi.internal

import app.cash.paparazzi.internal.parsers.LayoutPullParser
import app.cash.paparazzi.internal.parsers.TagSnapshot
import com.android.SdkConstants.CLASS_RECYCLER_VIEW_ADAPTER
import com.android.ide.common.rendering.api.ActionBarCallback
import com.android.ide.common.rendering.api.AdapterBinding
import com.android.ide.common.rendering.api.ILayoutPullParser
import com.android.ide.common.rendering.api.LayoutlibCallback
import com.android.ide.common.rendering.api.ResourceNamespace.RES_AUTO
import com.android.ide.common.rendering.api.ResourceReference
import com.android.ide.common.rendering.api.ResourceValue
import com.android.resources.ResourceType
import com.android.resources.ResourceType.STYLE
import com.google.common.io.ByteStreams
import org.kxml2.io.KXmlParser
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.lang.reflect.Modifier

internal class PaparazziCallback(
  private val logger: PaparazziLogger,
  private val packageName: String,
  private val resourcePackageNames: List<String>
) : LayoutlibCallback() {
  private val projectResources = mutableMapOf<Int, ResourceReference>()
  private val resources = mutableMapOf<ResourceReference, Int>()
  private val actionBarCallback = ActionBarCallback()
  private val aaptDeclaredResources = mutableMapOf<String, TagSnapshot>()
  private val dynamicResourceIdManager = DynamicResourceIdManager()

  private val loadedClasses = mutableMapOf<String, Class<*>>()

  @Throws(ClassNotFoundException::class)
  fun initResources() {
    for (rPackageName in resourcePackageNames) {
      val rClass = Class.forName("$rPackageName.R")
      for (resourceClass in rClass.declaredClasses) {
        val resourceType = ResourceType.fromClassName(resourceClass.simpleName) ?: continue

        for (field in resourceClass.declaredFields) {
          if (!Modifier.isStatic(field.modifiers)) continue

          // May not be final in library projects.
          val type = field.type
          try {
            if (type == Int::class.javaPrimitiveType) {
              val value = field.get(null) as Int
              val reference = ResourceReference(RES_AUTO, resourceType, field.name)
              projectResources[value] = reference
              resources[reference] = value
            } else if (type.isArray && type.componentType == Int::class.javaPrimitiveType) {
              // Ignore.
            } else {
              logger.error(null, "Unknown field type in R class: $type")
            }
          } catch (e: IllegalAccessException) {
            logger.error(e, "Malformed R class: %1\$s", "$rPackageName.R")
          }
        }
      }
    }
  }

  @Throws(Exception::class)
  override fun loadView(
    name: String,
    constructorSignature: Array<Class<*>>,
    constructorArgs: Array<Any>
  ): Any? = createNewInstance(name, constructorSignature, constructorArgs)

  override fun loadClass(
    name: String,
    constructorSignature: Array<Class<*>>,
    constructorArgs: Array<Any>
  ): Any? {
    // RecyclerView.Adapter is an abstract class, but its instance is needed for RecyclerView to work correctly.
    // So, when LayoutLib asks for its instance, we define a new class which extends the Adapter class.
    // We check whether the class being loaded is the support or the androidx one and use the appropriate adapter that references to the
    // right namespace.
    return try {
      when (name) {
        CLASS_RECYCLER_VIEW_ADAPTER.newName() -> createNewInstance(CN_ANDROIDX_CUSTOM_ADAPTER, EMPTY_CLASS_ARRAY, EMPTY_OBJECT_ARRAY)
        CLASS_RECYCLER_VIEW_ADAPTER.oldName() -> createNewInstance(CN_SUPPORT_CUSTOM_ADAPTER, EMPTY_CLASS_ARRAY, EMPTY_OBJECT_ARRAY)
        else -> createNewInstance(name, constructorSignature, constructorArgs)
      }
    } catch (e: ClassNotFoundException) {
      null
    }
  }

  override fun resolveResourceId(id: Int): ResourceReference? =
    projectResources[id] ?: dynamicResourceIdManager.findById(id)

  override fun getOrGenerateResourceId(resource: ResourceReference): Int {
    // Workaround: We load our resource map from fields in R.class, which are named using Java
    // class conventions.  Therefore, we need to similarly transform style naming conventions
    // that contain periods (e.g., Widget.AppCompat.TextView) to avoid false lookup misses.
    // Long-term: Perhaps parse and load resource names from file system directly?
    val resourceKey =
      if (resource.resourceType == STYLE) resource.transformStyleResource() else resource
    return resources[resourceKey] ?: dynamicResourceIdManager.getOrGenerateId(resourceKey)
  }

  override fun getParser(layoutResource: ResourceValue): ILayoutPullParser? {
    try {
      val value = layoutResource.value ?: return null
      if (aaptDeclaredResources.isNotEmpty() && layoutResource.resourceType == ResourceType.AAPT) {
        val aaptResource = aaptDeclaredResources.getValue(value)
        return LayoutPullParser.createFromAaptResource(aaptResource)
      }

      return LayoutPullParser.createFromFile(File(layoutResource.value))
        .also {
          // For parser of elements included in this parser, publish any aapt declared values
          aaptDeclaredResources.putAll(it.getAaptDeclaredAttrs())
        }
    } catch (e: FileNotFoundException) {
      return null
    }
  }

  override fun getAdapterItemValue(
    adapterView: ResourceReference,
    adapterCookie: Any,
    itemRef: ResourceReference,
    fullPosition: Int,
    positionPerType: Int,
    fullParentPosition: Int,
    parentPositionPerType: Int,
    viewRef: ResourceReference,
    viewAttribute: ViewAttribute,
    defaultValue: Any
  ): Any? = null

  override fun getAdapterBinding(
    adapterViewRef: ResourceReference,
    adapterCookie: Any,
    viewObject: Any
  ): AdapterBinding? = null

  override fun getActionBarCallback(): ActionBarCallback = actionBarCallback

  override fun createXmlParserForPsiFile(fileName: String): XmlPullParser? =
    createXmlParserForFile(fileName)

  override fun createXmlParserForFile(fileName: String): XmlPullParser? {
    try {
      FileInputStream(fileName).use { fileStream ->
        // Read data fully to memory to be able to close the file stream.
        val byteOutputStream = ByteArrayOutputStream()
        ByteStreams.copy(fileStream, byteOutputStream)
        val parser = KXmlParser()
        parser.setInput(ByteArrayInputStream(byteOutputStream.toByteArray()), null)
        parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
        return parser
      }
    } catch (e: IOException) {
      return null
    } catch (e: XmlPullParserException) {
      return null
    }
  }

  override fun createXmlParser(): XmlPullParser = KXmlParser()

  override fun getApplicationId(): String = packageName

  override fun getResourcePackage(): String = packageName

  override fun findClass(name: String): Class<*> {
    val clazz = loadedClasses[name]
    logger.verbose("loadClassA($name)")

    try {
      if (clazz != null) {
        return clazz
      }
      val clazz2 = Class.forName(name)
      logger.verbose("loadClassB($name)")
      loadedClasses[name] = clazz2
      return clazz2
    } catch (e: LinkageError) {
      throw ClassNotFoundException("error loading class $name", e)
    } catch (e: ExceptionInInitializerError) {
      throw ClassNotFoundException("error loading class $name", e)
    } catch (e: ClassNotFoundException) {
      throw ClassNotFoundException("error loading class $name", e)
    }
  }

  private fun ResourceReference.transformStyleResource() =
    ResourceReference.style(namespace, name.replace('.', '_'))

  private fun createNewInstance(
    name: String,
    constructorSignature: Array<Class<*>>,
    constructorArgs: Array<Any>
  ): Any? {
    val anyClass = Class.forName(name)
    val anyConstructor = anyClass.getConstructor(*constructorSignature)
    anyConstructor.isAccessible = true
    return anyConstructor.newInstance(*constructorArgs)
  }

  private companion object {
    private val EMPTY_CLASS_ARRAY = emptyArray<Class<*>>()
    private val EMPTY_OBJECT_ARRAY = emptyArray<Any>()
    private const val CN_ANDROIDX_CUSTOM_ADAPTER = "com.android.layoutlib.bridge.android.androidx.Adapter"
    private const val CN_SUPPORT_CUSTOM_ADAPTER = "com.android.layoutlib.bridge.android.support.Adapter"
  }
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal

import app.cash.paparazzi.Snapshot
import app.cash.paparazzi.TestName
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.Types
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import java.util.Date

internal object PaparazziJson {
  val moshi = Moshi.Builder()
    .add(Date::class.java, Rfc3339DateJsonAdapter())
    .add(this)
    .build()!!

  val listOfShotsAdapter: JsonAdapter<List<Snapshot>> =
    moshi
      .adapter<List<Snapshot>>(
        Types.newParameterizedType(List::class.java, Snapshot::class.java)
      )
      .indent("  ")

  val listOfStringsAdapter: JsonAdapter<List<String>> =
    moshi
      .adapter<List<String>>(
        Types.newParameterizedType(List::class.java, String::class.java)
      )
      .indent("  ")

  @ToJson
  fun testNameToJson(testName: TestName): String {
    return "${testName.packageName}.${testName.className}#${testName.methodName}"
  }

  @FromJson
  fun testNameFromJson(json: String): TestName {
    val regex = Regex("(.*)\\.([^.]*)#([^.]*)")
    val (packageName, className, methodName) = regex.matchEntire(json)!!.destructured
    return TestName(packageName, className, methodName)
  }
}

/*
 * Copyright (C) 2019 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal

import app.cash.paparazzi.Paparazzi
import com.android.ide.common.rendering.api.ILayoutLog
import com.android.utils.ILogger
import java.io.PrintStream
import java.io.PrintWriter
import java.util.logging.Level
import java.util.logging.Logger
import java.util.logging.Logger.getLogger

/**
 * This logger delegates to java.util.Logging.
 */
internal class PaparazziLogger : ILayoutLog, ILogger {
  private val logger: Logger = getLogger(Paparazzi::class.java.name)
  private val errors = mutableListOf<Throwable>()

  override fun error(
    throwable: Throwable?,
    format: String?,
    vararg args: Any
  ) {
    logger.log(Level.SEVERE, format?.format(args), throwable)
    if (throwable != null) {
      errors += throwable
    }
  }

  override fun warning(
    format: String,
    vararg args: Any
  ) {
    logger.log(Level.WARNING, format, args)
  }

  override fun info(
    format: String,
    vararg args: Any
  ) {
    logger.log(Level.INFO, format, args)
  }

  override fun verbose(
    format: String,
    vararg args: Any
  ) {
    logger.log(Level.FINE, format, args)
  }

  override fun fidelityWarning(
    tag: String?,
    message: String?,
    throwable: Throwable?,
    cookie: Any?,
    data: Any?
  ) {
    logger.log(Level.WARNING, "$tag: $message", throwable)
  }

  override fun error(
    tag: String?,
    message: String?,
    viewCookie: Any?,
    data: Any?
  ) {
    logger.log(Level.SEVERE, "$tag: $message")
  }

  override fun error(
    tag: String?,
    message: String?,
    throwable: Throwable?,
    viewCookie: Any?,
    data: Any?
  ) {
    logger.log(Level.SEVERE, "$tag: $message", throwable)
    if (throwable != null) {
      errors += throwable
    }
  }

  override fun warning(
    tag: String?,
    message: String?,
    viewCookie: Any?,
    data: Any?
  ) {
    logger.log(Level.WARNING, "$tag: $message")
  }

  override fun logAndroidFramework(priority: Int, tag: String?, message: String?) {
    logger.log(Level.INFO, "$tag [$priority]: $message")
  }

  fun assertNoErrors() {
    when (errors.size) {
      0 -> return
      1 -> throw errors[0]
      else -> throw MultipleFailuresException(errors)
    }
  }

  internal class MultipleFailuresException(private val causes: List<Throwable>) : Exception() {
    init {
      require(causes.isNotEmpty()) { "List of Throwables must not be empty" }
    }

    override val message: String
      get() = buildString {
        appendLine(String.format("There were %d errors:", causes.size))
        causes.forEach { e ->
          appendLine(String.format("%n  %s: %s", e.javaClass.name, e.message))
          e.stackTrace.forEach { traceElement ->
            appendLine("\tat $traceElement")
          }
        }
      }

    override fun printStackTrace() {
      causes.forEach { e ->
        e.printStackTrace()
      }
    }

    override fun printStackTrace(s: PrintStream) {
      causes.forEach { e ->
        e.printStackTrace(s)
      }
    }

    override fun printStackTrace(s: PrintWriter) {
      causes.forEach { e ->
        e.printStackTrace(s)
      }
    }
  }
}

/*
 * Copyright (C) 2016 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 app.cash.paparazzi.internal

import com.android.ide.common.rendering.api.RenderSession
import com.android.ide.common.rendering.api.Result
import com.android.ide.common.rendering.api.ViewInfo
import java.awt.image.BufferedImage

internal data class RenderResult(
  val result: Result,
  val systemViews: List<ViewInfo>,
  val rootViews: List<ViewInfo>,
  val image: BufferedImage
)

internal fun RenderSession.toResult(): RenderResult {
  return RenderResult(result, systemRootViews.toList(), rootViews.toList(), image)
}

/*
 * Copyright (C) 2016 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 app.cash.paparazzi.internal

import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Environment
import app.cash.paparazzi.Flags
import app.cash.paparazzi.deprecated.com.android.ide.common.resources.deprecated.FrameworkResources
import app.cash.paparazzi.deprecated.com.android.ide.common.resources.deprecated.ResourceItem
import app.cash.paparazzi.deprecated.com.android.ide.common.resources.deprecated.ResourceRepository
import app.cash.paparazzi.deprecated.com.android.io.FolderWrapper
import com.android.layoutlib.bridge.Bridge
import com.android.layoutlib.bridge.android.RenderParamsFlags
import com.android.layoutlib.bridge.impl.DelegateManager
import java.io.Closeable
import java.io.File
import java.util.Locale

/** View rendering. */
internal class Renderer(
  private val environment: Environment,
  private val layoutlibCallback: PaparazziCallback,
  private val logger: PaparazziLogger
) : Closeable {
  private var bridge: Bridge? = null
  private lateinit var sessionParamsBuilder: SessionParamsBuilder

  /** Initialize the bridge and the resource maps. */
  fun prepare(): SessionParamsBuilder {
    val platformDataResDir = File("${environment.platformDir}/data/res")
    val frameworkResources = FrameworkResources(FolderWrapper(platformDataResDir)).apply {
      loadResources()
      loadPublicResources(logger)
    }

    val projectResources = object : ResourceRepository(FolderWrapper(environment.resDir), false) {
      override fun createResourceItem(name: String): ResourceItem {
        return ResourceItem(name)
      }
    }
    projectResources.loadResources()

    sessionParamsBuilder = SessionParamsBuilder(
      layoutlibCallback = layoutlibCallback,
      logger = logger,
      frameworkResources = frameworkResources,
      projectResources = projectResources,
      assetRepository = PaparazziAssetRepository(environment.assetsDir)
    )
      .plusFlag(RenderParamsFlags.FLAG_DO_NOT_RENDER_ON_CREATE, true)
      .withTheme("AppTheme", true)

    val platformDataRoot = System.getProperty("paparazzi.platform.data.root")
      ?: throw RuntimeException("Missing system property for 'paparazzi.platform.data.root'")
    val platformDataDir = File(platformDataRoot, "data")
    val fontLocation = File(platformDataDir, "fonts")
    val nativeLibLocation = File(platformDataDir, getNativeLibDir())
    val icuLocation = File(platformDataDir, "icu" + File.separator + "icudt68l.dat")
    val buildProp = File(environment.platformDir, "build.prop")
    val attrs = File(platformDataResDir, "values" + File.separator + "attrs.xml")
    val systemProperties = DeviceConfig.loadProperties(buildProp) + mapOf(
      // We want Choreographer.USE_FRAME_TIME to be false so it uses System_Delegate.nanoTime()
      "debug.choreographer.frametime" to "false"
    )
    bridge = Bridge().apply {
      check(
        init(
          systemProperties,
          fontLocation,
          nativeLibLocation.path,
          icuLocation.path,
          DeviceConfig.getEnumMap(attrs),
          logger
        )
      ) { "Failed to init Bridge." }
    }
    Bridge.getLock()
      .lock()
    try {
      Bridge.setLog(logger)
    } finally {
      Bridge.getLock()
        .unlock()
    }

    return sessionParamsBuilder
  }

  private fun getNativeLibDir(): String {
    val osName = System.getProperty("os.name").toLowerCase(Locale.US)
    val osLabel = when {
      osName.startsWith("windows") -> "win"
      osName.startsWith("mac") -> {
        val osArch = System.getProperty("os.arch").lowercase(Locale.US)
        if (osArch.startsWith("x86")) "mac" else "mac-arm"
      }
      else -> "linux"
    }
    return "$osLabel/lib64"
  }

  override fun close() {
    bridge = null

    Gc.gc()

    dumpDelegates()
  }

  fun dumpDelegates() {
    if (System.getProperty(Flags.DEBUG_LINKED_OBJECTS) != null) {
      println("Objects still linked from the DelegateManager:")
      DelegateManager.dump(System.out)
    }
  }
}

package app.cash.paparazzi.internal

import android.content.Context
import android.graphics.Typeface

object ResourcesInterceptor {
  @JvmStatic
  fun intercept(
    context: Context,
    resId: Int
  ): Typeface? {
    return context.resources.getFont(resId)
  }
}

package app.cash.paparazzi.internal

import android.os.IBinder

/**
 * The ImeTracing class attempts to initialize its [mService field in its constructor](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/util/imetracing/ImeTracing.java;l=60).
 *
 * Unfortunately, [layoutlib's version of ServiceManager](https://cs.android.com/android/platform/superproject/+/master:frameworks/layoutlib/bridge/src/android/os/ServiceManager.java;l=37)
 * throws an exception immediately.
 *
 * This interceptor overrides ServiceManager.getServiceOrThrow to simply return null instead.
 */
object ServiceManagerInterceptor {
  @Suppress("unused")
  @JvmStatic
  fun interceptGetServiceOrThrow(@Suppress("UNUSED_PARAMETER") name: String): IBinder? = null
}

/*
 * Copyright (C) 2017 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 app.cash.paparazzi.internal

import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.deprecated.com.android.ide.common.resources.deprecated.ResourceRepository
import app.cash.paparazzi.internal.parsers.LayoutPullParser
import com.android.SdkConstants
import com.android.ide.common.rendering.api.AssetRepository
import com.android.ide.common.rendering.api.ResourceNamespace
import com.android.ide.common.rendering.api.ResourceReference
import com.android.ide.common.rendering.api.SessionParams
import com.android.ide.common.rendering.api.SessionParams.Key
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
import com.android.ide.common.resources.ResourceResolver
import com.android.ide.common.resources.ResourceValueMap
import com.android.layoutlib.bridge.Bridge
import com.android.resources.LayoutDirection
import com.android.resources.ResourceType

/** Creates [SessionParams] objects. */
internal data class SessionParamsBuilder(
  private val layoutlibCallback: PaparazziCallback,
  private val logger: PaparazziLogger,
  private val frameworkResources: ResourceRepository,
  private val assetRepository: AssetRepository,
  private val projectResources: ResourceRepository,
  private val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5,
  private val renderingMode: RenderingMode = RenderingMode.NORMAL,
  private val targetSdk: Int = 22,
  private val flags: Map<Key<*>, Any> = mapOf(),
  private val themeName: String? = null,
  private val isProjectTheme: Boolean = false,
  private val layoutPullParser: LayoutPullParser? = null,
  private val projectKey: Any? = null,
  private val minSdk: Int = 0,
  private val decor: Boolean = true,
  private val supportsRtl: Boolean = false
) {
  fun withTheme(
    themeName: String,
    isProjectTheme: Boolean
  ): SessionParamsBuilder {
    return copy(themeName = themeName, isProjectTheme = isProjectTheme)
  }

  fun withTheme(themeName: String): SessionParamsBuilder {
    return when {
      themeName.startsWith(SdkConstants.PREFIX_ANDROID) -> {
        withTheme(themeName.substring(SdkConstants.PREFIX_ANDROID.length), false)
      }
      else -> withTheme(themeName, true)
    }
  }

  fun plusFlag(
    flag: SessionParams.Key<*>,
    value: Any
  ) = copy(flags = flags + (flag to value))

  fun build(): SessionParams {
    require(themeName != null)

    val folderConfiguration = deviceConfig.folderConfiguration
    val resourceResolver = ResourceResolver.create(
      mapOf<ResourceNamespace, Map<ResourceType, ResourceValueMap>>(
        ResourceNamespace.ANDROID to frameworkResources.getConfiguredResources(
          folderConfiguration
        ),
        ResourceNamespace.TODO() to projectResources.getConfiguredResources(
          folderConfiguration
        )
      ),
      ResourceReference(
        ResourceNamespace.fromBoolean(!isProjectTheme),
        ResourceType.STYLE,
        themeName
      )
    )

    val result = SessionParams(
      layoutPullParser, renderingMode, projectKey /* for caching */,
      deviceConfig.hardwareConfig, resourceResolver, layoutlibCallback, minSdk, targetSdk, logger
    )
    result.fontScale = deviceConfig.fontScale
    result.uiMode = deviceConfig.uiModeMask

    val localeQualifier = folderConfiguration.localeQualifier
    val layoutDirectionQualifier = folderConfiguration.layoutDirectionQualifier
    // https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/src/com/android/tools/idea/rendering/RenderTask.java;l=645
    if (LayoutDirection.RTL == layoutDirectionQualifier.value && !Bridge.isLocaleRtl(localeQualifier.tag)) {
      result.locale = "ur"
    } else {
      result.locale = localeQualifier.tag
    }
    result.setRtlSupport(supportsRtl)

    for ((key, value) in flags) {
      result.setFlag(key as Key<Any>, value)
    }
    result.setAssetRepository(assetRepository)

    if (!decor) {
      result.setForceNoDecor()
    }

    return result
  }
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

/**
 * Copied from https://cs.android.com/android-studio/platform/tools/adt/idea/+/858f81bb7c350bc7a05daad36edefd21f74c8cef:android/src/com/android/tools/idea/rendering/parsers/AaptAttrParser.java
 *
 * Interface for parsers that support declaration of inlined {@code aapt:attr} attributes
 */
interface AaptAttrParser {
  /**
   * Returns a [Map] that contains all the `aapt:attr` elements declared in this or any
   * children parsers. This list can be used to resolve `@aapt/_aapt` references into this parser.
   */
  fun getAaptDeclaredAttrs(): Map<String, TagSnapshot>
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

import com.android.SdkConstants.AAPT_ATTR_PREFIX
import com.android.SdkConstants.AAPT_PREFIX

/**
 * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/AaptAttrAttributeSnapshot.java
 *
 * Aapt attributes are attributes that instead of containing a reference, contain the inlined value
 * of the reference. This snapshot will generate a dynamic reference that will be used by the
 * resource resolution to be able to retrieve the inlined value.
 */
class AaptAttrSnapshot(
  override val namespace: String,
  override val prefix: String,
  override val name: String,
  val id: String,
  val bundledTag: TagSnapshot
) : AttributeSnapshot(namespace, prefix, name, "${AAPT_ATTR_PREFIX}$AAPT_PREFIX$id")

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

/**
 * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/AttributeSnapshot.java
 *
 * A snapshot of an attribute value pulled from an XML resource.
 * Used in conjunction with [TagSnapshot].
 */
open class AttributeSnapshot(
  open val namespace: String,
  open val prefix: String?,
  open val name: String,
  open val value: String
) {
  override fun toString() = "$name: $value"

  // since data classes can't be subclassed
  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (javaClass != other?.javaClass) return false

    other as AttributeSnapshot

    if (namespace != other.namespace) return false
    if (prefix != other.prefix) return false
    if (name != other.name) return false
    if (value != other.value) return false

    return true
  }

  override fun hashCode(): Int {
    var result = namespace.hashCode()
    result = 31 * result + prefix.hashCode()
    result = 31 * result + name.hashCode()
    result = 31 * result + value.hashCode()
    return result
  }
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

import org.kxml2.io.KXmlParser
import org.xmlpull.v1.XmlPullParserException

/**
 * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/858f81bb7c350bc7a05daad36edefd21f74c8cef:android/src/com/android/tools/idea/rendering/parsers/LayoutPullParser.java;bpv=0;bpt=0
 *
 * A parser implementation that walks an in-memory XML DOM tree.
 */
abstract class InMemoryParser : KXmlParser() {
  abstract fun rootTag(): TagSnapshot

  private val nodeStack = mutableListOf<TagSnapshot>()
  private var parsingState = START_DOCUMENT

  override fun getAttributeCount(): Int {
    val tag = getCurrentNode() ?: return 0
    return tag.attributes.size
  }

  override fun getAttributeName(i: Int): String? {
    val attribute = getAttribute(i) ?: return null
    return attribute.name
  }

  override fun getAttributeNamespace(i: Int): String {
    val attribute = getAttribute(i) ?: return ""
    return attribute.namespace
  }

  override fun getAttributePrefix(i: Int): String? {
    val attribute = getAttribute(i) ?: return null
    return attribute.prefix
  }

  override fun getAttributeValue(i: Int): String? {
    val attribute = getAttribute(i) ?: return null
    return attribute.value
  }

  override fun getAttributeValue(
    namespace: String?,
    name: String?
  ): String? {
    val tag = getCurrentNode() ?: return null
    return tag.attributes.find { it.name == name }?.value
  }

  override fun getDepth(): Int = nodeStack.size

  override fun getName(): String? {
    if (parsingState == START_TAG || parsingState == END_TAG) {
      // Should only be called when START_TAG
      val currentNode = getCurrentNode()!!
      return currentNode.name
    }
    return null
  }

  @Throws(XmlPullParserException::class)
  override fun next(): Int {
    when (parsingState) {
      END_DOCUMENT -> throw XmlPullParserException("Nothing after the end")
      START_DOCUMENT -> onNextFromStartDocument()
      START_TAG -> onNextFromStartTag()
      END_TAG -> onNextFromEndTag()
    }
    return parsingState
  }

  private fun getCurrentNode(): TagSnapshot? = nodeStack.lastOrNull()

  private fun getAttribute(i: Int): AttributeSnapshot? {
    if (parsingState != START_TAG) {
      throw IndexOutOfBoundsException()
    }
    val tag = getCurrentNode() ?: return null
    return tag.attributes[i]
  }

  private fun push(node: TagSnapshot) {
    nodeStack.add(node)
  }

  private fun pop(): TagSnapshot = nodeStack.removeLast()

  private fun onNextFromStartDocument() {
    val rootTag = rootTag()
    parsingState = if (rootTag != null) {
      push(rootTag)
      START_TAG
    } else {
      END_DOCUMENT
    }
  }

  private fun onNextFromStartTag() {
    // get the current node, and look for text or children (children first)
    // Should only be called when START_TAG
    val node = getCurrentNode()!!
    val children = node.children
    parsingState = if (children.isNotEmpty()) {
      // move to the new child, and don't change the state.
      push(children[0])

      // in case the current state is CURRENT_DOC, we set the proper state.
      START_TAG
    } else {
      if (parsingState == START_DOCUMENT) {
        // this handles the case where there's no node.
        END_DOCUMENT
      } else {
        END_TAG
      }
    }
  }

  private fun onNextFromEndTag() {
    // look for a sibling. if no sibling, go back to the parent
    // Should only be called when END_TAG
    var node = getCurrentNode()!!
    val sibling = node.next
    if (sibling != null) {
      node = sibling
      // to go to the sibling, we need to remove the current node,
      pop()
      // and add its sibling.
      push(node)
      parsingState = START_TAG
    } else {
      // move back to the parent
      pop()

      // we have only one element left (myRoot), then we're done with the document.
      parsingState = if (nodeStack.isEmpty()) {
        END_DOCUMENT
      } else {
        END_TAG
      }
    }
  }
}

/*
 * Copyright (C) 2014 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 app.cash.paparazzi.internal.parsers

import com.android.SdkConstants.ATTR_IGNORE
import com.android.SdkConstants.EXPANDABLE_LIST_VIEW
import com.android.SdkConstants.GRID_VIEW
import com.android.SdkConstants.LIST_VIEW
import com.android.SdkConstants.SPINNER
import com.android.SdkConstants.TOOLS_URI
import com.android.ide.common.rendering.api.ILayoutPullParser
import com.android.ide.common.rendering.api.ResourceNamespace
import okio.buffer
import okio.source
import org.xmlpull.v1.XmlPullParserException
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.IOError
import java.io.InputStream
import java.nio.charset.Charset

/**
 * A layout parser that holds an in-memory tree of a given resource for subsequent traversal
 * during the inflation process
 */
internal class LayoutPullParser : InMemoryParser, AaptAttrParser, ILayoutPullParser {
  private constructor(inputStream: InputStream) : super() {
    try {
      val buffer = inputStream.source().buffer()

      setFeature(FEATURE_PROCESS_NAMESPACES, true)
      setInput(buffer.peek().inputStream(), null)

      // IntelliJ uses XmlFile/PsiFile to parse tag snapshots,
      // leaving XmlPullParser for Android to parse resources as usual
      // Here, we use the same XmlPullParser approach for both, which means
      // we need reinitialize the document stream between the two passes.
      val resourceParser = ResourceParser(buffer.inputStream())
      root = resourceParser.createTagSnapshot()

      // Obtain a list of all the aapt declared attributes
      declaredAaptAttrs = findDeclaredAaptAttrs(root)
    } catch (e: XmlPullParserException) {
      throw IOError(e)
    }
  }

  private constructor(aaptResource: TagSnapshot) : super() {
    root = aaptResource
    declaredAaptAttrs = emptyMap()
  }

  private val root: TagSnapshot
  private val declaredAaptAttrs: Map<String, TagSnapshot>

  private var layoutNamespace = ResourceNamespace.RES_AUTO

  override fun rootTag() = root

  override fun getViewCookie(): Any? {
    // TODO: Implement this properly.
    val name = super.getName() ?: return null

    // Store tools attributes if this looks like a layout we'll need adapter view
    // bindings for in the LayoutlibCallback.
    if (LIST_VIEW == name || EXPANDABLE_LIST_VIEW == name || GRID_VIEW == name || SPINNER == name) {
      var map: MutableMap<String, String>? = null
      val count = attributeCount
      for (i in 0 until count) {
        val namespace = getAttributeNamespace(i)
        if (namespace != null && namespace == TOOLS_URI) {
          val attribute = getAttributeName(i)!!
          if (attribute == ATTR_IGNORE) {
            continue
          }
          if (map == null) {
            map = HashMap(4)
          }
          map[attribute] = getAttributeValue(i)!!
        }
      }

      return map
    }

    return null
  }

  override fun getLayoutNamespace(): ResourceNamespace = layoutNamespace

  override fun getAaptDeclaredAttrs(): Map<String, TagSnapshot> = declaredAaptAttrs

  fun setLayoutNamespace(layoutNamespace: ResourceNamespace) {
    this.layoutNamespace = layoutNamespace
  }

  private fun findDeclaredAaptAttrs(tag: TagSnapshot): Map<String, TagSnapshot> {
    if (!tag.hasDeclaredAaptAttrs) {
      // Nor tag or any of the children has any aapt:attr declarations, we can stop here.
      return emptyMap()
    }

    return buildMap {
      tag.attributes
        .filterIsInstance<AaptAttrSnapshot>()
        .forEach { attr ->
          val bundledTag = attr.bundledTag
          put(attr.id, bundledTag)
          for (child in bundledTag.children) {
            putAll(findDeclaredAaptAttrs(child))
          }
        }
      for (child in tag.children) {
        putAll(findDeclaredAaptAttrs(child))
      }
    }
  }

  companion object {
    @Throws(FileNotFoundException::class)
    fun createFromFile(layoutFile: File) = LayoutPullParser(FileInputStream(layoutFile))

    /**
     * @param layoutPath Must start with '/' and be relative to test resources.
     */
    fun createFromPath(layoutPath: String): LayoutPullParser {
      var layoutPath = layoutPath
      if (layoutPath.startsWith("/")) {
        layoutPath = layoutPath.substring(1)
      }

      return LayoutPullParser(
        LayoutPullParser::class.java.classLoader.getResourceAsStream(layoutPath)
      )
    }

    fun createFromString(contents: String): LayoutPullParser {
      return LayoutPullParser(
        ByteArrayInputStream(contents.toByteArray(Charset.forName("UTF-8")))
      )
    }

    fun createFromAaptResource(aaptResource: TagSnapshot): LayoutPullParser {
      return LayoutPullParser(aaptResource)
    }
  }
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

import com.android.SdkConstants.AAPT_URI
import com.android.SdkConstants.TAG_ATTR
import org.kxml2.io.KXmlParser
import java.io.InputStream

/**
 * An XML resource parser that creates a tree of [TagSnapshot]s
 */
class ResourceParser(inputStream: InputStream) : KXmlParser() {
  init {
    setFeature(FEATURE_PROCESS_NAMESPACES, true)
    setInput(inputStream, null)

    require(START_DOCUMENT, null, null)
    next()
  }

  fun createTagSnapshot(): TagSnapshot {
    require(START_TAG, null, null)

    // need to store now, since TagSnapshot is created on end tag after parser mark has moved
    val tagName = name
    val tagNamespace = namespace
    val prefix = prefix

    val attributes = createAttributesForTag()

    var hasDeclaredAaptAttrs = false
    var last: TagSnapshot? = null
    val children = mutableListOf<TagSnapshot>()
    while (eventType != END_DOCUMENT) {
      when (next()) {
        START_TAG -> {
          if (AAPT_URI == namespace) {
            if (TAG_ATTR == name) {
              val attrAttribute = createAttrTagSnapshot()
              if (attrAttribute != null) {
                attributes += attrAttribute
                hasDeclaredAaptAttrs = true
              }
            }
            // Since we save the aapt:attr tags as an attribute, we do not save them as a child element. Skip.
          } else {
            val child = createTagSnapshot()
            hasDeclaredAaptAttrs = hasDeclaredAaptAttrs || child.hasDeclaredAaptAttrs
            children += child
            if (last != null) {
              last.next = child
            }
            last = child
          }
        }
        END_TAG -> {
          return TagSnapshot(
            tagName,
            tagNamespace,
            prefix,
            attributes,
            children.toList(),
            hasDeclaredAaptAttrs
          )
        }
      }
    }

    throw IllegalStateException("We should never reach here")
  }

  private fun createAttrTagSnapshot(): AaptAttrSnapshot? {
    require(START_TAG, null, "attr")

    val name = getAttributeValue(null, "name") ?: return null
    val prefix = findPrefixByQualifiedName(name)
    val namespace = getNamespace(prefix)
    val localName = findLocalNameByQualifiedName(name)
    val id = (++uniqueId).toString()

    var bundleTagSnapshot: TagSnapshot? = null
    loop@ while (eventType != END_TAG) {
      when (nextTag()) {
        START_TAG -> {
          bundleTagSnapshot = createTagSnapshot()
        }
        END_TAG -> {
          break@loop
        }
      }
    }

    return if (bundleTagSnapshot != null) {
      // swallow end tag
      nextTag()
      require(END_TAG, null, "attr")

      AaptAttrSnapshot(namespace, prefix, localName, id, bundleTagSnapshot)
    } else {
      null
    }
  }

  private fun findPrefixByQualifiedName(name: String): String {
    val prefixEnd = name.indexOf(':')
    return if (prefixEnd > 0) {
      name.substring(0, prefixEnd)
    } else ""
  }

  private fun findLocalNameByQualifiedName(name: String): String {
    return name.substring(name.indexOf(':') + 1)
  }

  private fun createAttributesForTag(): MutableList<AttributeSnapshot> {
    return buildList {
      for (i in 0 until attributeCount) {
        add(
          AttributeSnapshot(
            getAttributeNamespace(i),
            getAttributePrefix(i),
            getAttributeName(i),
            getAttributeValue(i)
          )
        )
      }
    }.toMutableList()
  }

  companion object {
    private var uniqueId = 0L
  }
}

/*
 * Copyright (C) 2021 Square, Inc.
 *
 * 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 app.cash.paparazzi.internal.parsers

/**
 * Derived from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-master-dev:android/src/com/android/tools/idea/rendering/parsers/TagSnapshot.java
 *
 * A snapshot of the state of an xml tag.
 *
 * Used by the rendering architecture to be able to hold a consistent view of
 * the layout files across a long rendering operation without holding read locks,
 * as well as to for example let the property sheet evaluate and paint the values
 * of properties as they were at the time of rendering, not as they are at the current
 * instant.
 */
data class TagSnapshot(
  val name: String,
  val namespace: String,
  val prefix: String?,
  val attributes: List<AttributeSnapshot>,
  val children: List<TagSnapshot>,
  val hasDeclaredAaptAttrs: Boolean = false
) {
  var next: TagSnapshot? = null

  @Suppress("unused") // Used for debugging
  fun printFormatted(): String {
    indent++
    val output = """
      |$name:
      |${pad(indent + 1)}attributes: ${print(attributes)}
      |${pad(indent + 1)}children: ${print(children)}
    """.trimMargin()
    indent--
    return output
  }

  private fun print(children: List<*>): String {
    if (children.isEmpty()) return children.toString()

    indent++
    val output = children.joinToString(
      prefix = "[\n${pad(indent + 1)}",
      separator = "\n${pad(indent + 1)}",
      postfix = "\n${pad(indent)}]"
    )
    indent--
    return output
  }

  private fun pad(length: Int): String = "  ".repeat(length)

  companion object {
    var indent = -1
  }
}