diff --git a/documentation/content/docs/api/jindong-core/core-api.mdx b/documentation/content/docs/api/jindong-core/core-api.mdx index d27670a..ad14138 100644 --- a/documentation/content/docs/api/jindong-core/core-api.mdx +++ b/documentation/content/docs/api/jindong-core/core-api.mdx @@ -211,6 +211,17 @@ val composedPattern = buildHapticPattern { } ``` +If included patterns overlap in time, they are not summed. A single vibration motor +plays one amplitude at a time, so at each instant the strongest active event wins: + +```kotlin +val composed = buildHapticPattern { + include(strongPattern) // e.g. 0-100ms at HIGH + include(softPattern) // e.g. 50-150ms at LIGHT +} +// 0-100ms plays at HIGH (it dominates the overlap), then 100-150ms plays at LIGHT. +``` + ## Examples ### ViewModel Usage diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fef09bf..ea445e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidxTest = "1.7.0" androidxActivity = "1.12.2" androidxAnnotation = "1.9.1" kover = "0.9.4" +junit5 = "5.13.4" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -26,7 +27,9 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTest" } diff --git a/jindong-core/build.gradle.kts b/jindong-core/build.gradle.kts index d0d7c2a..d4810b4 100644 --- a/jindong-core/build.gradle.kts +++ b/jindong-core/build.gradle.kts @@ -69,6 +69,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotest.framework.engine) implementation(libs.kotest.assertions.core) + implementation(libs.kotest.property) } named("androidHostTest").dependencies { @@ -78,6 +79,8 @@ kotlin { implementation(libs.kotest.assertions.core) implementation(libs.kotlinx.coroutines.test) implementation(libs.kotest.runner.junit5) + // JUnit4 Robolectric tests run under the JUnit Platform via the vintage engine. + runtimeOnly(libs.junit.vintage.engine) } } } diff --git a/jindong-core/src/androidHostTest/kotlin/io/github/compose/jindong/core/executor/AndroidHapticHandleTest.kt b/jindong-core/src/androidHostTest/kotlin/io/github/compose/jindong/core/executor/AndroidHapticHandleTest.kt new file mode 100644 index 0000000..a7de800 --- /dev/null +++ b/jindong-core/src/androidHostTest/kotlin/io/github/compose/jindong/core/executor/AndroidHapticHandleTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import android.content.Context +import android.os.Build +import android.os.Vibrator +import androidx.test.core.app.ApplicationProvider +import io.kotest.matchers.shouldBe +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TestTimeSource + +/** + * Time-based expiry behaviour of [AndroidHapticHandle], the bug this change fixes: before, `isActive` + * was decided once at construction (vibrator != null) and never noticed natural completion, so it + * stayed `true` until [AndroidHapticHandle.cancel]. A [TestTimeSource] drives expiry deterministically. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O]) +class AndroidHapticHandleTest { + + private lateinit var vibrator: Vibrator + + @Before + fun setup() { + val context: Context = ApplicationProvider.getApplicationContext() + vibrator = context.getSystemService(Vibrator::class.java) + } + + @Test + fun `isActive is true right after creation`() { + val time = TestTimeSource() + val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time) + + handle.isActive shouldBe true + } + + @Test + fun `isActive stays true before the duration elapses`() { + val time = TestTimeSource() + val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time) + + time += 99.milliseconds + + handle.isActive shouldBe true + } + + // Regression guard: the false positive the previous handle could never detect. + @Test + fun `isActive becomes false once the duration elapses without cancel`() { + val time = TestTimeSource() + val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time) + + time += 100.milliseconds + + handle.isActive shouldBe false + } + + @Test + fun `isActive is false after cancel regardless of time`() { + val time = TestTimeSource() + val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time) + + handle.cancel() + + handle.isActive shouldBe false + } + + @Test + fun `a silent pattern handle is inactive from the start`() { + val time = TestTimeSource() + val handle = AndroidHapticHandle(vibrator = null, totalDurationMs = 0L, timeSource = time) + + handle.isActive shouldBe false + } +} diff --git a/jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt b/jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt index 8105e93..ef452f3 100644 --- a/jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt +++ b/jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt @@ -25,6 +25,7 @@ import io.github.compose.jindong.core.model.HapticIntensity import io.github.compose.jindong.core.model.HapticPattern import io.github.compose.jindong.core.model.ScheduledHapticEvent import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest import org.junit.Before @@ -190,8 +191,10 @@ class AndroidVibratorTest { executor.execute(pattern) shadowVibrator.isVibrating shouldBe true - // [100ms event1] + [50ms gap] + [100ms event2] + [1ms end] - shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 1) + // On an amplitude-capable (LRA) actuator, the active->gap boundary gets a fall ramp borrowed + // from the gap front: the 50ms gap becomes [8ms ramp @ HIGH/2][42ms gap]. Total span unchanged. + // [100ms event1] + [8ms ramp] + [42ms gap] + [100ms event2] + [1ms end] + shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 100, 1) } @Test @@ -220,8 +223,191 @@ class AndroidVibratorTest { executor.execute(pattern) shadowVibrator.isVibrating shouldBe true - // [100ms event1] + [50ms gap1] + [100ms event2] + [50ms gap2] + [100ms event3] + [1ms end] - shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 50, 100, 1) + // LRA fall ramps soften both internal active->gap boundaries: each 50ms gap becomes + // [8ms ramp][42ms gap]. Total span unchanged (ramp borrowed from the gap front). + // [100 e1][8 ramp][42 gap1][100 e2][8 ramp][42 gap2][100 e3][1 end] + shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 100, 8, 42, 100, 1) + } + + @Test + fun `should insert a fall ramp at an active-to-gap boundary on an LRA actuator`() = runTest { + // setup() already enabled amplitude control (LRA). A single active->gap boundary. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.STRONG, + ), + ScheduledHapticEvent( + startTimeMs = 150, // 100ms + 50ms gap + durationMs = 50, + intensity = HapticIntensity.STRONG, + ), + ), + ) + + executor.execute(pattern) + + shadowVibrator.isVibrating shouldBe true + // The 50ms gap is split into an 8ms ramp + 42ms gap; the active segments are untouched. + // ShadowVibrator only exposes timings (getPattern), so amplitude precision is asserted in + // InsertFallRampsTest; here we verify the timeline was reshaped by the ramp. + // [100 active][8 ramp][42 gap][50 active][1 end] + shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 50, 1) + } + + @Test + fun `should not insert a fall ramp on an ERM actuator without amplitude control`() = runTest { + val context: Context = ApplicationProvider.getApplicationContext() + // Disable amplitude control BEFORE the executor evaluates its lazy hasAmplitudeControl. + shadowVibrator.setHasAmplitudeControl(false) + val ermExecutor = createHapticExecutor(context) + + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.HIGH, + ), + ScheduledHapticEvent( + startTimeMs = 150, // 100ms + 50ms gap + durationMs = 100, + intensity = HapticIntensity.MEDIUM, + ), + ), + ) + + ermExecutor.execute(pattern) + + shadowVibrator.isVibrating shouldBe true + // No ramp on ERM (amplitude would round up anyway): original gap shape preserved. + shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 1) + } + + @Test + fun `should leave a sub-threshold gap unramped on an LRA actuator`() = runTest { + // setup() enabled amplitude control (LRA). The gap (4ms) is not greater than MIN_RAMP_MS, + // so insertFallRamps must leave it intact rather than splitting it into a ramp. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.HIGH, + ), + ScheduledHapticEvent( + startTimeMs = 104, // 100ms + 4ms gap (== MIN_RAMP_MS, not greater) + durationMs = 50, + intensity = HapticIntensity.HIGH, + ), + ), + ) + + executor.execute(pattern) + + shadowVibrator.isVibrating shouldBe true + // Gap stays whole: [100 active][4 gap][50 active][1 end]; no ramp inserted. + shadowVibrator.pattern shouldBe longArrayOf(100, 4, 50, 1) + } + + @Test + fun `should not vibrate a zero-duration event`() = runTest { + // A zero-duration event produces no active segment, so it must be a no-op rather than + // emitting the compat-only primer/trailing buzz. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 0, + intensity = HapticIntensity.HIGH, + ), + ), + ) + + executor.execute(pattern) + + // isVibrating alone could pass even if a short compat-only waveform briefly played and ended; + // assert no waveform was ever handed to the vibrator, proving execute() was a true no-op. + shadowVibrator.pattern.shouldBeNull() + shadowVibrator.isVibrating shouldBe false + } + + @Test + fun `should await the merged duration for overlapping events`() = runTest { + // Overlap [0,100)@HIGH + [50,150)@MEDIUM merges to a 150ms span; execute() must suspend for + // the played waveform (150ms span + 1ms trailing = 151ms), not the raw maxOf of the events. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.HIGH, + ), + ScheduledHapticEvent( + startTimeMs = 50, + durationMs = 100, + intensity = HapticIntensity.MEDIUM, + ), + ), + ) + + val before = testScheduler.currentTime + executor.execute(pattern) + val elapsed = testScheduler.currentTime - before + + elapsed shouldBe shadowVibrator.pattern.sum() + elapsed shouldBe 151L + } + + @Test + fun `should await the full played waveform length including compat segments`() = runTest { + // A single 100ms event plays as [100 active][1 gap][1 primer][1 end] = 103ms (the primer is + // single-event only). execute() must delay for the whole 103ms, not the bare 100ms merged span, + // so the caller resumes when the vibration truly ends. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.HIGH, + ), + ), + ) + + val before = testScheduler.currentTime + executor.execute(pattern) + val elapsed = testScheduler.currentTime - before + + elapsed shouldBe shadowVibrator.pattern.sum() + elapsed shouldBe 103L + } + + @Test + fun `should serialize overlapping events keeping higher intensity`() = runTest { + // Overlap: [0,100)@HIGH overlaps [50,150)@MEDIUM. + // Merged serial timeline: [0,50)@HIGH, [50,100)@HIGH (winner), [100,150)@MEDIUM. + val pattern = HapticPattern( + listOf( + ScheduledHapticEvent( + startTimeMs = 0, + durationMs = 100, + intensity = HapticIntensity.HIGH, + ), + ScheduledHapticEvent( + startTimeMs = 50, + durationMs = 100, + intensity = HapticIntensity.MEDIUM, + ), + ), + ) + + executor.execute(pattern) + + shadowVibrator.isVibrating shouldBe true + // [50ms HIGH] + [50ms HIGH] + [50ms MEDIUM] + [1ms end], total span 150ms. + shadowVibrator.pattern shouldBe longArrayOf(50, 50, 50, 1) } @Test @@ -302,7 +488,8 @@ class AndroidVibratorTest { executor.execute(pattern) - // Should not crash, but also should not vibrate + // Should not crash, and no waveform should ever reach the vibrator. + shadowVibrator.pattern.shouldBeNull() shadowVibrator.isVibrating shouldBe false } diff --git a/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt b/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt index 61c3124..3a2e532 100644 --- a/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt +++ b/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt @@ -24,9 +24,10 @@ import android.os.VibratorManager import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import io.github.compose.jindong.core.model.HapticPattern -import io.github.compose.jindong.core.model.ScheduledHapticEvent import kotlinx.coroutines.delay import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource /** * Android implementation of [HapticExecutor] using [VibrationEffect.createWaveform]. @@ -55,8 +56,9 @@ internal class DefaultAndroidHapticExecutor(context: Context) : HapticExecutor { vibrator.hasVibrator() } - // ERM actuators round any non-zero amplitude up to 100%, so per-level intensity is indistinguishable; - // callers read this to warn that LIGHT/MEDIUM/STRONG/HIGH feel identical on such devices. + // ERM actuators round any non-zero amplitude up to 100%, so per-level intensity is indistinguishable: + // callers read this to warn that LIGHT/MEDIUM/STRONG/HIGH feel identical, and toWaveform() gates the + // fall ramp on it (a ramp would be lost when every non-zero amplitude rounds up to full). override val hasAmplitudeControl: Boolean by lazy { vibrator.hasAmplitudeControl() } @@ -65,75 +67,131 @@ internal class DefaultAndroidHapticExecutor(context: Context) : HapticExecutor { override suspend fun execute(pattern: HapticPattern) { if (!isSupported || pattern.events.isEmpty()) return - vibratePattern(pattern) - val totalDurationMs = pattern.events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L - delay(totalDurationMs) + // Await the exact waveform that was played, including the primer/trailing compat segments, + // so the caller resumes when the vibration truly ends rather than a few ms early. + val waveform = vibratePattern(pattern) ?: return + delay(waveform.playbackDurationMs().milliseconds) } @RequiresPermission(Manifest.permission.VIBRATE) override fun executeAsync(pattern: HapticPattern): HapticHandle = when { - !isSupported || pattern.events.isEmpty() -> AndroidHapticHandle(null) + !isSupported || pattern.events.isEmpty() -> AndroidHapticHandle(vibrator = null, totalDurationMs = 0L) - else -> { - vibratePattern(pattern) - AndroidHapticHandle(vibrator) + else -> when (val waveform = vibratePattern(pattern)) { + // Use the exact waveform length (primer/trailing compat segments included), matching execute(), + // so isActive estimates completion against what actually played. + null -> AndroidHapticHandle(vibrator = null, totalDurationMs = 0L) + + else -> AndroidHapticHandle(vibrator = vibrator, totalDurationMs = waveform.playbackDurationMs()) } } @RequiresPermission(Manifest.permission.VIBRATE) override fun release() = vibrator.cancel() + /** Plays [pattern] and returns the played waveform, or null when there is nothing to vibrate. */ @RequiresPermission(Manifest.permission.VIBRATE) - private fun vibratePattern(pattern: HapticPattern) { - val waveform = pattern.toWaveform() + private fun vibratePattern(pattern: HapticPattern): Waveform? { + val waveform = pattern.toWaveform() ?: return null val vibrationEffect = VibrationEffect.createWaveform(waveform.timings, waveform.amplitudes, -1) vibrator.vibrate(vibrationEffect) + return waveform } /** - * Converts [HapticPattern] to Android waveform format with alternating off/on segments. - * Note: Some devices require at least one gap in the middle of the waveform to function properly. + * Translates a [HapticPattern] (events on a timeline) into the two parallel arrays Android's + * [VibrationEffect.createWaveform] expects: `timings` (how long each slice lasts) and `amplitudes` + * (how hard the motor buzzes during it, 0 = off). Android plays one amplitude at a time, so the + * pattern's overlapping, intensity-bearing events must first be flattened into a single serial + * track of non-overlapping slices. + * + * Worked example — `Haptic(100ms, STRONG) + Delay(50ms) + Haptic(100ms, MEDIUM)` on an LRA: + * ``` + * events STRONG |##########| MEDIUM |##########| two overlapping/spaced events + * 0 100 150 250 with intensities, on a timeline + * + * 1. mergeToSerial -> [100 @0.75][50 @gap][100 @0.5] one serial track, gaps explicit; + * overlaps would resolve to the + * louder event (max, not sum) + * + * 2. insertFallRamps -> [100 @0.75][8 @0.38][42 @gap][100 @0.5] the 50ms gap lends its first 8ms + * \_ramp_/ to a fade-down step, so a hard + * drop to 0 doesn't ring (LRA only) + * + * 3. quantize timings = [100, 8, 42, 100] intensity 0..1 -> amplitude 0..255; + * amplitudes=[191, 95, 0, 127] a real gap stays 0, an active slice + * floors to 1 (never silent-by-rounding) + * + * 4. applyDeviceCompat timings = [100, 8, 42, 100, 1] trailing 1ms-off terminates the + * amplitudes=[191,95, 0, 127, 0] waveform cleanly; a single-event + * pattern also gets a Samsung primer + * ``` + * + * Returns null (so the caller plays nothing) when [mergeToSerial] yields no active slice — i.e. + * an empty, zero-duration, or all-gap pattern. Without this guard step 4 would still append the + * compat segments, making the motor buzz for a pattern the user meant to be silent. + * + * Fall ramps (step 2) only soften `active -> gap` transitions where a real gap follows, not the + * pattern's final active slice: that trailing drop to 0 is handled by the compat 1ms-off segment + * (step 4), which lets the driver's active braking settle the actuator rather than a ramp. */ - private fun HapticPattern.toWaveform(): Waveform { - val sortedEvents = events.sortedBy { it.startTimeMs } - val isSingleEvent = sortedEvents.size == 1 + private fun HapticPattern.toWaveform(): Waveform? { + // 1. Flatten overlapping events into one serial timeline of [HapticSegment]s. + val serial = mergeToSerial(events) + if (serial.none { !it.isGap }) return null + + // 2. Soften active->gap amplitude drops, but only where the motor can render the in-between + // levels (LRA); on ERM every non-zero amplitude rounds up to full, so a ramp is pointless. + val segments = if (hasAmplitudeControl) insertFallRamps(serial) else serial + + // 3. Quantize each segment into a (timing, amplitude) pair. val timings = mutableListOf() val amplitudes = mutableListOf() - var currentTime = 0L - - for (event in sortedEvents) { - val gap = event.startTimeMs - currentTime - - // Add gap if there's delay before this event - if (gap > 0) { - timings += gap - amplitudes += 0 - } - - // Add the haptic event - timings += event.durationMs - amplitudes += event.toAmplitude() - - // Single-event patterns are split with a 1ms primer vibration at the end of the main vibration. - // This is to ensure vibration support on Samsung devices. - if (isSingleEvent) { - timings += 1L - amplitudes += 0 - timings += 1L - amplitudes += 1 - } - - currentTime = event.startTimeMs + event.durationMs + for (segment in segments) { + timings += segment.durationMs + // A real gap stays at 0; an active slice floors to 1 via coerceIn, so an event with + // 0f intensity (Custom(0.0)) still registers as the faintest buzz rather than silence. + amplitudes += if (segment.isGap) 0 else segment.toAmplitude() + } + + // 4. Append the device-compat segments (Samsung primer + clean trailing termination). + return applyDeviceCompat(timings, amplitudes, isSingleEvent = events.size == 1) + } + + /** + * Post-processes the quantized waveform with device-compat segments: + * single-event patterns get a 1ms off + 1ms on primer (Samsung), and every pattern gets a + * trailing 1ms gap so the waveform terminates cleanly. + * + * The primer exists so devices that drop a single-segment [VibrationEffect.createWaveform] + * (they need an off-segment to recognize it as a real pattern) still fire — it is a recognition + * aid, not a motor warm-up, so it is appended after the active slice rather than prepended. A + * leading pair would add a perceptible pre-buzz; the 1ms tail here is imperceptible. + */ + private fun applyDeviceCompat( + timings: MutableList, + amplitudes: MutableList, + isSingleEvent: Boolean, + ): Waveform { + if (isSingleEvent) { + timings += 1L + amplitudes += 0 + timings += 1L + amplitudes += 1 } - // Add trailing segment to ensure proper termination timings += 1L amplitudes += 0 return Waveform(timings.toLongArray(), amplitudes.toIntArray()) } - private fun ScheduledHapticEvent.toAmplitude(): Int = (intensity.value * MAX_AMPLITUDE).toInt().coerceIn(1, MAX_AMPLITUDE) + private fun HapticSegment.toAmplitude(): Int = (intensity * MAX_AMPLITUDE).toInt().coerceIn(1, MAX_AMPLITUDE) + + // The played waveform's length is the sum of its slice timings (compat segments included). Signature + // is asymmetric with iOS's playbackDurationMs() on purpose: playback length is derived from a + // different input per platform (Android = the waveform actually played, iOS = the pattern). + private fun Waveform.playbackDurationMs(): Long = timings.sum() private data class Waveform( val timings: LongArray, @@ -157,15 +215,22 @@ internal class DefaultAndroidHapticExecutor(context: Context) : HapticExecutor { } } -internal class AndroidHapticHandle(private var vibrator: Vibrator?) : HapticHandle { +internal class AndroidHapticHandle( + private var vibrator: Vibrator?, + totalDurationMs: Long, + timeSource: TimeSource = TimeSource.Monotonic, +) : HapticHandle { + + // executeAsync may be called off the main thread, so cancel() can race the reader; keep it atomic. + private val cancelled = AtomicBoolean(false) + private val expiry = HandleExpiry(totalDurationMs, timeSource) - private val _isActive = AtomicBoolean(vibrator != null) override val isActive: Boolean - get() = _isActive.get() + get() = !cancelled.get() && !expiry.isExpired @RequiresPermission(Manifest.permission.VIBRATE) override fun cancel() { - if (!_isActive.compareAndSet(true, false)) return + if (!cancelled.compareAndSet(false, true)) return vibrator?.cancel() vibrator = null } diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HandleExpiry.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HandleExpiry.kt new file mode 100644 index 0000000..9978bdc --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HandleExpiry.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Shared, pull-based expiry judgement for platform [HapticHandle]s. + * + * The OS gives no per-effect completion callback (Android [android.os.Vibrator] and iOS + * `CHHapticPatternPlayerProtocol` both lack one), so "completed" can only be *estimated* from the + * expected playback length. This holds the start [TimeMark] and the total duration and answers, on + * each [isExpired] read, whether enough monotonic time has elapsed — no coroutine, scope, or timer. + * + * Both [io.github.compose.jindong.core.executor.AndroidHapticHandle] and + * [io.github.compose.jindong.core.executor.IosHapticHandle] delegate to this so they share identical + * expiry semantics across platforms. + * + * @param totalDurationMs Expected playback length. A non-positive value means nothing is playing, so + * the handle is expired from the start (a silent/empty pattern is never active). + * @param timeSource Monotonic clock; injectable so tests can advance time deterministically. + */ +internal class HandleExpiry( + totalDurationMs: Long, + timeSource: TimeSource = TimeSource.Monotonic, +) { + private val totalDuration: Duration = totalDurationMs.coerceAtLeast(0L).milliseconds + private val start: TimeMark = timeSource.markNow() + + /** True once the estimated playback window has elapsed (best-effort; ±OS scheduling jitter). */ + val isExpired: Boolean + get() = start.elapsedNow() >= totalDuration +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt index 03e4fdc..0f026e8 100644 --- a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt @@ -29,7 +29,15 @@ interface HapticHandle { fun cancel() /** - * Returns true if the haptic execution is still active (not completed or cancelled). + * Returns true while the haptic execution is still considered active, i.e. neither cancelled nor + * completed. + * + * Completion is a **best-effort estimate** based on the pattern's expected playback duration, not + * an OS completion notification (neither Android's `Vibrator` nor iOS' base + * `CHHapticPatternPlayerProtocol` reports per-effect completion). As a result: + * - Natural completion may be off by tens of milliseconds (OS scheduling, Doze, throttling). + * - [cancel] flips this to `false` immediately and exactly. + * - A silent or empty pattern is never active (this is `false` from the start). */ val isActive: Boolean } diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticSegment.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticSegment.kt new file mode 100644 index 0000000..a6e4fb2 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticSegment.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +/** + * A single non-overlapping slice of a serialized haptic timeline. + * + * @property startTimeMs Absolute start of this segment. + * @property durationMs Length of this segment, always greater than 0. + * @property intensity Winning event's intensity (NOT a sum of overlapping events); 0f when a gap. + * @property sharpness Carried from the same winning event (iOS Core Haptics parameter). + * @property isGap True when no event is active here. Distinct from an active event whose intensity + * happens to be 0f (e.g. `Custom(0.0)`), which must still floor to a non-zero amplitude. + */ +internal data class HapticSegment( + val startTimeMs: Long, + val durationMs: Long, + val intensity: Float, + val sharpness: Float, + val isGap: Boolean = false, +) diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/InsertFallRamps.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/InsertFallRamps.kt new file mode 100644 index 0000000..59173bf --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/InsertFallRamps.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +// Fall-ramp parameters. On LRA actuators a hard amplitude drop to 0 rings out for 50ms+ +// because createWaveform renders pure steps (no interpolation); a short stepped fall ramp +// borrowed from the front of the following gap masks that ringing. +private const val FALL_RAMP_MS = 16L +private const val FALL_RAMP_STEPS = 2 +private const val MIN_RAMP_MS = 4L + +/** + * Softens every `active -> gap` transition with a short stepped fall ramp, masking the LRA + * ringing a hard amplitude drop to 0 would otherwise cause. + * + * The ramp is borrowed from the FRONT of the following gap (the active segment is never touched), + * so the total duration and every active segment's timing are preserved. Each ramp step is marked + * `isGap = false` so quantization floors its non-zero intensity to a real amplitude; only the + * remaining true gap stays at 0. Transitions other than active->gap (active->active, gap->active, + * leading gap) are left untouched. + * + * Invariant: `insertFallRamps(s).sumOf { it.durationMs } == s.sumOf { it.durationMs }`. + */ +internal fun insertFallRamps(segments: List): List { + if (segments.size < 2) return segments + + val result = mutableListOf() + var i = 0 + while (i < segments.size) { + val current = segments[i] + val next = segments.getOrNull(i + 1) + + val isActiveToGap = !current.isGap && next != null && next.isGap && next.durationMs > MIN_RAMP_MS + if (next == null || !isActiveToGap) { + result += current + i++ + continue + } + + result += current + result += rampThenGap(fromIntensity = current.intensity, gap = next) + i += 2 // current and the gap are both consumed here + } + + return result +} + +/** + * Replaces [gap] with `[ramp steps..., shrunken gap]`, stepping the amplitude down from + * [fromIntensity] to 0 across the front [FALL_RAMP_MS] (or the whole gap if shorter). + * Duration is conserved: the borrowed window plus the leftover gap equal `gap.durationMs`. + */ +private fun rampThenGap(fromIntensity: Float, gap: HapticSegment): List { + val effectiveRampMs = minOf(FALL_RAMP_MS, gap.durationMs) + val stepMs = effectiveRampMs / FALL_RAMP_STEPS + if (stepMs <= 0L) return listOf(gap) // not enough room to split; leave the gap intact + + val out = mutableListOf() + var cursor = gap.startTimeMs + // Steps 1..(STEPS-1) carry a decaying non-zero amplitude; the final step folds into the gap. + for (step in 1 until FALL_RAMP_STEPS) { + val stepIntensity = fromIntensity * (FALL_RAMP_STEPS - step) / FALL_RAMP_STEPS + out += HapticSegment( + startTimeMs = cursor, + durationMs = stepMs, + intensity = stepIntensity, + sharpness = gap.sharpness, + isGap = false, + ) + cursor += stepMs + } + + // Remaining gap absorbs both the last ramp slot and any integer-division remainder, conserving sum. + out += gap.copy(startTimeMs = cursor, durationMs = gap.durationMs - (cursor - gap.startTimeMs)) + return out +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt new file mode 100644 index 0000000..d4ff1da --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.github.compose.jindong.core.model.ScheduledHapticEvent + +private const val DEFAULT_SHARPNESS = 0.5f + +/** + * Flattens potentially overlapping [events] into a gap-filled serial timeline. + * + * Boundaries are every event start and end, so each sub-interval is either fully covered by an + * event or empty. Overlaps resolve to the highest-intensity event (ties keep input order for + * determinism) instead of summing, matching how a single vibrator motor can only render one + * amplitude at a time. Gaps (including a leading gap before the first event) become 0f segments. + * + * Invariant: the sum of output [HapticSegment.durationMs] equals `events.maxOf { start + dur }`. + */ +internal fun mergeToSerial(events: List): List { + if (events.isEmpty()) return emptyList() + + val boundaries = buildList { + add(0L) + for (event in events) { + add(event.startTimeMs) + add(event.startTimeMs + event.durationMs) + } + }.distinct().sorted() + + val segments = mutableListOf() + + for (i in 0 until boundaries.size - 1) { + val start = boundaries[i] + val end = boundaries[i + 1] + if (start == end) continue // drop zero-length slices + + // Boundaries cover every endpoint, so an active event fully spans [start, end). + val winner = events + .filter { it.startTimeMs <= start && it.startTimeMs + it.durationMs >= end } + .maxByOrNull { it.intensity.value } + + segments += HapticSegment( + startTimeMs = start, + durationMs = end - start, + intensity = winner?.intensity?.value ?: 0f, + sharpness = winner?.iosParameters?.sharpness ?: DEFAULT_SHARPNESS, + isGap = winner == null, + ) + } + + return segments +} diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt new file mode 100644 index 0000000..fce9793 --- /dev/null +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/RawSpanMs.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.github.compose.jindong.core.model.HapticPattern + +/** + * The raw span of a pattern: the latest event end on its timeline, or 0 for an empty pattern. + * + * This is the timeline extent of the scheduled events themselves, before any platform post-processing + * (Android compat segments, actuator quirks). Each executor derives its own playback length from this + * according to its physics — see the platform `playbackDurationMs()` helpers. + */ +internal fun HapticPattern.rawSpanMs(): Long = events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HandleExpiryTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HandleExpiryTest.kt new file mode 100644 index 0000000..af662de --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HandleExpiryTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TestTimeSource + +/** + * Deterministic coverage of the shared, pull-based expiry decision both platform handles delegate to. + * A [TestTimeSource] makes "natural completion" testable without real waiting — the previous + * callback-less handle could never detect this and reported a false-positive active state forever. + */ +class HandleExpiryTest : + FunSpec({ + + test("is not expired right after creation") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = 100L, timeSource = time) + + expiry.isExpired shouldBe false + } + + test("is not expired while elapsed time is below the total duration") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = 100L, timeSource = time) + + time += 99.milliseconds + + expiry.isExpired shouldBe false + } + + // The regression guard: without time-based expiry this stays false forever (false positive). + test("becomes expired once elapsed time reaches the total duration") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = 100L, timeSource = time) + + time += 100.milliseconds + + expiry.isExpired shouldBe true + } + + test("stays expired after the total duration has passed") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = 100L, timeSource = time) + + time += 500.milliseconds + + expiry.isExpired shouldBe true + } + + test("a zero total duration is expired from the start") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = 0L, timeSource = time) + + expiry.isExpired shouldBe true + } + + test("a negative total duration is treated as zero and expired from the start") { + val time = TestTimeSource() + val expiry = HandleExpiry(totalDurationMs = -10L, timeSource = time) + + expiry.isExpired shouldBe true + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt new file mode 100644 index 0000000..c29283d --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.floats.plusOrMinus +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.float +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.long +import io.kotest.property.checkAll + +private fun active(startTimeMs: Long, durationMs: Long, intensity: Float): HapticSegment = HapticSegment(startTimeMs = startTimeMs, durationMs = durationMs, intensity = intensity, sharpness = 0.5f, isGap = false) + +private fun gap(startTimeMs: Long, durationMs: Long): HapticSegment = HapticSegment(startTimeMs = startTimeMs, durationMs = durationMs, intensity = 0f, sharpness = 0.5f, isGap = true) + +class InsertFallRampsTest : + FunSpec({ + test("active to gap inserts a decaying ramp step borrowed from the gap front") { + // STRONG (0.75) active for 100ms, then a 16ms gap. + val input = listOf( + active(startTimeMs = 0, durationMs = 100, intensity = 0.75f), + gap(startTimeMs = 100, durationMs = 16), + ) + + val output = insertFallRamps(input) + + // active (untouched) + 1 ramp step (8ms @ 0.375) + shrunken gap (8ms @ 0). + output shouldHaveSize 3 + assertSoftly { + output[0] shouldBe input[0] + with(output[1]) { + startTimeMs shouldBe 100L + durationMs shouldBe 8L + intensity shouldBe (0.375f plusOrMinus 1e-6f) + isGap shouldBe false + } + with(output[2]) { + startTimeMs shouldBe 108L + durationMs shouldBe 8L + intensity shouldBe 0f + isGap shouldBe true + } + } + } + + test("total duration is conserved (ramp is borrowed, never added)") { + val input = listOf( + active(startTimeMs = 0, durationMs = 100, intensity = 0.75f), + gap(startTimeMs = 100, durationMs = 50), + active(startTimeMs = 150, durationMs = 100, intensity = 1.0f), + gap(startTimeMs = 250, durationMs = 30), + ) + + val output = insertFallRamps(input) + + output.sumOf { it.durationMs } shouldBe input.sumOf { it.durationMs } + } + + test("ramp shrinks to fit a gap smaller than the full ramp window") { + // 10ms gap (< FALL_RAMP_MS 16) -> effective ramp 10ms, step 5ms. + val input = listOf( + active(startTimeMs = 0, durationMs = 100, intensity = 0.5f), + gap(startTimeMs = 100, durationMs = 10), + ) + + val output = insertFallRamps(input) + + output shouldHaveSize 3 + assertSoftly { + output[1].durationMs shouldBe 5L + output[1].intensity shouldBe (0.25f plusOrMinus 1e-6f) + output[1].isGap shouldBe false + output[2].durationMs shouldBe 5L + output[2].isGap shouldBe true + } + output.sumOf { it.durationMs } shouldBe 110L + } + + test("gap at or below the minimum is left untouched") { + val input = listOf( + active(startTimeMs = 0, durationMs = 100, intensity = 0.75f), + gap(startTimeMs = 100, durationMs = 4), + ) + + val output = insertFallRamps(input) + + output shouldBe input + } + + test("active to active transition gets no ramp") { + val input = listOf( + active(startTimeMs = 0, durationMs = 50, intensity = 0.75f), + active(startTimeMs = 50, durationMs = 50, intensity = 0.5f), + ) + + val output = insertFallRamps(input) + + output shouldBe input + } + + test("leading gap before the first active is not ramped") { + val input = listOf( + gap(startTimeMs = 0, durationMs = 100), + active(startTimeMs = 100, durationMs = 50, intensity = 0.75f), + ) + + val output = insertFallRamps(input) + + output shouldBe input + } + + test("multiple active-to-gap boundaries each get their own ramp") { + val input = listOf( + active(startTimeMs = 0, durationMs = 100, intensity = 1.0f), + gap(startTimeMs = 100, durationMs = 50), + active(startTimeMs = 150, durationMs = 100, intensity = 0.5f), + gap(startTimeMs = 250, durationMs = 50), + ) + + val output = insertFallRamps(input) + + // Each gap splits into [ramp step, gap], so 2 actives + 2 ramps + 2 gaps = 6. + output shouldHaveSize 6 + output.count { !it.isGap && it.intensity > 0f && it.durationMs == 8L } shouldBe 2 + output.sumOf { it.durationMs } shouldBe input.sumOf { it.durationMs } + } + + test("a single segment is returned unchanged") { + val input = listOf(active(startTimeMs = 0, durationMs = 100, intensity = 0.75f)) + + insertFallRamps(input) shouldBe input + } + + // Property tests over randomly built timelines. The duration-conservation invariant must hold + // regardless of gap parity, so odd gaps (where ramp splitting has an integer-division remainder + // the leftover gap must absorb) are exercised alongside even ones. + test("duration is conserved for any timeline") { + checkAll(timelines()) { input -> + insertFallRamps(input).sumOf { it.durationMs } shouldBe input.sumOf { it.durationMs } + } + } + + test("active segments are preserved and ramp steps stay within their gap") { + checkAll(timelines()) { input -> + val output = insertFallRamps(input) + // Every original active segment survives untouched (ramps only ever borrow from gaps). + output.filter { !it.isGap && it in input } shouldContainAll input.filter { !it.isGap } + // Output stays contiguous and non-negative: no ramp ever overruns its gap. + output.forEach { it.durationMs shouldBeGreaterThan 0L } + output.zipWithNext { a, b -> b.startTimeMs shouldBe a.startTimeMs + a.durationMs } + } + } + }) + +/** + * Generates merge-to-serial-shaped timelines: contiguous segments alternating active/gap with + * cumulative start times, mixing even and odd gap durations and gaps below/above [MIN_RAMP_MS]. + */ +private fun timelines(): Arb> = arbitrary { rs -> + val count = Arb.int(1..6).bind() + var cursor = 0L + var wasGap = true // so the first segment can be active + buildList { + repeat(count) { + val makeGap = if (wasGap) false else Arb.boolean().bind() + val durationMs = Arb.long(1L..60L).bind() + add( + if (makeGap) { + gap(startTimeMs = cursor, durationMs = durationMs) + } else { + active(startTimeMs = cursor, durationMs = durationMs, intensity = Arb.float(0f..1f).bind()) + }, + ) + cursor += durationMs + wasGap = makeGap + } + } +} diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/MergeToSerialTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/MergeToSerialTest.kt new file mode 100644 index 0000000..c115826 --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/MergeToSerialTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.IosHapticParameters +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +private fun event( + startTimeMs: Long, + durationMs: Long, + intensity: HapticIntensity, + sharpness: Float? = null, +): ScheduledHapticEvent = ScheduledHapticEvent( + startTimeMs = startTimeMs, + durationMs = durationMs, + intensity = intensity, + iosParameters = sharpness?.let { IosHapticParameters(sharpness = it) }, +) + +class MergeToSerialTest : + FunSpec({ + test("single event produces a single segment preserving intensity") { + val segments = mergeToSerial( + listOf(event(startTimeMs = 0, durationMs = 100, intensity = HapticIntensity.HIGH)), + ) + + segments shouldHaveSize 1 + assertSoftly(segments.single()) { + startTimeMs shouldBe 0L + durationMs shouldBe 100L + intensity shouldBe HapticIntensity.HIGH.value + } + } + + test("adjacent non-overlapping events produce gapless back-to-back segments") { + val segments = mergeToSerial( + listOf( + event(startTimeMs = 0, durationMs = 25, intensity = HapticIntensity.MEDIUM), + event(startTimeMs = 25, durationMs = 25, intensity = HapticIntensity.MEDIUM), + event(startTimeMs = 50, durationMs = 25, intensity = HapticIntensity.MEDIUM), + ), + ) + + segments shouldHaveSize 3 + segments.map { it.durationMs } shouldBe listOf(25L, 25L, 25L) + segments.sumOf { it.durationMs } shouldBe 75L + segments.all { it.intensity == HapticIntensity.MEDIUM.value } shouldBe true + } + + test("overlapping events keep the higher intensity as winner (no summing)") { + // [0,100) HIGH overlaps [50,150) MEDIUM. + val segments = mergeToSerial( + listOf( + event(startTimeMs = 0, durationMs = 100, intensity = HapticIntensity.HIGH), + event(startTimeMs = 50, durationMs = 100, intensity = HapticIntensity.MEDIUM), + ), + ) + + segments shouldHaveSize 3 + assertSoftly { + segments[0] shouldBe HapticSegment(0, 50, HapticIntensity.HIGH.value, 0.5f) + segments[1] shouldBe HapticSegment(50, 50, HapticIntensity.HIGH.value, 0.5f) + segments[2] shouldBe HapticSegment(100, 50, HapticIntensity.MEDIUM.value, 0.5f) + } + segments.none { it.isGap } shouldBe true + // total spans the full [0,150) timeline, NOT a back-to-back 80ms compression. + segments.sumOf { it.durationMs } shouldBe 150L + } + + test("disjoint events preserve the gap between them as a 0f segment") { + val segments = mergeToSerial( + listOf( + event(startTimeMs = 0, durationMs = 50, intensity = HapticIntensity.HIGH), + event(startTimeMs = 100, durationMs = 50, intensity = HapticIntensity.HIGH), + ), + ) + + segments shouldHaveSize 3 + assertSoftly { + segments[0].intensity shouldBe HapticIntensity.HIGH.value + segments[0].isGap shouldBe false + segments[1] shouldBe HapticSegment(50, 50, 0f, 0.5f, isGap = true) + segments[2].intensity shouldBe HapticIntensity.HIGH.value + } + segments.sumOf { it.durationMs } shouldBe 150L + } + + test("fully contained event alternates the winner and carries its sharpness") { + // [0,200) LOW wraps [50,100) HIGH (with custom sharpness). + val segments = mergeToSerial( + listOf( + event(startTimeMs = 0, durationMs = 200, intensity = HapticIntensity.LIGHT, sharpness = 0.2f), + event(startTimeMs = 50, durationMs = 50, intensity = HapticIntensity.HIGH, sharpness = 0.9f), + ), + ) + + segments shouldHaveSize 3 + assertSoftly { + segments[0] shouldBe HapticSegment(0, 50, HapticIntensity.LIGHT.value, 0.2f) + segments[1] shouldBe HapticSegment(50, 50, HapticIntensity.HIGH.value, 0.9f) + segments[2] shouldBe HapticSegment(100, 100, HapticIntensity.LIGHT.value, 0.2f) + } + segments.sumOf { it.durationMs } shouldBe 200L + } + + test("a leading gap before the first event is preserved") { + val segments = mergeToSerial( + listOf(event(startTimeMs = 100, durationMs = 50, intensity = HapticIntensity.HIGH)), + ) + + segments shouldHaveSize 2 + segments[0] shouldBe HapticSegment(0, 100, 0f, 0.5f, isGap = true) + segments[1].intensity shouldBe HapticIntensity.HIGH.value + segments[1].isGap shouldBe false + segments.sumOf { it.durationMs } shouldBe 150L + } + + test("tied intensity resolves deterministically to the earlier input event") { + val first = event(startTimeMs = 0, durationMs = 200, intensity = HapticIntensity.HIGH, sharpness = 0.1f) + val second = event(startTimeMs = 50, durationMs = 50, intensity = HapticIntensity.HIGH, sharpness = 0.9f) + + val segments = mergeToSerial(listOf(first, second)) + + // [50,100) is a tie (both HIGH); the earlier event (first) wins -> sharpness 0.1f. + val tiedSegment = segments.single { it.startTimeMs == 50L } + tiedSegment.sharpness shouldBe 0.1f + } + + test("an active zero-intensity event is not flagged as a gap") { + val segments = mergeToSerial( + listOf(event(startTimeMs = 0, durationMs = 100, intensity = HapticIntensity.Custom(0.0f))), + ) + + segments shouldHaveSize 1 + assertSoftly(segments.single()) { + intensity shouldBe 0f + isGap shouldBe false + } + } + + test("operates independently of input ordering") { + val unsorted = listOf( + event(startTimeMs = 50, durationMs = 25, intensity = HapticIntensity.MEDIUM), + event(startTimeMs = 0, durationMs = 25, intensity = HapticIntensity.MEDIUM), + event(startTimeMs = 25, durationMs = 25, intensity = HapticIntensity.MEDIUM), + ) + + val segments = mergeToSerial(unsorted) + + segments.map { it.startTimeMs } shouldBe listOf(0L, 25L, 50L) + segments.sumOf { it.durationMs } shouldBe 75L + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/RawSpanMsTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/RawSpanMsTest.kt new file mode 100644 index 0000000..d724ed6 --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/RawSpanMsTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +private fun pattern(vararg events: ScheduledHapticEvent): HapticPattern = HapticPattern(events.toList()) + +private fun event(startTimeMs: Long, durationMs: Long): ScheduledHapticEvent = ScheduledHapticEvent( + startTimeMs = startTimeMs, + durationMs = durationMs, + intensity = HapticIntensity.HIGH, +) + +class RawSpanMsTest : + FunSpec({ + test("overlapping events span to the latest event end") { + // [0,100) overlaps [50,150); the latest end is 150. + val span = pattern( + event(startTimeMs = 0, durationMs = 100), + event(startTimeMs = 50, durationMs = 100), + ).rawSpanMs() + + span shouldBe 150L + } + + test("an empty pattern has a zero span") { + HapticPattern.Empty.rawSpanMs() shouldBe 0L + } + + test("a single event spans its own duration") { + pattern(event(startTimeMs = 0, durationMs = 100)).rawSpanMs() shouldBe 100L + } + + test("a fully contained event does not shorten the outer span") { + // [0,200) wraps [50,100); the span stays 200 (the outer end), not the inner one. + val span = pattern( + event(startTimeMs = 0, durationMs = 200), + event(startTimeMs = 50, durationMs = 50), + ).rawSpanMs() + + span shouldBe 200L + } + }) diff --git a/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt b/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt index 30ba7b1..03cd597 100644 --- a/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt +++ b/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt @@ -38,6 +38,7 @@ import platform.CoreHaptics.CHHapticPatternPlayerProtocol import platform.Foundation.NSError import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.TimeSource /** * iOS HapticExecutor implementation using Core Haptics. @@ -74,8 +75,7 @@ internal class DefaultIosHapticExecutor : HapticExecutor { player.startAtTime(0.0, errorPtr.ptr) if (errorPtr.value != null) return - val totalDurationMs = pattern.events.maxOfOrNull { it.startTimeMs + it.durationMs } ?: 0L - delay(totalDurationMs) + delay(pattern.playbackDurationMs()) player.stopAtTime(0.0, errorPtr.ptr) } @@ -83,25 +83,25 @@ internal class DefaultIosHapticExecutor : HapticExecutor { override fun executeAsync(pattern: HapticPattern): HapticHandle { if (!isSupported || pattern.events.isEmpty()) { - return IosHapticHandle(null) + return IosHapticHandle(player = null, totalDurationMs = 0L) } - val currentEngine = ensureEngine() ?: return IosHapticHandle(null) - val hapticPattern = pattern.toCHHapticPattern() ?: return IosHapticHandle(null) + val currentEngine = ensureEngine() ?: return IosHapticHandle(player = null, totalDurationMs = 0L) + val hapticPattern = pattern.toCHHapticPattern() ?: return IosHapticHandle(player = null, totalDurationMs = 0L) return memScoped { val errorPtr = alloc>() val player = currentEngine.createPlayerWithPattern(hapticPattern, errorPtr.ptr) if (errorPtr.value != null || player == null) { - return@memScoped IosHapticHandle(null) + return@memScoped IosHapticHandle(player = null, totalDurationMs = 0L) } player.startAtTime(0.0, errorPtr.ptr) if (errorPtr.value != null) { - return@memScoped IosHapticHandle(null) + return@memScoped IosHapticHandle(player = null, totalDurationMs = 0L) } - IosHapticHandle(player) + IosHapticHandle(player = player, totalDurationMs = pattern.playbackDurationMs()) } } @@ -144,6 +144,11 @@ internal class DefaultIosHapticExecutor : HapticExecutor { } } + // Signature is asymmetric with Android's playbackDurationMs() on purpose: playback length is derived + // from a different input per platform (iOS = the pattern, Android = the waveform actually played). + // Core Haptics has no compat segments, so the pattern's raw span already is its playback length. + private fun HapticPattern.playbackDurationMs(): Long = rawSpanMs() + private fun HapticPattern.toCHHapticPattern(): CHHapticPattern? { val hapticEvents = events.map { it.toCHHapticEvent() } return memScoped { @@ -188,16 +193,20 @@ internal class DefaultIosHapticExecutor : HapticExecutor { @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) internal class IosHapticHandle( private var player: CHHapticPatternPlayerProtocol?, + totalDurationMs: Long, + timeSource: TimeSource = TimeSource.Monotonic, ) : HapticHandle { - private var _isActive = player != null + // iOS executeAsync/cancel run on the same thread, so a plain flag is sufficient here. + private var cancelled = false + private val expiry = HandleExpiry(totalDurationMs, timeSource) override val isActive: Boolean - get() = _isActive + get() = !cancelled && !expiry.isExpired override fun cancel() { - if (!_isActive) return - _isActive = false + if (cancelled) return + cancelled = true memScoped { val errorPtr = alloc>()