• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.launcher3.graphics
18 
19 import android.graphics.Matrix
20 import android.graphics.Matrix.ScaleToFit.FILL
21 import android.graphics.Path
22 import android.graphics.Path.Direction
23 import android.graphics.Rect
24 import android.graphics.RectF
25 import android.graphics.Region
26 import android.platform.uiautomatorhelpers.DeviceHelpers.context
27 import android.view.View
28 import androidx.core.graphics.PathParser
29 import androidx.test.ext.junit.runners.AndroidJUnit4
30 import androidx.test.filters.SmallTest
31 import com.android.launcher3.graphics.ShapeDelegate.Circle
32 import com.android.launcher3.graphics.ShapeDelegate.Companion.AREA_CALC_SIZE
33 import com.android.launcher3.graphics.ShapeDelegate.Companion.AREA_DIFF_THRESHOLD
34 import com.android.launcher3.graphics.ShapeDelegate.Companion.areaDiffCalculator
35 import com.android.launcher3.graphics.ShapeDelegate.Companion.pickBestShape
36 import com.android.launcher3.graphics.ShapeDelegate.GenericPathShape
37 import com.android.launcher3.graphics.ShapeDelegate.RoundedSquare
38 import com.android.launcher3.icons.GraphicsUtils
39 import com.android.launcher3.views.ClipPathView
40 import com.google.common.truth.Truth.assertThat
41 import org.junit.Test
42 import org.junit.runner.RunWith
43 
44 @SmallTest
45 @RunWith(AndroidJUnit4::class)
46 class ShapeDelegateTest {
47 
48     @Test
areaDiffCalculator increases with outwards shapenull49     fun `areaDiffCalculator increases with outwards shape`() {
50         val diffCalculator =
51             areaDiffCalculator(
52                 Path().apply {
53                     addCircle(
54                         AREA_CALC_SIZE / 2f,
55                         AREA_CALC_SIZE / 2f,
56                         AREA_CALC_SIZE / 2f,
57                         Direction.CW,
58                     )
59                 }
60             )
61         assertThat(diffCalculator(Circle())).isLessThan(AREA_DIFF_THRESHOLD)
62         assertThat(diffCalculator(Circle())).isLessThan(diffCalculator(RoundedSquare(.9f)))
63         assertThat(diffCalculator(RoundedSquare(.9f)))
64             .isLessThan(diffCalculator(RoundedSquare(.8f)))
65         assertThat(diffCalculator(RoundedSquare(.8f)))
66             .isLessThan(diffCalculator(RoundedSquare(.7f)))
67         assertThat(diffCalculator(RoundedSquare(.7f)))
68             .isLessThan(diffCalculator(RoundedSquare(.6f)))
69         assertThat(diffCalculator(RoundedSquare(.6f)))
70             .isLessThan(diffCalculator(RoundedSquare(.5f)))
71     }
72 
73     @Test
areaDiffCalculator increases with inwards shapenull74     fun `areaDiffCalculator increases with inwards shape`() {
75         val diffCalculator = areaDiffCalculator(roundedRectPath(0.5f))
76         assertThat(diffCalculator(RoundedSquare(.5f))).isLessThan(AREA_DIFF_THRESHOLD)
77         assertThat(diffCalculator(RoundedSquare(.5f)))
78             .isLessThan(diffCalculator(RoundedSquare(.6f)))
79         assertThat(diffCalculator(RoundedSquare(.5f)))
80             .isLessThan(diffCalculator(RoundedSquare(.4f)))
81     }
82 
83     @Test
pickBestShape picks circlenull84     fun `pickBestShape picks circle`() {
85         val r = AREA_CALC_SIZE / 2
86         val pathStr = "M 50 0 a 50 50 0 0 1 0 100 a 50 50 0 0 1 0 -100"
87         val path = Path().apply { addCircle(r.toFloat(), r.toFloat(), r.toFloat(), Direction.CW) }
88         assertThat(pickBestShape(path, pathStr)).isInstanceOf(Circle::class.java)
89     }
90 
91     @Test
pickBestShape picks rounded rectnull92     fun `pickBestShape picks rounded rect`() {
93         val factor = 0.5f
94         var shape = pickBestShape(roundedRectPath(factor), roundedRectString(factor))
95         assertThat(shape).isInstanceOf(RoundedSquare::class.java)
96         assertThat((shape as RoundedSquare).radiusRatio).isEqualTo(factor)
97 
98         val factor2 = 0.2f
99         shape = pickBestShape(roundedRectPath(factor2), roundedRectString(factor2))
100         assertThat(shape).isInstanceOf(RoundedSquare::class.java)
101         assertThat((shape as RoundedSquare).radiusRatio).isEqualTo(factor2)
102     }
103 
104     @Test
pickBestShape picks generic shapenull105     fun `pickBestShape picks generic shape`() {
106         val path = cookiePath(Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE))
107         val pathStr = FOUR_SIDED_COOKIE
108         val shape = pickBestShape(path, pathStr)
109         assertThat(shape).isInstanceOf(GenericPathShape::class.java)
110 
111         val diffCalculator = areaDiffCalculator(path)
112         assertThat(diffCalculator(shape)).isLessThan(AREA_DIFF_THRESHOLD)
113     }
114 
115     @Test
generic shape creates smooth animationnull116     fun `generic shape creates smooth animation`() {
117         val shape = GenericPathShape(FOUR_SIDED_COOKIE)
118         val target = TestClipView()
119         val anim =
120             shape.createRevealAnimator(
121                 target,
122                 Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE),
123                 Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE),
124                 AREA_CALC_SIZE * .25f,
125                 false,
126             )
127 
128         // Verify that the start rect is similar to initial path
129         anim.setCurrentFraction(0f)
130         assertThat(
131                 getAreaDiff(
132                     target.currentClip!!,
133                     cookiePath(Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE)),
134                 )
135             )
136             .isLessThan(AREA_CALC_SIZE)
137 
138         // Verify that end rect is similar to end path
139         anim.setCurrentFraction(1f)
140         assertThat(getAreaDiff(target.currentClip!!, roundedRectPath(0.5f)))
141             .isLessThan(AREA_CALC_SIZE)
142 
143         // Ensure that when running animation, area increases smoothly. We run the animation over
144         // [steps] and verify increase of max 5 times the linear diff increase
145         val steps = 1000
146         val incrementalDiff =
147             getAreaDiff(
148                 cookiePath(Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE)),
149                 roundedRectPath(0.5f),
150             ) * 5 / steps
151         var lastPath = cookiePath(Rect(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE))
152         for (progress in 1..steps) {
153             anim.setCurrentFraction(progress / 1000f)
154             val currentPath = Path(target.currentClip!!)
155             assertThat(getAreaDiff(lastPath, currentPath)).isLessThan(incrementalDiff)
156             lastPath = currentPath
157         }
158         assertThat(getAreaDiff(lastPath, roundedRectPath(0.5f))).isLessThan(AREA_CALC_SIZE)
159     }
160 
roundedRectPathnull161     private fun roundedRectPath(factor: Float) =
162         Path().apply {
163             val r = factor * AREA_CALC_SIZE / 2
164             addRoundRect(
165                 0f,
166                 0f,
167                 AREA_CALC_SIZE.toFloat(),
168                 AREA_CALC_SIZE.toFloat(),
169                 r,
170                 r,
171                 Direction.CW,
172             )
173         }
174 
roundedRectStringnull175     private fun roundedRectString(factor: Float): String {
176         val s = 100f
177         val r = (factor * s / 2)
178         val t = s - r
179         return "M $r 0 " +
180             "L $t 0 " +
181             "A $r $r 0 0 1 $s $r " +
182             "L $s $t " +
183             "A $r $r 0 0 1 $t $s " +
184             "L $r $s " +
185             "A $r $r 0 0 1 0 $t " +
186             "L 0 $r " +
187             "A $r $r 0 0 1 $r 0 Z"
188     }
189 
getAreaDiffnull190     private fun getAreaDiff(p1: Path, p2: Path): Int {
191         val fullRegion = Region(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE)
192         val iconRegion = Region().apply { setPath(p1, fullRegion) }
193         val shapeRegion = Region().apply { setPath(p2, fullRegion) }
194         shapeRegion.op(iconRegion, Region.Op.XOR)
195         return GraphicsUtils.getArea(shapeRegion)
196     }
197 
198     class TestClipView : View(context), ClipPathView {
199 
200         var currentClip: Path? = null
201 
setClipPathnull202         override fun setClipPath(clipPath: Path?) {
203             currentClip = clipPath
204         }
205     }
206 
207     companion object {
208         const val FOUR_SIDED_COOKIE =
209             "M63.605 3C84.733 -6.176 106.176 15.268 97 36.395L95.483 39.888C92.681 46.338 92.681 53.662 95.483 60.112L97 63.605C106.176 84.732 84.733 106.176 63.605 97L60.112 95.483C53.662 92.681 46.338 92.681 39.888 95.483L36.395 97C15.267 106.176 -6.176 84.732 3 63.605L4.517 60.112C7.319 53.662 7.319 46.338 4.517 39.888L3 36.395C -6.176 15.268 15.267 -6.176 36.395 3L39.888 4.517C46.338 7.319 53.662 7.319 60.112 4.517L63.605 3Z"
210 
cookiePathnull211         private fun cookiePath(bounds: Rect) =
212             PathParser.createPathFromPathData(FOUR_SIDED_COOKIE).apply {
213                 transform(
214                     Matrix().apply { setRectToRect(RectF(0f, 0f, 100f, 100f), RectF(bounds), FILL) }
215                 )
216             }
217     }
218 }
219