Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions app/lint-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.0.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.0.1)" variant="all" version="9.0.1">
<issues format="6" by="lint 9.1.0" type="baseline" client="gradle" dependencies="false" name="AGP (9.1.0)" variant="all" version="9.1.0">

<issue
id="MissingPermission"
Expand Down Expand Up @@ -140,7 +140,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsProviderService.kt"
line="46"
line="47"
column="30"/>
</issue>

Expand Down Expand Up @@ -1183,12 +1183,12 @@
<issue
id="AvoidAnySerializer"
message="Prefer polymorphic serializer over AnySerializer."
errorLine1=" MapAnySerializer,"
errorLine2=" ~~~~~~~~~~~~~~~~">
errorLine1=" MapAnySerializer,"
errorLine2=" ~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt"
line="317"
column="25"/>
line="318"
column="21"/>
</issue>

<issue
Expand Down Expand Up @@ -1341,7 +1341,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/io/homeassistant/companion/android/settings/assist/AssistSettingsScreen.kt"
line="375"
line="381"
column="22"/>
</issue>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorScreen
import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider
import io.homeassistant.companion.android.frontend.externalbus.WebViewScript
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import io.homeassistant.companion.android.frontend.haptic.HapticFeedbackPerformer
import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt
import io.homeassistant.companion.android.frontend.permissions.PendingWebViewPermissionRequest
import io.homeassistant.companion.android.frontend.permissions.WebViewPermissionEffect
Expand Down Expand Up @@ -112,6 +114,7 @@ internal fun FrontendScreen(
webChromeClient = viewModel.webChromeClient,
frontendJsCallback = viewModel.frontendJsCallback,
scriptsToEvaluate = viewModel.scriptsToEvaluate,
hapticEvents = viewModel.hapticEvents,
pendingWebViewPermission = pendingWebViewPermission,
onWebViewPermissionResult = viewModel::onWebViewPermissionResult,
onBlockInsecureRetry = viewModel::onRetry,
Expand Down Expand Up @@ -150,6 +153,7 @@ internal fun FrontendScreenContent(
onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
onWebViewCreationFailed: (Throwable) -> Unit,
modifier: Modifier = Modifier,
hapticEvents: Flow<HapticType> = emptyFlow(),
pendingWebViewPermission: PendingWebViewPermissionRequest? = null,
onWebViewPermissionResult: (Map<String, Boolean>) -> Unit = {},
errorStateProvider: FrontendConnectionErrorStateProvider = FrontendConnectionErrorStateProvider.noOp,
Expand All @@ -164,6 +168,7 @@ internal fun FrontendScreenContent(
webView = webView,
url = viewState.url,
scriptsToEvaluate = scriptsToEvaluate,
hapticEvents = hapticEvents,
)

WebViewPermissionEffect(
Expand Down Expand Up @@ -444,10 +449,15 @@ private fun Color.Overlay(modifier: Modifier = Modifier) {
}

/**
* Handles WebView side effects: URL loading and script evaluation.
* Handles WebView side effects: URL loading, script evaluation, haptics.
*/
@Composable
private fun WebViewEffects(webView: WebView?, url: String, scriptsToEvaluate: Flow<WebViewScript>) {
private fun WebViewEffects(
webView: WebView?,
url: String,
scriptsToEvaluate: Flow<WebViewScript>,
hapticEvents: Flow<HapticType>,
) {
if (webView != null) {
LaunchedEffect(webView, url) {
Timber.v("Load url ${sensitive(url)}")
Expand All @@ -461,6 +471,11 @@ private fun WebViewEffects(webView: WebView?, url: String, scriptsToEvaluate: Fl
}
}
}
LaunchedEffect(webView) {
hapticEvents.collect { hapticType ->
HapticFeedbackPerformer.perform(webView, hapticType)
}
}
Comment on lines +474 to +478
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LaunchedEffect is keyed only on webView, but it captures/collects hapticEvents. If hapticEvents changes across recompositions (eg a new ViewModel instance while the same WebView is kept), this effect will keep collecting the old Flow and haptics may stop working (and the old collector may leak). Key the effect on both webView and hapticEvents (or use rememberUpdatedState for the Flow) so the collector restarts when the Flow reference changes.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is valid to key on the webview but happy to discuss with @jpelgrom

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.homeassistant.companion.android.common.data.connectivity.ConnectivityC
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider
import io.homeassistant.companion.android.frontend.externalbus.WebViewScript
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent
import io.homeassistant.companion.android.frontend.handler.FrontendMessageHandler
import io.homeassistant.companion.android.frontend.navigation.FrontendNavigationEvent
Expand Down Expand Up @@ -137,6 +138,9 @@ internal class FrontendViewModel @VisibleForTesting constructor(
private val _navigationEvents = MutableSharedFlow<FrontendNavigationEvent>(extraBufferCapacity = 1)
val navigationEvents: SharedFlow<FrontendNavigationEvent> = _navigationEvents.asSharedFlow()

private val _hapticEvents = MutableSharedFlow<HapticType>(extraBufferCapacity = 16)
val hapticEvents: SharedFlow<HapticType> = _hapticEvents.asSharedFlow()

override val urlFlow: StateFlow<String?> =
_viewState.map { it.url }
.distinctUntilChanged()
Expand Down Expand Up @@ -335,6 +339,10 @@ internal class FrontendViewModel @VisibleForTesting constructor(
)
}

is FrontendHandlerEvent.PerformHaptic -> {
_hapticEvents.tryEmit(result.hapticType)
}

is FrontendHandlerEvent.AuthError -> {
onError(result.error)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.frontend.externalbus

import io.homeassistant.companion.android.common.util.kotlinJsonMapper
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import io.homeassistant.companion.android.frontend.externalbus.incoming.IncomingExternalBusMessage
import io.homeassistant.companion.android.frontend.externalbus.outgoing.OutgoingExternalBusMessage
import io.homeassistant.companion.android.util.sensitive
Expand All @@ -26,6 +27,7 @@ private const val BUFFER_CAPACITY = 10
val frontendExternalBusJson = Json(kotlinJsonMapper) {
namingStrategy = null
serializersModule += IncomingExternalBusMessage.serializersModule
serializersModule += HapticType.serializersModule
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.homeassistant.companion.android.frontend.externalbus.incoming

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
import kotlinx.serialization.modules.SerializersModule

/**
* Represents the haptic feedback types sent by the Home Assistant frontend.
*
* Each type maps to a specific Android haptic feedback constant or vibration pattern.
* The frontend sends these as string identifiers via the external bus `haptic` message.
*
* Unknown haptic types from newer frontend versions are deserialized as [Unknown]
* instead of failing, allowing graceful forward compatibility.
*
* @see <a href="https://developers.home-assistant.io/docs/frontend/external-bus/#trigger-haptic-haptic">Haptic feedback documentation</a>
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("hapticType")
sealed interface HapticType {
@Serializable
@SerialName("success")
data object Success : HapticType

@Serializable
@SerialName("warning")
data object Warning : HapticType

@Serializable
@SerialName("failure")
data object Failure : HapticType

@Serializable
@SerialName("light")
data object Light : HapticType

@Serializable
@SerialName("medium")
data object Medium : HapticType

@Serializable
@SerialName("heavy")
data object Heavy : HapticType

@Serializable
@SerialName("selection")
data object Selection : HapticType

@Serializable data object Unknown : HapticType

companion object {
internal val serializersModule = SerializersModule {
polymorphicDefaultDeserializer(HapticType::class) { Unknown.serializer() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,14 @@ data class OpenAssistPayload(
@SerialName("pipeline_id") val pipelineId: String? = null,
@SerialName("start_listening") val startListening: Boolean = true,
)

/**
* Message requesting haptic feedback from the Home Assistant frontend.
*
* Sent when the user interacts with UI elements in the frontend that provide
* tactile feedback (e.g., toggling a switch, long-pressing an entity).
* This is a fire-and-forget message — no response is expected.
*/
@Serializable
@SerialName("haptic")
data class HapticMessage(override val id: Int? = null, val payload: HapticType) : IncomingExternalBusMessage
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.frontend.handler

import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType

/**
* Events emitted by [FrontendMessageHandler].
Expand Down Expand Up @@ -45,6 +46,11 @@ sealed interface FrontendHandlerEvent {
*/
data object ThemeUpdated : FrontendHandlerEvent

/**
* Frontend requested haptic feedback.
*/
data class PerformHaptic(val hapticType: HapticType) : FrontendHandlerEvent

/**
* Received an unrecognized message type from the frontend.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalB
import io.homeassistant.companion.android.frontend.externalbus.WebViewScript
import io.homeassistant.companion.android.frontend.externalbus.incoming.ConfigGetMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.ConnectionStatusMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.IncomingExternalBusMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistSettingsMessage
Expand Down Expand Up @@ -183,6 +184,10 @@ class FrontendMessageHandler @Inject constructor(
FrontendHandlerEvent.ThemeUpdated
}

is HapticMessage -> {
FrontendHandlerEvent.PerformHaptic(message.payload)
}

is UnknownIncomingMessage -> {
Timber.d("Unknown message type received: ${message.content}")
FrontendHandlerEvent.UnknownMessage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package io.homeassistant.companion.android.frontend.haptic

import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.view.HapticFeedbackConstants
import android.view.View
import androidx.core.content.getSystemService
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import timber.log.Timber

private val SUCCESS_FALLBACK_DURATION = 500.milliseconds
private val FAILURE_FALLBACK_DURATION = 1.seconds
private val WARNING_FALLBACK_DURATION = 1.5.seconds
private val SELECTION_FALLBACK_DURATION = 50.milliseconds

/**
* Performs haptic feedback on a [View] based on a [HapticType].
*
* Uses `View.performHapticFeedback()` with semantic constants where available,
* falling back to `Vibrator` patterns for API levels that lack the specific constant.
*/
object HapticFeedbackPerformer {

/**
* Performs the haptic feedback corresponding to [hapticType] on the given [view].
*
* Uses `View.performHapticFeedback` for types that have a matching `HapticFeedbackConstants`
* value on the current API level, and falls back to `Vibrator` for types that require it
* (e.g., `warning` always uses Vibrator, `success`/`failure`/`selection` fall back to
* Vibrator on pre-API 30).
*/
fun perform(view: View, hapticType: HapticType) {
Timber.d("Performing haptic feedback: $hapticType")
when (hapticType) {
is HapticType.Success -> performSuccess(view)
is HapticType.Warning -> performWarning(view)
is HapticType.Failure -> performFailure(view)
is HapticType.Light -> view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
is HapticType.Medium -> view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
is HapticType.Heavy -> view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
is HapticType.Selection -> performSelection(view)
is HapticType.Unknown -> Timber.w("Ignoring unknown haptic type")
}
}

private fun performSuccess(view: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
} else {
vibrate(view, duration = SUCCESS_FALLBACK_DURATION)
}
}

private fun performWarning(view: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
view.context.getSystemService<Vibrator>()?.vibrate(
VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK),
)
} else {
vibrate(view, duration = WARNING_FALLBACK_DURATION)
}
}

private fun performFailure(view: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.performHapticFeedback(HapticFeedbackConstants.REJECT)
} else {
vibrate(view, duration = FAILURE_FALLBACK_DURATION)
}
}

private fun performSelection(view: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
view.performHapticFeedback(HapticFeedbackConstants.GESTURE_START)
} else {
vibrate(view, duration = SELECTION_FALLBACK_DURATION)
}
}

@Suppress("DEPRECATION")
private fun vibrate(view: View, duration: Duration) {
view.context.getSystemService<Vibrator>()?.vibrate(duration.inWholeMilliseconds)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1383,7 +1383,7 @@ class WebViewActivity :

"warning" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vm?.vibrate(VibrationEffect.createOneShot(400, VibrationEffect.EFFECT_HEAVY_CLICK))
vm?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
} else {
@Suppress("DEPRECATION")
vm?.vibrate(1500)
Expand Down
Loading
Loading