Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ba3ed7e
build: run JUnit4 Robolectric tests via the vintage engine
l2hyunwoo Jun 27, 2026
dc66e2b
feat(core): add serial-timeline merge and fall-ramp primitives for ov…
l2hyunwoo Jun 27, 2026
9007991
fix(android): serialize overlapping events and soften amplitude drops…
l2hyunwoo Jun 27, 2026
cb84684
fix(android): await the full waveform length including compat segments
l2hyunwoo Jun 28, 2026
9700e78
test(android): cover await length and sub-threshold gap handling
l2hyunwoo Jun 28, 2026
e8d6160
fix(android): skip vibration for zero-duration or all-gap patterns
l2hyunwoo Jun 28, 2026
9725488
test(android): cover zero-duration no-op and overlap await duration
l2hyunwoo Jun 28, 2026
f7996dc
test(core): property-test fall-ramp duration conservation across gap …
l2hyunwoo Jun 28, 2026
bd7860d
docs: write detail process of `HapticPattern.toWaveform()`
l2hyunwoo Jun 28, 2026
a4a0647
fix(core): expire HapticHandle.isActive when the estimated playback ends
l2hyunwoo Jun 28, 2026
b347741
test(core): cover HapticHandle expiry and natural-completion deactiva…
l2hyunwoo Jun 28, 2026
13e8386
refactor(core): split executor helpers into one declaration per file
l2hyunwoo Jun 28, 2026
34c376e
test(android): assert no waveform reaches the vibrator for silent pat…
l2hyunwoo Jun 28, 2026
c23df09
docs: document max-intensity resolution for overlapping included patt…
l2hyunwoo Jun 28, 2026
812098d
docs: clarify fall-ramp scope and Samsung primer placement rationale
l2hyunwoo Jul 7, 2026
fbe49c2
refactor(core): extract playbackDurationMs to unify duration derivati…
l2hyunwoo Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions documentation/content/docs/api/jindong-core/core-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions jindong-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ kotlin {
commonTest.dependencies {
implementation(libs.kotest.framework.engine)
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.property)
}

named("androidHostTest").dependencies {
Expand All @@ -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)
Comment thread
l2hyunwoo marked this conversation as resolved.
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@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
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading