custom linear haptics

This commit is contained in:
Boof2015
2026-07-13 02:20:57 -04:00
parent a34d9cba89
commit 261d095090
42 changed files with 1569 additions and 89 deletions
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.astrahaptics'
version = '0.1.0'
android {
namespace "expo.modules.astrahaptics"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>
@@ -0,0 +1,163 @@
package expo.modules.astrahaptics
import android.content.Context
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.provider.Settings
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
class HapticCompositionStepRecord : Record {
@Field
val primitive: String = ""
@Field
val scale: Float = 1f
@Field
val delayMs: Int = 0
}
private data class PrimitiveSpec(
val id: Int,
val minApi: Int,
)
class AstraHapticsModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val vibrator: Vibrator
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
override fun definition() = ModuleDefinition {
Name("AstraHaptics")
Function("getCapabilities") {
capabilities()
}
Function("isTouchFeedbackEnabled") {
touchFeedbackEnabled()
}
Function("playComposition") { steps: List<HapticCompositionStepRecord> ->
playComposition(steps)
}
}
private fun capabilities(): Map<String, Any> {
val currentVibrator = vibrator
val canCompose = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && currentVibrator.hasVibrator()
val primitiveCapabilities = linkedMapOf<String, Map<String, Any>>()
PRIMITIVES.entries.forEach { entry ->
val availableOnApi = Build.VERSION.SDK_INT >= entry.value.minApi
val supported = canCompose && availableOnApi &&
currentVibrator.areAllPrimitivesSupported(entry.value.id)
val duration = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && supported) {
currentVibrator.getPrimitiveDurations(entry.value.id).firstOrNull() ?: 0
} else {
0
}
primitiveCapabilities[entry.key] = mapOf(
"supported" to supported,
"durationMs" to duration,
)
}
return mapOf(
"apiLevel" to Build.VERSION.SDK_INT,
"hasVibrator" to currentVibrator.hasVibrator(),
"hasAmplitudeControl" to (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && currentVibrator.hasAmplitudeControl()
),
"touchFeedbackEnabled" to touchFeedbackEnabled(),
"primitives" to primitiveCapabilities,
)
}
private fun playComposition(steps: List<HapticCompositionStepRecord>): Boolean {
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
steps.isEmpty() ||
steps.size > MAX_STEPS ||
!vibrator.hasVibrator() ||
!touchFeedbackEnabled()
) {
return false
}
val specs = steps.map { step ->
if (!step.scale.isFinite() || step.scale <= 0f || step.scale > 1f) return false
if (step.delayMs < 0 || step.delayMs > MAX_DELAY_MS) return false
val spec = PRIMITIVES[step.primitive] ?: return false
if (Build.VERSION.SDK_INT < spec.minApi) return false
spec
}
if (!vibrator.areAllPrimitivesSupported(*specs.map { it.id }.toIntArray())) return false
return try {
val composition = VibrationEffect.startComposition()
steps.forEachIndexed { index, step ->
composition.addPrimitive(specs[index].id, step.scale, step.delayMs)
}
vibrateForTouch(composition.compose())
true
} catch (_: Exception) {
false
}
}
private fun vibrateForTouch(effect: VibrationEffect) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
vibrator.vibrate(
effect,
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH),
)
return
}
val attributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.build()
vibrator.vibrate(effect, attributes)
}
private fun touchFeedbackEnabled(): Boolean =
Settings.System.getInt(
context.contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED,
1,
) != 0
companion object {
private const val MAX_STEPS = 8
private const val MAX_DELAY_MS = 1_000
private val PRIMITIVES = linkedMapOf(
"click" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_CLICK, 30),
"thud" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_THUD, 31),
"spin" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_SPIN, 31),
"quickRise" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 30),
"slowRise" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 30),
"quickFall" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 30),
"tick" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_TICK, 30),
"lowTick" to PrimitiveSpec(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, 31),
)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astrahaptics.AstraHapticsModule"]
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Platform } from 'react-native';
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
import type {
HapticCapabilities,
HapticCompositionStep,
HapticPrimitive,
HapticPrimitiveCapability,
} from './types';
declare class AstraHapticsModuleType extends NativeModule {
getCapabilities(): Omit<HapticCapabilities, 'moduleAvailable'>;
isTouchFeedbackEnabled(): boolean;
playComposition(steps: HapticCompositionStep[]): boolean;
}
const native = requireOptionalNativeModule<AstraHapticsModuleType>('AstraHaptics');
const PRIMITIVES: HapticPrimitive[] = [
'click',
'thud',
'spin',
'quickRise',
'slowRise',
'quickFall',
'tick',
'lowTick',
];
function unsupportedPrimitives(): Record<HapticPrimitive, HapticPrimitiveCapability> {
return Object.fromEntries(
PRIMITIVES.map((primitive) => [primitive, { supported: false, durationMs: 0 }])
) as Record<HapticPrimitive, HapticPrimitiveCapability>;
}
export const AstraHaptics = {
isAvailable: native !== null,
getCapabilities(): HapticCapabilities {
if (!native) {
return {
moduleAvailable: false,
apiLevel: typeof Platform.Version === 'number' ? Platform.Version : 0,
hasVibrator: false,
hasAmplitudeControl: false,
touchFeedbackEnabled: false,
primitives: unsupportedPrimitives(),
};
}
return { moduleAvailable: true, ...native.getCapabilities() };
},
isTouchFeedbackEnabled(): boolean {
return native?.isTouchFeedbackEnabled() ?? true;
},
playComposition(steps: HapticCompositionStep[]): boolean {
return native?.playComposition(steps) ?? false;
},
};
export type {
HapticCapabilities,
HapticCompositionStep,
HapticPrimitive,
HapticPrimitiveCapability,
} from './types';
+29
View File
@@ -0,0 +1,29 @@
export type HapticPrimitive =
| 'click'
| 'thud'
| 'spin'
| 'quickRise'
| 'slowRise'
| 'quickFall'
| 'tick'
| 'lowTick';
export interface HapticCompositionStep {
primitive: HapticPrimitive;
scale: number;
delayMs?: number;
}
export interface HapticPrimitiveCapability {
supported: boolean;
durationMs: number;
}
export interface HapticCapabilities {
moduleAvailable: boolean;
apiLevel: number;
hasVibrator: boolean;
hasAmplitudeControl: boolean;
touchFeedbackEnabled: boolean;
primitives: Record<HapticPrimitive, HapticPrimitiveCapability>;
}