diff --git a/app/src/main/java/com/sameerasw/essentials/domain/controller/HilightController.kt b/app/src/main/java/com/sameerasw/essentials/domain/controller/HilightController.kt index 775fb70b9..f29f68e51 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/controller/HilightController.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/controller/HilightController.kt @@ -9,10 +9,15 @@ package com.sameerasw.essentials.domain.controller +import android.content.Context +import android.os.BatteryManager import android.os.IBinder import android.os.SystemClock +import com.sameerasw.essentials.EssentialsApp import com.sameerasw.essentials.domain.model.HilightEffect import com.sameerasw.essentials.domain.model.HilightFrames +import com.sameerasw.essentials.domain.model.HilightPattern +import com.sameerasw.essentials.utils.AppColorUtil import com.sameerasw.essentials.utils.hardware.HilightLights import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,11 +44,36 @@ object HilightController { fun play( effect: HilightEffect, onStarted: (() -> Unit)? = null, - ): Boolean = - play( - durationMs = effect.durationMs.coerceAtLeast(HilightEffect.MIN_DURATION_MS), + ): Boolean { + val ledCount = HilightLights.array()?.ids?.size ?: HilightEffect.DEFAULT_LED_COUNT + val batteryLevel = + if (effect.pattern == HilightPattern.BATTERY) { + EssentialsApp.context + .getSystemService(BatteryManager::class.java) + ?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: 100 + } else { + 100 + } + return play( + durationMs = HilightFrames.durationMs(effect, ledCount), onStarted = onStarted, - ) { elapsedMs, ledCount -> HilightFrames.frame(effect, elapsedMs, ledCount) } + ) { elapsedMs, count -> HilightFrames.frame(effect, elapsedMs, count, batteryLevel) } + } + + // Plays in the app's colour when autoColor is on; the lookup is cached after the first call + fun playForApp( + context: Context, + packageName: String, + effect: HilightEffect, + onStarted: (() -> Unit)? = null, + ): Boolean { + if (!effect.autoColor) return play(effect, onStarted) + if (!HilightLights.isAccessGranted()) return false + AppColorUtil.resolveColor(context, packageName) { color -> + play(effect.copy(color = HilightEffect.appColorOr(color, effect.color)), onStarted) + } + return true + } // Shows frame(elapsedMs, ledCount) until durationMs, replacing whatever is playing @Synchronized diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index fc5d67bcd..67b0a4e12 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -48,6 +48,8 @@ sealed interface Action { @SerializedName("pattern") val pattern: HilightPattern = HilightPattern.GLOW, @SerializedName("color") val color: Int = HilightEffect.DEFAULT_COLOR, @SerializedName("durationMs") val durationMs: Long = HilightEffect.DEFAULT_DURATION_MS, + @SerializedName("brightness") val brightness: Int = 100, + @SerializedName("repeats") val repeats: Int = 0, ) : Action { override val title: Int get() = R.string.diy_action_hilight override val icon: Int get() = R.drawable.rounded_auto_awesome_24 @@ -56,7 +58,7 @@ sealed interface Action { // Gson sets an unknown pattern name to null despite the non-null type @Suppress("USELESS_ELVIS") - fun toEffect() = HilightEffect(pattern ?: HilightPattern.GLOW, color, durationMs) + fun toEffect() = HilightEffect(pattern ?: HilightPattern.GLOW, color, durationMs, brightness, repeats) } @Keep diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/HilightEffect.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/HilightEffect.kt index 6f6ac55e0..3b2b7e6ce 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/HilightEffect.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/HilightEffect.kt @@ -16,6 +16,8 @@ import com.sameerasw.essentials.R import kotlin.math.PI import kotlin.math.abs import kotlin.math.cos +import kotlin.math.max +import kotlin.math.round @Keep enum class HilightPattern( @@ -36,8 +38,20 @@ enum class HilightPattern( @SerializedName("SWEEP") SWEEP(R.string.hilight_pattern_sweep), + @SerializedName("PULSE") + PULSE(R.string.hilight_pattern_pulse), + + @SerializedName("RIPPLE") + RIPPLE(R.string.hilight_pattern_ripple), + + @SerializedName("SPARKLE") + SPARKLE(R.string.hilight_pattern_sparkle), + @SerializedName("RAINBOW") RAINBOW(R.string.hilight_pattern_rainbow), + + @SerializedName("BATTERY") + BATTERY(R.string.hilight_pattern_battery), } @Keep @@ -45,6 +59,9 @@ data class HilightEffect( @SerializedName("pattern") val pattern: HilightPattern = HilightPattern.GLOW, @SerializedName("color") val color: Int = DEFAULT_COLOR, @SerializedName("durationMs") val durationMs: Long = DEFAULT_DURATION_MS, + @SerializedName("brightness") val brightness: Int = 100, + @SerializedName("repeats") val repeats: Int = 0, + @SerializedName("autoColor") val autoColor: Boolean = false, ) { // Gson sets an unknown pattern name to null despite the non-null type @Suppress("USELESS_ELVIS") @@ -57,6 +74,16 @@ data class HilightEffect( // No published guidance exists for sustained use of the array, so keep effects short const val MAX_DURATION_MS = 30_000L + + const val MIN_BRIGHTNESS = 10 + const val MAX_REPEATS = 10 + const val DEFAULT_REPEATS = 3 + const val DEFAULT_LED_COUNT = 8 + + fun appColorOr( + appColor: Int, + fallback: Int, + ): Int = if (appColor ushr 24 == 0) fallback else appColor } } @@ -68,9 +95,25 @@ object HilightFrames { effect: HilightEffect, elapsedMs: Long, ledCount: Int, + batteryLevel: Int = 100, ): IntArray { val t = elapsedMs.coerceAtLeast(0) - return when (effect.pattern) { + val base = patternFrame(effect, t, ledCount, batteryLevel) + return if (effect.brightness < 100) { + val factor = effect.brightness / 100f + IntArray(ledCount) { i -> scale(base[i], factor) } + } else { + base + } + } + + private fun patternFrame( + effect: HilightEffect, + t: Long, + ledCount: Int, + batteryLevel: Int, + ): IntArray = + when (effect.pattern) { HilightPattern.SOLID -> IntArray(ledCount) { opaque(effect.color) } HilightPattern.BLINK -> uniform(ledCount, effect.color, if ((t / BLINK_HALF_MS) % 2 == 0L) 1f else 0f) HilightPattern.GLOW -> { @@ -92,20 +135,101 @@ object HilightFrames { } } } + HilightPattern.PULSE -> { + fun beat(x: Long): Float = if (x in 0 until 200) 1f - x / 200f else 0f + val c = t % PULSE_PERIOD_MS + val level = max(beat(c), beat(c - 280)) + uniform(ledCount, effect.color, level) + } + HilightPattern.RIPPLE -> { + val centre = (ledCount - 1) / 2f + val maxD = centre + 1f + val front = (t % RIPPLE_PERIOD_MS).toFloat() / RIPPLE_PERIOD_MS * maxD + IntArray(ledCount) { i -> + val d = abs(i - centre) + val level = (1f - abs(d - front)).coerceIn(0f, 1f) + scale(effect.color, level) + } + } + HilightPattern.SPARKLE -> { + val step = t / SPARKLE_STEP_MS + IntArray(ledCount) { i -> + var h = (i * 73856093) xor (step * 19349663L).toInt() + h = h xor (h ushr 13) + if ((h and 3) == 0) { + val level = 0.4f + ((h ushr 2) and 0xFF) / 255f * 0.6f + scale(effect.color, level) + } else { + OFF + } + } + } HilightPattern.RAINBOW -> IntArray(ledCount) { i -> - val hue = ((t % RAINBOW_PERIOD_MS).toFloat() / RAINBOW_PERIOD_MS * 360f + i * 360f / ledCount.coerceAtLeast(1)) % 360f + val hue = + ((t % RAINBOW_PERIOD_MS).toFloat() / RAINBOW_PERIOD_MS * 360f + i * 360f / ledCount.coerceAtLeast(1)) % 360f hueToColor(hue) } + HilightPattern.BATTERY -> { + val maxLit = HilightProgressFrames.litCount(batteryLevel.toFloat(), ledCount) + val filled = ((t / BATTERY_STEP_MS) + 1).toInt().coerceAtMost(maxLit) + IntArray(ledCount) { i -> if (i < filled) scale(effect.color, 1f) else OFF } + } } + + fun cycleMs( + pattern: HilightPattern, + ledCount: Int, + ): Long? = + when (pattern) { + HilightPattern.BLINK -> 2 * BLINK_HALF_MS + HilightPattern.GLOW -> GLOW_PERIOD_MS + HilightPattern.FLASH -> FLASH_PERIOD_MS + HilightPattern.SWEEP -> SWEEP_STEP_MS * ledCount + HilightPattern.PULSE -> PULSE_PERIOD_MS + HilightPattern.RIPPLE -> RIPPLE_PERIOD_MS + HilightPattern.RAINBOW -> RAINBOW_PERIOD_MS + HilightPattern.SOLID, + HilightPattern.SPARKLE, + HilightPattern.BATTERY, + -> null + } + + fun durationMs( + effect: HilightEffect, + ledCount: Int, + ): Long { + val cycle = cycleMs(effect.pattern, ledCount) + val duration = + if (effect.repeats > 0 && cycle != null) { + effect.repeats * cycle + } else { + effect.durationMs.coerceAtLeast(HilightEffect.MIN_DURATION_MS) + } + return duration.coerceAtMost(HilightEffect.MAX_DURATION_MS) } + fun repeatsFor(effect: HilightEffect): Int { + if (effect.repeats > 0) return effect.repeats + val cycle = cycleMs(effect.pattern, HilightEffect.DEFAULT_LED_COUNT) ?: return HilightEffect.DEFAULT_REPEATS + val count = round(effect.durationMs.toFloat() / cycle).toInt() + return count.coerceIn(1, HilightEffect.MAX_REPEATS) + } + + // The repeat count the editor shows, so an effect saved without touching the slider plays what it showed + fun withShownRepeats(effect: HilightEffect): HilightEffect = + if (effect.repeats == 0 && cycleMs(effect.pattern, HilightEffect.DEFAULT_LED_COUNT) != null) { + effect.copy(repeats = repeatsFor(effect)) + } else { + effect + } + fun scale( color: Int, level: Float, ): Int { val f = level.coerceIn(0f, 1f) - if (f == 0f) return OFF + if (color == OFF || f == 0f) return OFF val r = (((color shr 16) and 0xFF) * f).toInt() val g = (((color shr 8) and 0xFF) * f).toInt() val b = ((color and 0xFF) * f).toInt() @@ -145,4 +269,8 @@ object HilightFrames { private const val SWEEP_STEP_MS = 90L private const val SWEEP_TAIL_LEVEL = 0.3f private const val RAINBOW_PERIOD_MS = 3_000L + private const val PULSE_PERIOD_MS = 1_200L + private const val RIPPLE_PERIOD_MS = 1_200L + private const val SPARKLE_STEP_MS = 120L + private const val BATTERY_STEP_MS = 150L } diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 929b0a581..fbfdb51e1 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -1429,7 +1429,7 @@ class NotificationListener : NotificationListenerService() { val now = SystemClock.elapsedRealtime() val last = lastHilightTime[sbn.packageName] if (last != null && now - last < settings.getHilightCooldownSeconds() * 1000L) return - HilightController.play(effect) { lastHilightTime[sbn.packageName] = SystemClock.elapsedRealtime() } + HilightController.playForApp(this, sbn.packageName, effect) { lastHilightTime[sbn.packageName] = SystemClock.elapsedRealtime() } } private val lastHilightTime = ConcurrentHashMap() diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/HilightEffectSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/HilightEffectSheet.kt index 93741b339..f7931c71f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/HilightEffectSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/HilightEffectSheet.kt @@ -4,7 +4,7 @@ * * Feature Module: Core UI Components * File: HilightEffectSheet.kt - * Description: Pattern, colour and duration editor for Hilight effects, with a live try-out. + * Description: Pattern, colour, timing and brightness editor for Hilight effects, with a live try-out. */ package com.sameerasw.essentials.ui.core.sheets @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilterChip @@ -42,8 +44,10 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.controller.HilightController import com.sameerasw.essentials.domain.diy.Action import com.sameerasw.essentials.domain.model.HilightEffect +import com.sameerasw.essentials.domain.model.HilightFrames import com.sameerasw.essentials.domain.model.HilightPattern import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem +import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.ColorSwatchPicker import com.sameerasw.essentials.utils.HapticUtil @@ -54,6 +58,8 @@ fun HilightEffectEditor( onEffectChange: (HilightEffect) -> Unit, modifier: Modifier = Modifier, canTry: Boolean = true, + patterns: List = HilightPattern.entries, + appPackage: String? = null, ) { val context = LocalContext.current val view = LocalView.current @@ -67,7 +73,7 @@ fun HilightEffectEditor( .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - HilightPattern.entries.forEach { pattern -> + patterns.forEach { pattern -> FilterChip( selected = effect.pattern == pattern, onClick = { @@ -80,8 +86,20 @@ fun HilightEffectEditor( } } + if (appPackage != null && effect.pattern != HilightPattern.RAINBOW) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_palette_24, + title = stringResource(R.string.hilight_auto_color_title), + description = stringResource(R.string.hilight_auto_color_desc), + isChecked = effect.autoColor, + onCheckedChange = { onEffectChange(effect.copy(autoColor = it)) }, + ) + } + } + // Rainbow cycles through every hue, so a colour choice would have no effect - if (effect.pattern != HilightPattern.RAINBOW) { + if (effect.pattern != HilightPattern.RAINBOW && !(effect.autoColor && appPackage != null)) { ColorSwatchPicker( selectedColorHex = toHex(effect.color), onColorSelected = { hex -> onEffectChange(effect.copy(color = Color.parseColor(hex))) }, @@ -89,22 +107,51 @@ fun HilightEffectEditor( } RoundedCardContainer { + if (HilightFrames.cycleMs(effect.pattern, HilightEffect.DEFAULT_LED_COUNT) != null) { + ConfigSliderItem( + title = stringResource(R.string.hilight_repeats_title), + value = HilightFrames.repeatsFor(effect).toFloat(), + onValueChange = { onEffectChange(effect.copy(repeats = it.toInt())) }, + valueRange = 1f..HilightEffect.MAX_REPEATS.toFloat(), + increment = 1f, + steps = HilightEffect.MAX_REPEATS - 2, + valueFormatter = { context.getString(R.string.hilight_repeats_value, it.toInt()) }, + iconRes = R.drawable.rounded_sync_24, + ) + } else { + ConfigSliderItem( + title = stringResource(R.string.hilight_duration_title), + value = effect.durationMs / 1000f, + onValueChange = { onEffectChange(effect.copy(durationMs = (it * 1000).toLong())) }, + valueRange = HilightEffect.MIN_DURATION_MS / 1000f..HilightEffect.MAX_DURATION_MS / 1000f, + increment = 1f, + steps = ((HilightEffect.MAX_DURATION_MS - HilightEffect.MIN_DURATION_MS) / 1000 - 1).toInt(), + valueFormatter = { context.getString(R.string.hilight_duration_value, it.toInt()) }, + iconRes = R.drawable.rounded_timer_24, + ) + } ConfigSliderItem( - title = stringResource(R.string.hilight_duration_title), - value = effect.durationMs / 1000f, - onValueChange = { onEffectChange(effect.copy(durationMs = (it * 1000).toLong())) }, - valueRange = HilightEffect.MIN_DURATION_MS / 1000f..HilightEffect.MAX_DURATION_MS / 1000f, - increment = 1f, - steps = ((HilightEffect.MAX_DURATION_MS - HilightEffect.MIN_DURATION_MS) / 1000 - 1).toInt(), - valueFormatter = { context.getString(R.string.hilight_duration_value, it.toInt()) }, - iconRes = R.drawable.rounded_timer_24, + title = stringResource(R.string.hilight_brightness_title), + value = effect.brightness.toFloat(), + onValueChange = { onEffectChange(effect.copy(brightness = it.toInt())) }, + valueRange = HilightEffect.MIN_BRIGHTNESS.toFloat()..100f, + increment = 10f, + steps = ((100 - HilightEffect.MIN_BRIGHTNESS) / 10 - 1), + valueFormatter = { context.getString(R.string.hilight_brightness_value, it.toInt()) }, + iconRes = R.drawable.rounded_brightness_medium_24, ) } OutlinedButton( onClick = { HapticUtil.performVirtualKeyHaptic(view) - if (!HilightController.play(effect)) { + val played = + if (appPackage != null) { + HilightController.playForApp(context, appPackage, HilightFrames.withShownRepeats(effect)) + } else { + HilightController.play(HilightFrames.withShownRepeats(effect)) + } + if (!played) { Toast.makeText(context, R.string.hilight_unavailable_toast, Toast.LENGTH_SHORT).show() } }, @@ -132,7 +179,7 @@ fun HilightEffectSettingsSheet( title = stringResource(R.string.diy_action_hilight), initialEffect = initialAction.toEffect(), onDismiss = onDismiss, - onSave = { onSave(Action.Hilight(it.pattern, it.color, it.durationMs)) }, + onSave = { onSave(Action.Hilight(it.pattern, it.color, it.durationMs, it.brightness, it.repeats)) }, ) } @@ -143,6 +190,8 @@ fun HilightEffectSheet( onDismiss: () -> Unit, onSave: (HilightEffect) -> Unit, canTry: Boolean = true, + patterns: List = HilightPattern.entries, + appPackage: String? = null, ) { val view = LocalView.current var effect by remember { mutableStateOf(initialEffect) } @@ -152,7 +201,8 @@ fun HilightEffectSheet( modifier = Modifier .padding(16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( @@ -162,7 +212,13 @@ fun HilightEffectSheet( color = MaterialTheme.colorScheme.onSurface, ) - HilightEffectEditor(effect = effect, onEffectChange = { effect = it }, canTry = canTry) + HilightEffectEditor( + effect = effect, + onEffectChange = { effect = it }, + canTry = canTry, + patterns = patterns, + appPackage = appPackage, + ) Row( modifier = Modifier.fillMaxWidth(), @@ -194,7 +250,7 @@ fun HilightEffectSheet( onClick = { HapticUtil.performVirtualKeyHaptic(view) HilightController.stop() - onSave(effect) + onSave(HilightFrames.withShownRepeats(effect)) }, modifier = Modifier.weight(1f), ) { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/HilightSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/HilightSettingsUI.kt index 74b16c467..55abc84df 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/HilightSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/HilightSettingsUI.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.controller.HilightController import com.sameerasw.essentials.domain.model.HilightEffect +import com.sameerasw.essentials.domain.model.HilightFrames import com.sameerasw.essentials.domain.model.HilightPattern import com.sameerasw.essentials.domain.model.HilightProgressColorMode import com.sameerasw.essentials.domain.model.HilightProgressFrames @@ -68,6 +69,7 @@ import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker import com.sameerasw.essentials.ui.core.sheets.HilightEffectSheet import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.AppColorUtil import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.hardware.HilightLights @@ -214,6 +216,8 @@ fun HilightNotificationsSettingsUI( editingPackage = null }, canTry = isHilightDevice, + patterns = HilightPattern.entries - HilightPattern.BATTERY, + appPackage = packageName, ) } } @@ -429,6 +433,11 @@ private fun HilightAppItem( .getOrNull() } } + val dotColor by produceState(effect.color, packageName, effect.autoColor) { + if (effect.autoColor) { + AppColorUtil.resolveColor(context, packageName) { value = HilightEffect.appColorOr(it, effect.color) } + } + } Row( modifier = @@ -470,16 +479,24 @@ private fun HilightAppItem( Modifier .size(10.dp) .clip(CircleShape) - .background(Color(effect.color)), + .background(Color(dotColor)), ) } Text( text = - stringResource( - R.string.hilight_app_effect_summary, - stringResource(effect.pattern.title), - (effect.durationMs / 1000).toInt(), - ), + if (effect.repeats > 0 && HilightFrames.cycleMs(effect.pattern, HilightEffect.DEFAULT_LED_COUNT) != null) { + stringResource( + R.string.hilight_app_effect_summary_repeats, + stringResource(effect.pattern.title), + effect.repeats, + ) + } else { + stringResource( + R.string.hilight_app_effect_summary, + stringResource(effect.pattern.title), + (effect.durationMs / 1000).toInt(), + ) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f97275599..bb2f52fff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -296,6 +296,17 @@ Flash Sweep Rainbow + Pulse + Ripple + Sparkle + Battery level + Repeat + %1$d× + Brightness + %1$d%% + %1$s · %2$d× + Match app color + Use the app\'s icon color, or the one you set in Custom app colors Live progress Notification effects Show download and live update progress on the LEDs diff --git a/app/src/test/java/com/sameerasw/essentials/domain/HilightFramesTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/HilightFramesTest.kt index 060d48c7c..e6f1d57c8 100644 --- a/app/src/test/java/com/sameerasw/essentials/domain/HilightFramesTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/domain/HilightFramesTest.kt @@ -75,10 +75,105 @@ class HilightFramesTest { @Test fun hilightActionRoundTrips() { - val action = Action.Hilight(HilightPattern.SWEEP, red, 5_000) + val action = Action.Hilight(HilightPattern.SWEEP, red, 5_000, brightness = 40, repeats = 4) assertEquals(action, ActionGsonAdapter.fromJson(ActionGsonAdapter.toJson(action))) } + @Test + fun pulseBeatsTwiceThenRests() { + assertArrayEquals(IntArray(8) { red }, frame(HilightPattern.PULSE, 0)) + assertArrayEquals(IntArray(8) { red }, frame(HilightPattern.PULSE, 280)) + assertEquals(HilightFrames.scale(red, 0.25f), frame(HilightPattern.PULSE, 150)[0]) + assertArrayEquals(IntArray(8) { HilightFrames.OFF }, frame(HilightPattern.PULSE, 800)) + } + + @Test + fun rippleStartsInTheMiddle() { + val colors = frame(HilightPattern.RIPPLE, 0) + assertEquals(HilightFrames.scale(red, 0.5f), colors[3]) + assertEquals(HilightFrames.scale(red, 0.5f), colors[4]) + assertEquals(HilightFrames.OFF, colors[0]) + assertEquals(HilightFrames.OFF, colors[7]) + assertTrue(((colors[3] shr 16) and 0xFF) > ((colors[0] shr 16) and 0xFF)) + assertTrue(((colors[4] shr 16) and 0xFF) > ((colors[7] shr 16) and 0xFF)) + } + + @Test + fun sparkleIsDeterministicAndChanges() { + val frame0a = frame(HilightPattern.SPARKLE, 0) + val frame0b = frame(HilightPattern.SPARKLE, 0) + assertArrayEquals(frame0a, frame0b) + + val frame120 = frame(HilightPattern.SPARKLE, 120) + assertTrue(!frame0a.contentEquals(frame120)) + + val allLeds = + (0 until 10).flatMap { step -> + frame(HilightPattern.SPARKLE, step * 120L).toList() + } + assertTrue(allLeds.any { it != HilightFrames.OFF }) + assertTrue(allLeds.any { it == HilightFrames.OFF }) + } + + @Test + fun batteryFillsToLevelThenHolds() { + val effect = HilightEffect(HilightPattern.BATTERY, red) + val frameStart = HilightFrames.frame(effect, 0, 8, batteryLevel = 50) + assertEquals(1, frameStart.count { it == red }) + assertEquals(7, frameStart.count { it == HilightFrames.OFF }) + + val frameEnd = HilightFrames.frame(effect, 8 * 150L, 8, batteryLevel = 50) + assertEquals(4, frameEnd.count { it == red }) + assertEquals(4, frameEnd.count { it == HilightFrames.OFF }) + for (i in 0 until 4) { + assertEquals(red, frameEnd[i]) + } + for (i in 4 until 8) { + assertEquals(HilightFrames.OFF, frameEnd[i]) + } + } + + @Test + fun brightnessScalesEveryLed() { + val effect50 = HilightEffect(HilightPattern.SOLID, red, brightness = 50) + assertArrayEquals(IntArray(8) { HilightFrames.scale(red, 0.5f) }, HilightFrames.frame(effect50, 0, 8)) + + val effect100 = HilightEffect(HilightPattern.SOLID, red, brightness = 100) + assertArrayEquals(IntArray(8) { red }, HilightFrames.frame(effect100, 0, 8)) + } + + @Test + fun durationUsesWholeCycles() { + val glow = HilightEffect(HilightPattern.GLOW, red, durationMs = 3000, repeats = 3) + assertEquals(6_000L, HilightFrames.durationMs(glow, 8)) + + val rainbow = HilightEffect(HilightPattern.RAINBOW, red, durationMs = 3000, repeats = 10) + assertEquals(30_000L, HilightFrames.durationMs(rainbow, 8)) + + val solid = HilightEffect(HilightPattern.SOLID, red, durationMs = 5000, repeats = 3) + assertEquals(5_000L, HilightFrames.durationMs(solid, 8)) + + val repeatsZero = HilightEffect(HilightPattern.GLOW, red, durationMs = 5000, repeats = 0) + assertEquals(5_000L, HilightFrames.durationMs(repeatsZero, 8)) + } + + @Test + fun repeatsForDerivesFromLegacyDuration() { + val effect = HilightEffect(HilightPattern.GLOW, red, durationMs = 6000, repeats = 0) + assertEquals(3, HilightFrames.repeatsFor(effect)) + } + + @Test + fun oldHilightJsonLoadsWithDefaults() { + val json = """{"type":"Hilight","pattern":"GLOW","color":-48060,"durationMs":5000}""" + val action = ActionGsonAdapter.fromJson(json) as Action.Hilight + assertEquals(100, action.brightness) + assertEquals(0, action.repeats) + val effect = action.toEffect() + assertEquals(100, effect.brightness) + assertEquals(0, effect.repeats) + } + @Test fun unknownPatternNamesFallBackToGlow() { val gson = Gson() @@ -87,4 +182,33 @@ class HilightFramesTest { val action = gson.fromJson("""{"pattern":"CHASE","color":-1,"durationMs":3000}""", Action.Hilight::class.java) assertEquals(HilightPattern.GLOW, action.toEffect().pattern) } + + @Test + fun appColorOrFallsBackWhenTransparent() { + assertEquals(red, HilightEffect.appColorOr(0x00000000, red)) + assertEquals(red, HilightEffect.appColorOr(0x00123456, red)) + assertEquals(0xFF00FF00.toInt(), HilightEffect.appColorOr(0xFF00FF00.toInt(), red)) + } + + @Test + fun autoColorRoundTripsAndDefaultsOff() { + val gson = Gson() + val loaded = gson.fromJson(gson.toJson(HilightEffect(autoColor = true)), HilightEffect::class.java) + assertEquals(true, loaded.autoColor) + + val legacy = gson.fromJson("""{"pattern":"GLOW","color":-48060,"durationMs":3000}""", HilightEffect::class.java) + assertEquals(false, legacy.autoColor) + } + + @Test + fun withShownRepeatsFillsTheShownCount() { + val glowDefault = HilightEffect(HilightPattern.GLOW, red, durationMs = 3000, repeats = 0) + assertEquals(2, HilightFrames.withShownRepeats(glowDefault).repeats) + + val solid = HilightEffect(HilightPattern.SOLID, red, repeats = 0) + assertEquals(0, HilightFrames.withShownRepeats(solid).repeats) + + val glowExplicit = HilightEffect(HilightPattern.GLOW, red, repeats = 4) + assertEquals(4, HilightFrames.withShownRepeats(glowExplicit).repeats) + } }