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