Skip to content

Commit 7692779

Browse files
authored
feat(core): serial-timeline waveform pipeline (overlap, fall-ramp, handle lifecycle) (#85)
* build: run JUnit4 Robolectric tests via the vintage engine * feat(core): add serial-timeline merge and fall-ramp primitives for overlapping events * fix(android): serialize overlapping events and soften amplitude drops into gaps * fix(android): await the full waveform length including compat segments * test(android): cover await length and sub-threshold gap handling * fix(android): skip vibration for zero-duration or all-gap patterns * test(android): cover zero-duration no-op and overlap await duration * test(core): property-test fall-ramp duration conservation across gap parities * docs: write detail process of `HapticPattern.toWaveform()` * fix(core): expire HapticHandle.isActive when the estimated playback ends * test(core): cover HapticHandle expiry and natural-completion deactivation * refactor(core): split executor helpers into one declaration per file * test(android): assert no waveform reaches the vibrator for silent patterns * docs: document max-intensity resolution for overlapping included patterns * docs: clarify fall-ramp scope and Samsung primer placement rationale * refactor(core): extract playbackDurationMs to unify duration derivation across platforms
1 parent b71e993 commit 7692779

17 files changed

Lines changed: 1221 additions & 66 deletions

File tree

documentation/content/docs/api/jindong-core/core-api.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ val composedPattern = buildHapticPattern {
211211
}
212212
```
213213

214+
If included patterns overlap in time, they are not summed. A single vibration motor
215+
plays one amplitude at a time, so at each instant the strongest active event wins:
216+
217+
```kotlin
218+
val composed = buildHapticPattern {
219+
include(strongPattern) // e.g. 0-100ms at HIGH
220+
include(softPattern) // e.g. 50-150ms at LIGHT
221+
}
222+
// 0-100ms plays at HIGH (it dominates the overlap), then 100-150ms plays at LIGHT.
223+
```
224+
214225
## Examples
215226

216227
### ViewModel Usage

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ androidxTest = "1.7.0"
1717
androidxActivity = "1.12.2"
1818
androidxAnnotation = "1.9.1"
1919
kover = "0.9.4"
20+
junit5 = "5.13.4"
2021

2122
[libraries]
2223
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
2627
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
2728
kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
2829
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
30+
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
2931
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
32+
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
3033
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
3134
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" }
3235
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTest" }

jindong-core/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ kotlin {
6969
commonTest.dependencies {
7070
implementation(libs.kotest.framework.engine)
7171
implementation(libs.kotest.assertions.core)
72+
implementation(libs.kotest.property)
7273
}
7374

7475
named("androidHostTest").dependencies {
@@ -78,6 +79,8 @@ kotlin {
7879
implementation(libs.kotest.assertions.core)
7980
implementation(libs.kotlinx.coroutines.test)
8081
implementation(libs.kotest.runner.junit5)
82+
// JUnit4 Robolectric tests run under the JUnit Platform via the vintage engine.
83+
runtimeOnly(libs.junit.vintage.engine)
8184
}
8285
}
8386
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (C) 2026 compose-jindong
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.compose.jindong.core.executor
17+
18+
import android.content.Context
19+
import android.os.Build
20+
import android.os.Vibrator
21+
import androidx.test.core.app.ApplicationProvider
22+
import io.kotest.matchers.shouldBe
23+
import org.junit.Before
24+
import org.junit.Test
25+
import org.junit.runner.RunWith
26+
import org.robolectric.RobolectricTestRunner
27+
import org.robolectric.annotation.Config
28+
import kotlin.time.Duration.Companion.milliseconds
29+
import kotlin.time.TestTimeSource
30+
31+
/**
32+
* Time-based expiry behaviour of [AndroidHapticHandle], the bug this change fixes: before, `isActive`
33+
* was decided once at construction (vibrator != null) and never noticed natural completion, so it
34+
* stayed `true` until [AndroidHapticHandle.cancel]. A [TestTimeSource] drives expiry deterministically.
35+
*/
36+
@RunWith(RobolectricTestRunner::class)
37+
@Config(sdk = [Build.VERSION_CODES.O])
38+
class AndroidHapticHandleTest {
39+
40+
private lateinit var vibrator: Vibrator
41+
42+
@Before
43+
fun setup() {
44+
val context: Context = ApplicationProvider.getApplicationContext()
45+
vibrator = context.getSystemService(Vibrator::class.java)
46+
}
47+
48+
@Test
49+
fun `isActive is true right after creation`() {
50+
val time = TestTimeSource()
51+
val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time)
52+
53+
handle.isActive shouldBe true
54+
}
55+
56+
@Test
57+
fun `isActive stays true before the duration elapses`() {
58+
val time = TestTimeSource()
59+
val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time)
60+
61+
time += 99.milliseconds
62+
63+
handle.isActive shouldBe true
64+
}
65+
66+
// Regression guard: the false positive the previous handle could never detect.
67+
@Test
68+
fun `isActive becomes false once the duration elapses without cancel`() {
69+
val time = TestTimeSource()
70+
val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time)
71+
72+
time += 100.milliseconds
73+
74+
handle.isActive shouldBe false
75+
}
76+
77+
@Test
78+
fun `isActive is false after cancel regardless of time`() {
79+
val time = TestTimeSource()
80+
val handle = AndroidHapticHandle(vibrator, totalDurationMs = 100L, timeSource = time)
81+
82+
handle.cancel()
83+
84+
handle.isActive shouldBe false
85+
}
86+
87+
@Test
88+
fun `a silent pattern handle is inactive from the start`() {
89+
val time = TestTimeSource()
90+
val handle = AndroidHapticHandle(vibrator = null, totalDurationMs = 0L, timeSource = time)
91+
92+
handle.isActive shouldBe false
93+
}
94+
}

jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt

Lines changed: 192 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import io.github.compose.jindong.core.model.HapticIntensity
2525
import io.github.compose.jindong.core.model.HapticPattern
2626
import io.github.compose.jindong.core.model.ScheduledHapticEvent
2727
import io.kotest.assertions.throwables.shouldNotThrow
28+
import io.kotest.matchers.nulls.shouldBeNull
2829
import io.kotest.matchers.shouldBe
2930
import kotlinx.coroutines.test.runTest
3031
import org.junit.Before
@@ -190,8 +191,10 @@ class AndroidVibratorTest {
190191
executor.execute(pattern)
191192

192193
shadowVibrator.isVibrating shouldBe true
193-
// [100ms event1] + [50ms gap] + [100ms event2] + [1ms end]
194-
shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 1)
194+
// On an amplitude-capable (LRA) actuator, the active->gap boundary gets a fall ramp borrowed
195+
// from the gap front: the 50ms gap becomes [8ms ramp @ HIGH/2][42ms gap]. Total span unchanged.
196+
// [100ms event1] + [8ms ramp] + [42ms gap] + [100ms event2] + [1ms end]
197+
shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 100, 1)
195198
}
196199

197200
@Test
@@ -220,8 +223,191 @@ class AndroidVibratorTest {
220223
executor.execute(pattern)
221224

222225
shadowVibrator.isVibrating shouldBe true
223-
// [100ms event1] + [50ms gap1] + [100ms event2] + [50ms gap2] + [100ms event3] + [1ms end]
224-
shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 50, 100, 1)
226+
// LRA fall ramps soften both internal active->gap boundaries: each 50ms gap becomes
227+
// [8ms ramp][42ms gap]. Total span unchanged (ramp borrowed from the gap front).
228+
// [100 e1][8 ramp][42 gap1][100 e2][8 ramp][42 gap2][100 e3][1 end]
229+
shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 100, 8, 42, 100, 1)
230+
}
231+
232+
@Test
233+
fun `should insert a fall ramp at an active-to-gap boundary on an LRA actuator`() = runTest {
234+
// setup() already enabled amplitude control (LRA). A single active->gap boundary.
235+
val pattern = HapticPattern(
236+
listOf(
237+
ScheduledHapticEvent(
238+
startTimeMs = 0,
239+
durationMs = 100,
240+
intensity = HapticIntensity.STRONG,
241+
),
242+
ScheduledHapticEvent(
243+
startTimeMs = 150, // 100ms + 50ms gap
244+
durationMs = 50,
245+
intensity = HapticIntensity.STRONG,
246+
),
247+
),
248+
)
249+
250+
executor.execute(pattern)
251+
252+
shadowVibrator.isVibrating shouldBe true
253+
// The 50ms gap is split into an 8ms ramp + 42ms gap; the active segments are untouched.
254+
// ShadowVibrator only exposes timings (getPattern), so amplitude precision is asserted in
255+
// InsertFallRampsTest; here we verify the timeline was reshaped by the ramp.
256+
// [100 active][8 ramp][42 gap][50 active][1 end]
257+
shadowVibrator.pattern shouldBe longArrayOf(100, 8, 42, 50, 1)
258+
}
259+
260+
@Test
261+
fun `should not insert a fall ramp on an ERM actuator without amplitude control`() = runTest {
262+
val context: Context = ApplicationProvider.getApplicationContext()
263+
// Disable amplitude control BEFORE the executor evaluates its lazy hasAmplitudeControl.
264+
shadowVibrator.setHasAmplitudeControl(false)
265+
val ermExecutor = createHapticExecutor(context)
266+
267+
val pattern = HapticPattern(
268+
listOf(
269+
ScheduledHapticEvent(
270+
startTimeMs = 0,
271+
durationMs = 100,
272+
intensity = HapticIntensity.HIGH,
273+
),
274+
ScheduledHapticEvent(
275+
startTimeMs = 150, // 100ms + 50ms gap
276+
durationMs = 100,
277+
intensity = HapticIntensity.MEDIUM,
278+
),
279+
),
280+
)
281+
282+
ermExecutor.execute(pattern)
283+
284+
shadowVibrator.isVibrating shouldBe true
285+
// No ramp on ERM (amplitude would round up anyway): original gap shape preserved.
286+
shadowVibrator.pattern shouldBe longArrayOf(100, 50, 100, 1)
287+
}
288+
289+
@Test
290+
fun `should leave a sub-threshold gap unramped on an LRA actuator`() = runTest {
291+
// setup() enabled amplitude control (LRA). The gap (4ms) is not greater than MIN_RAMP_MS,
292+
// so insertFallRamps must leave it intact rather than splitting it into a ramp.
293+
val pattern = HapticPattern(
294+
listOf(
295+
ScheduledHapticEvent(
296+
startTimeMs = 0,
297+
durationMs = 100,
298+
intensity = HapticIntensity.HIGH,
299+
),
300+
ScheduledHapticEvent(
301+
startTimeMs = 104, // 100ms + 4ms gap (== MIN_RAMP_MS, not greater)
302+
durationMs = 50,
303+
intensity = HapticIntensity.HIGH,
304+
),
305+
),
306+
)
307+
308+
executor.execute(pattern)
309+
310+
shadowVibrator.isVibrating shouldBe true
311+
// Gap stays whole: [100 active][4 gap][50 active][1 end]; no ramp inserted.
312+
shadowVibrator.pattern shouldBe longArrayOf(100, 4, 50, 1)
313+
}
314+
315+
@Test
316+
fun `should not vibrate a zero-duration event`() = runTest {
317+
// A zero-duration event produces no active segment, so it must be a no-op rather than
318+
// emitting the compat-only primer/trailing buzz.
319+
val pattern = HapticPattern(
320+
listOf(
321+
ScheduledHapticEvent(
322+
startTimeMs = 0,
323+
durationMs = 0,
324+
intensity = HapticIntensity.HIGH,
325+
),
326+
),
327+
)
328+
329+
executor.execute(pattern)
330+
331+
// isVibrating alone could pass even if a short compat-only waveform briefly played and ended;
332+
// assert no waveform was ever handed to the vibrator, proving execute() was a true no-op.
333+
shadowVibrator.pattern.shouldBeNull()
334+
shadowVibrator.isVibrating shouldBe false
335+
}
336+
337+
@Test
338+
fun `should await the merged duration for overlapping events`() = runTest {
339+
// Overlap [0,100)@HIGH + [50,150)@MEDIUM merges to a 150ms span; execute() must suspend for
340+
// the played waveform (150ms span + 1ms trailing = 151ms), not the raw maxOf of the events.
341+
val pattern = HapticPattern(
342+
listOf(
343+
ScheduledHapticEvent(
344+
startTimeMs = 0,
345+
durationMs = 100,
346+
intensity = HapticIntensity.HIGH,
347+
),
348+
ScheduledHapticEvent(
349+
startTimeMs = 50,
350+
durationMs = 100,
351+
intensity = HapticIntensity.MEDIUM,
352+
),
353+
),
354+
)
355+
356+
val before = testScheduler.currentTime
357+
executor.execute(pattern)
358+
val elapsed = testScheduler.currentTime - before
359+
360+
elapsed shouldBe shadowVibrator.pattern.sum()
361+
elapsed shouldBe 151L
362+
}
363+
364+
@Test
365+
fun `should await the full played waveform length including compat segments`() = runTest {
366+
// A single 100ms event plays as [100 active][1 gap][1 primer][1 end] = 103ms (the primer is
367+
// single-event only). execute() must delay for the whole 103ms, not the bare 100ms merged span,
368+
// so the caller resumes when the vibration truly ends.
369+
val pattern = HapticPattern(
370+
listOf(
371+
ScheduledHapticEvent(
372+
startTimeMs = 0,
373+
durationMs = 100,
374+
intensity = HapticIntensity.HIGH,
375+
),
376+
),
377+
)
378+
379+
val before = testScheduler.currentTime
380+
executor.execute(pattern)
381+
val elapsed = testScheduler.currentTime - before
382+
383+
elapsed shouldBe shadowVibrator.pattern.sum()
384+
elapsed shouldBe 103L
385+
}
386+
387+
@Test
388+
fun `should serialize overlapping events keeping higher intensity`() = runTest {
389+
// Overlap: [0,100)@HIGH overlaps [50,150)@MEDIUM.
390+
// Merged serial timeline: [0,50)@HIGH, [50,100)@HIGH (winner), [100,150)@MEDIUM.
391+
val pattern = HapticPattern(
392+
listOf(
393+
ScheduledHapticEvent(
394+
startTimeMs = 0,
395+
durationMs = 100,
396+
intensity = HapticIntensity.HIGH,
397+
),
398+
ScheduledHapticEvent(
399+
startTimeMs = 50,
400+
durationMs = 100,
401+
intensity = HapticIntensity.MEDIUM,
402+
),
403+
),
404+
)
405+
406+
executor.execute(pattern)
407+
408+
shadowVibrator.isVibrating shouldBe true
409+
// [50ms HIGH] + [50ms HIGH] + [50ms MEDIUM] + [1ms end], total span 150ms.
410+
shadowVibrator.pattern shouldBe longArrayOf(50, 50, 50, 1)
225411
}
226412

227413
@Test
@@ -302,7 +488,8 @@ class AndroidVibratorTest {
302488

303489
executor.execute(pattern)
304490

305-
// Should not crash, but also should not vibrate
491+
// Should not crash, and no waveform should ever reach the vibrator.
492+
shadowVibrator.pattern.shouldBeNull()
306493
shadowVibrator.isVibrating shouldBe false
307494
}
308495

0 commit comments

Comments
 (0)