1 /*
<lambda>null2 * Copyright (C) 2024 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.permissioncontroller.permission.ui.wear.elements.rotaryinput
18
19 import android.os.Build
20 import android.view.View
21 import androidx.compose.foundation.gestures.ScrollableState
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.LaunchedEffect
24 import androidx.compose.runtime.remember
25 import androidx.compose.ui.platform.LocalView
26 import kotlin.math.abs
27 import kotlinx.coroutines.Dispatchers
28 import kotlinx.coroutines.channels.BufferOverflow
29 import kotlinx.coroutines.channels.Channel
30 import kotlinx.coroutines.delay
31 import kotlinx.coroutines.flow.Flow
32 import kotlinx.coroutines.flow.conflate
33 import kotlinx.coroutines.flow.flow
34 import kotlinx.coroutines.flow.receiveAsFlow
35 import kotlinx.coroutines.withContext
36
37 // This file is a copy of Haptics.kt from Horologist (go/horologist),
38 // remove it once Wear Compose 1.4 is landed (b/325560444).
39
40 private const val DEBUG = false
41
42 /** Debug logging that can be enabled. */
43 private inline fun debugLog(generateMsg: () -> String) {
44 if (DEBUG) {
45 println("RotaryHaptics: ${generateMsg()}")
46 }
47 }
48
49 /**
50 * Throttling events within specified timeframe. Only first and last events will be received. For a
51 * flow emitting elements 1 to 30, with a 100ms delay between them:
52 * ```
53 * val flow = flow {
54 * for (i in 1..30) {
55 * delay(100)
56 * emit(i)
57 * }
58 * }
59 * ```
60 *
61 * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 .
62 */
<lambda>null63 internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> = flow {
64 conflate().collect {
65 emit(it)
66 delay(timeframe)
67 }
68 }
69
70 /** Handles haptics for rotary usage */
71 interface RotaryHapticHandler {
72
73 /** Handles haptics when scroll is used */
handleScrollHapticnull74 fun handleScrollHaptic(scrollDelta: Float)
75
76 /** Handles haptics when scroll with snap is used */
77 fun handleSnapHaptic(scrollDelta: Float)
78 }
79
80 /**
81 * Default implementation of [RotaryHapticHandler]. It handles haptic feedback based on the
82 * [scrollableState], scrolled pixels and [hapticsThresholdPx]. Haptic is not fired in this class,
83 * instead it's sent to [hapticsChannel] where it'll performed later.
84 *
85 * @param scrollableState Haptic performed based on this state
86 * @param hapticsChannel Channel to which haptic events will be sent
87 * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
88 */
89 class DefaultRotaryHapticHandler(
90 private val scrollableState: ScrollableState,
91 private val hapticsChannel: Channel<RotaryHapticsType>,
92 private val hapticsThresholdPx: Long = 50,
93 ) : RotaryHapticHandler {
94
95 private var overscrollHapticTriggered = false
96 private var currScrollPosition = 0f
97 private var prevHapticsPosition = 0f
98
99 override fun handleScrollHaptic(scrollDelta: Float) {
100 if (
101 (scrollDelta > 0 && !scrollableState.canScrollForward) ||
102 (scrollDelta < 0 && !scrollableState.canScrollBackward)
103 ) {
104 if (!overscrollHapticTriggered) {
105 trySendHaptic(RotaryHapticsType.ScrollLimit)
106 overscrollHapticTriggered = true
107 }
108 } else {
109 overscrollHapticTriggered = false
110 currScrollPosition += scrollDelta
111 val diff = abs(currScrollPosition - prevHapticsPosition)
112
113 if (diff >= hapticsThresholdPx) {
114 trySendHaptic(RotaryHapticsType.ScrollTick)
115 prevHapticsPosition = currScrollPosition
116 }
117 }
118 }
119
120 override fun handleSnapHaptic(scrollDelta: Float) {
121 if (
122 (scrollDelta > 0 && !scrollableState.canScrollForward) ||
123 (scrollDelta < 0 && !scrollableState.canScrollBackward)
124 ) {
125 if (!overscrollHapticTriggered) {
126 trySendHaptic(RotaryHapticsType.ScrollLimit)
127 overscrollHapticTriggered = true
128 }
129 } else {
130 overscrollHapticTriggered = false
131 trySendHaptic(RotaryHapticsType.ScrollItemFocus)
132 }
133 }
134
135 private fun trySendHaptic(rotaryHapticsType: RotaryHapticsType) {
136 // Ok to ignore the ChannelResult because we default to capacity = 2 and DROP_OLDEST
137 @Suppress("UNUSED_VARIABLE") val unused = hapticsChannel.trySend(rotaryHapticsType)
138 }
139 }
140
141 /** Interface for Rotary haptic feedback */
142 interface RotaryHapticFeedback {
performHapticFeedbacknull143 fun performHapticFeedback(type: RotaryHapticsType)
144 }
145
146 /** Rotary haptic types */
147 @JvmInline
148 value class RotaryHapticsType(private val type: Int) {
149 companion object {
150 /**
151 * A scroll ticking haptic. Similar to texture haptic - performed each time when a
152 * scrollable content is scrolled by a certain distance
153 */
154 val ScrollTick: RotaryHapticsType = RotaryHapticsType(1)
155
156 /**
157 * An item focus (snap) haptic. Performed when a scrollable content is snapped to a specific
158 * item.
159 */
160 val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2)
161
162 /**
163 * A limit(overscroll) haptic. Performed when a list reaches the limit (start or end) and
164 * can't scroll further
165 */
166 val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3)
167 }
168 }
169
170 /** Remember disabled haptics handler */
171 @Composable
<lambda>null172 fun rememberDisabledHaptic(): RotaryHapticHandler = remember {
173 object : RotaryHapticHandler {
174
175 override fun handleScrollHaptic(scrollDelta: Float) {
176 // Do nothing
177 }
178
179 override fun handleSnapHaptic(scrollDelta: Float) {
180 // Do nothing
181 }
182 }
183 }
184
185 /**
186 * Remember rotary haptic handler.
187 *
188 * @param scrollableState A scrollableState, used to determine whether the end of the scrollable was
189 * reached or not.
190 * @param throttleThresholdMs Throttling events within specified timeframe. Only first and last
191 * events will be received. Check [throttleLatest] for more info.
192 * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
193 * @param hapticsChannel Channel to which haptic events will be sent
194 * @param rotaryHaptics Interface for Rotary haptic feedback which performs haptics
195 */
196 @Composable
rememberRotaryHapticHandlernull197 fun rememberRotaryHapticHandler(
198 scrollableState: ScrollableState,
199 throttleThresholdMs: Long = 30,
200 hapticsThresholdPx: Long = 50,
201 hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel(),
202 rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback(),
203 ): RotaryHapticHandler {
204 return remember(scrollableState, hapticsChannel, rotaryHaptics) {
205 DefaultRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx)
206 }
207 .apply {
208 LaunchedEffect(hapticsChannel) {
209 hapticsChannel.receiveAsFlow().throttleLatest(throttleThresholdMs).collect {
210 hapticType ->
211 // 'withContext' launches performHapticFeedback in a separate thread,
212 // as otherwise it produces a visible lag (b/219776664)
213 val currentTime = System.currentTimeMillis()
214 debugLog { "Haptics started" }
215 withContext(Dispatchers.Default) {
216 debugLog {
217 "Performing haptics, delay: " +
218 "${System.currentTimeMillis() - currentTime}"
219 }
220 rotaryHaptics.performHapticFeedback(hapticType)
221 }
222 }
223 }
224 }
225 }
226
227 @Composable
<lambda>null228 private fun rememberHapticChannel() = remember {
229 Channel<RotaryHapticsType>(
230 capacity = 2,
231 onBufferOverflow = BufferOverflow.DROP_OLDEST,
232 )
233 }
234
235 @Composable
rememberDefaultRotaryHapticFeedbacknull236 public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback =
237 LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } }
238
findDeviceSpecificHapticFeedbacknull239 internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback =
240 if (isSamsungWatch()) {
241 SamsungWatchHapticFeedback(view)
242 } else {
243 DefaultRotaryHapticFeedback(view)
244 }
245
246 /** Default Rotary implementation for [RotaryHapticFeedback] */
247 class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
248
performHapticFeedbacknull249 override fun performHapticFeedback(
250 type: RotaryHapticsType,
251 ) {
252 when (type) {
253 RotaryHapticsType.ScrollItemFocus -> {
254 view.performHapticFeedback(SCROLL_ITEM_FOCUS)
255 }
256 RotaryHapticsType.ScrollTick -> {
257 view.performHapticFeedback(SCROLL_TICK)
258 }
259 RotaryHapticsType.ScrollLimit -> {
260 view.performHapticFeedback(SCROLL_LIMIT)
261 }
262 }
263 }
264
265 private companion object {
266 // Hidden constants from HapticFeedbackConstants
267 const val SCROLL_TICK: Int = 18
268 const val SCROLL_ITEM_FOCUS: Int = 19
269 const val SCROLL_LIMIT: Int = 20
270 }
271 }
272
273 /** Implementation of [RotaryHapticFeedback] for Samsung devices */
274 private class SamsungWatchHapticFeedback(private val view: View) : RotaryHapticFeedback {
performHapticFeedbacknull275 override fun performHapticFeedback(
276 type: RotaryHapticsType,
277 ) {
278 when (type) {
279 RotaryHapticsType.ScrollItemFocus -> {
280 view.performHapticFeedback(102)
281 }
282 RotaryHapticsType.ScrollTick -> {
283 view.performHapticFeedback(102)
284 }
285 RotaryHapticsType.ScrollLimit -> {
286 view.performHapticFeedback(50107)
287 }
288 }
289 }
290 }
291
isSamsungWatchnull292 private fun isSamsungWatch(): Boolean = Build.MANUFACTURER.contains("Samsung", ignoreCase = true)
293