How to add haptics to Button in home screen widget (Glance API) in android

31 Views Asked by At

I'm trying to make a homescreen widget (simply a button) that cycles through two wallpapers i have, and I want to add haptic feedback to it. I read the glance APIs have limited compose functionality so calling a function that triggers haptic feedback is not working in the below example. but I saw my Nothing phone's compass widget that gives haptic feedback when facing north, so I know it's at least possible. could you please guide me through this on how I can achieve this effect, kinda new to Android development.

// desktop widget to quickly toggle wallpaper
object WallChangeWidget : GlanceAppWidget() {
    val wallVariant = intPreferencesKey("wallVariant")
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            Button(text = "click", onClick = hapticsAndThenAction())
        }
    }

    @Composable
    fun hapticsAndThenAction(): Action {
        val haptic = LocalHapticFeedback.current
        haptic.performHapticFeedback(HapticFeedbackType.LongPress)
        return actionRunCallback(ChangeWallActionCallback::class.java)
    }
}

here's the ActionCallback that is triggered to swap the wallpapers

object ChangeWallActionCallback : ActionCallback {
    @RequiresApi(Build.VERSION_CODES.R)
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {
        updateAppWidgetState(context, glanceId) { prefs ->
            val currentCount = prefs[WallChangeWidget.wallVariant]
            val x = WallpaperManager.getInstance(context)
            if (currentCount != 1) {
                val bmp: Bitmap =
                    BitmapFactory.decodeFile("/storage/emulated/0/Media/Pictures/WallSwitch/two.png")
                x.setBitmap(bmp)
                prefs[WallChangeWidget.wallVariant] = 1
            } else {

                val bmp: Bitmap =
                    BitmapFactory.decodeFile("/storage/emulated/0/Media/Pictures/WallSwitch/one.png")
                x.setBitmap(bmp)
//                x.clear()
                prefs[WallChangeWidget.wallVariant] = 0
            }
        }
    }
}

the above code gives me the following exception, stating that of course this is not possible as LocalHapticFeedback is not present.

2024-03-23 21:56:31.511 32147-32183 WM-WorkerWrapper        com.sliya.np.ext.wallswitch          E  Work [ id=0f9eb632-47c1-4e38-bd06-86acbf66c988, tags={ androidx.glance.session.SessionWorker } ] failed because it threw an exception/error
java.util.concurrent.ExecutionException: java.lang.IllegalStateException: CompositionLocal LocalHapticFeedback not present
at androidx.work.impl.utils.futures.AbstractFuture.getDoneValue(AbstractFuture.java:516)
at androidx.work.impl.utils.futures.AbstractFuture.get(AbstractFuture.java:475)
at androidx.work.impl.WorkerWrapper$2.run(WorkerWrapper.java:311)
at androidx.work.impl.utils.SerialExecutor$Task.run(SerialExecutor.java:91)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
at java.lang.Thread.run(Thread.java:1012)
Caused by: java.lang.IllegalStateException: CompositionLocal LocalHapticFeedback not present
at androidx.compose.ui.platform.CompositionLocalsKt.noLocalProvidedFor(CompositionLocals.kt:220)
at androidx.compose.ui.platform.CompositionLocalsKt.access$noLocalProvidedFor(CompositionLocals.kt:1)
at androidx.compose.ui.platform.CompositionLocalsKt$LocalHapticFeedback$1.invoke(CompositionLocals.kt:117)
at androidx.compose.ui.platform.CompositionLocalsKt$LocalHapticFeedback$1.invoke(CompositionLocals.kt:116)
at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74)
at androidx.compose.runtime.LazyValueHolder.getCurrent(ValueHolders.kt:29)
at androidx.compose.runtime.LazyValueHolder.getValue(ValueHolders.kt:31)
at androidx.compose.runtime.CompositionLocalMapKt.read(CompositionLocalMap.kt:88)
at androidx.compose.runtime.ComposerImpl.consume(Composer.kt:2049)
at com.sliya.np.ext.wallswitch.WallChangeWidget.MyButton(WallChangeWidget.kt:90)
at com.sliya.np.ext.wallswitch.ComposableSingletons$WallChangeWidgetKt$lambda-1$1.invoke(WallChangeWidget.kt:34)
at com.sliya.np.ext.wallswitch.ComposableSingletons$WallChangeWidgetKt$lambda-1$1.invoke(WallChangeWidget.kt:33)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:108)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:35)
at androidx.glance.appwidget.SizeBoxKt$SizeBox$1.invoke(SizeBox.kt:127)
at androidx.glance.appwidget.SizeBoxKt$SizeBox$1.invoke(SizeBox.kt:74)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:108)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:35)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
at androidx.glance.appwidget.SizeBoxKt.SizeBox-IbIYxLY(SizeBox.kt:74)
at androidx.glance.appwidget.SizeBoxKt.ForEachSize-eVKgIn8(SizeBox.kt:114)
at androidx.glance.appwidget.AppWidgetSession$provideGlance$1$1.invoke(AppWidgetSession.kt:110)
at androidx.glance.appwidget.AppWidgetSession$provideGlance$1$1.invoke(AppWidgetSession.kt:90)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:108)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:35)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:228)
at androidx.glance.appwidget.AppWidgetSession$provideGlance$1.invoke(AppWidgetSession.kt:85)
at androidx.glance.appwidget.AppWidgetSession$provideGlance$1.invoke(AppWidgetSession.kt:84)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:108)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.jvm.kt:35)
at androidx.compose.runtime.RecomposeScopeImpl.compose(RecomposeScopeImpl.kt:169)
at androidx.compose.runtime.ComposerImpl.recomposeToGroupEnd(Composer.kt:2468)
at androidx.compose.runtime.ComposerImpl.skipCurrentGroup(Composer.kt:2737)
at androidx.compose.runtime.ComposerImpl.doCompose(Composer.kt:3352)
at androidx.compose.runtime.ComposerImpl.recompose$runtime_release(Composer.kt:3303)
2024-03-23 21:56:31.517 32147-32183 WM-WorkerWrapper        com.sliya.np.ext.wallswitch          E  at androidx.compose.runtime.CompositionImpl.recompose(Composition.kt:781)
at androidx.compose.runtime.Recomposer.performRecompose(Recomposer.kt:1097)
at androidx.compose.runtime.Recomposer.access$performRecompose(Recomposer.kt:124)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:569)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke(Recomposer.kt:537)
at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42)
at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71)
at androidx.glance.session.InteractiveFrameClock.sendFrame(InteractiveFrameClock.kt:127)
at androidx.glance.session.InteractiveFrameClock.access$sendFrame(InteractiveFrameClock.kt:39)
at androidx.glance.session.InteractiveFrameClock$onNewAwaiters$2.invokeSuspend(InteractiveFrameClock.kt:117)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at android.os.Handler.handleCallback(Handler.java:958)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:232)
at android.os.Looper.loop(Looper.java:334)
at android.app.ActivityThread.main(ActivityThread.java:8293)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:578)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1053)
2

There are 2 best solutions below

0
Boby On BEST ANSWER

deriving from @CommonsWave's answer calling vibrate from ActionCallback worked.

object ChangeWallActionCallback : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {

     
        val vibrator = context.getSystemService(Vibrator::class.java)
        vibrator.vibrate(
            VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK),
            VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build()
        )

// other stuff

}
2
CommonsWare On

Is there no other way for me to do haptics for home screen widgets?

For anything that uses a PendingIntent to trigger logic in your app code, you could try to use the Vibrator system service to provide haptic feedback, perhaps in conjunction with VibrationEffect. With Glance, PendingIntent is use for lambdas like your onClick one for your Button.

By eyeball, you could try:

    @Composable
    fun hapticsAndThenAction(): Action {
        val vibrator = context.getSystemService(Vibrator::class.java)

        // Requires VIBRATE permission
        vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))

        return actionRunCallback(ChangeWallActionCallback::class.java)
    }