539 lines
26 KiB
Kotlin

package de.timklge.karoopowerbar
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.WINDOW_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.os.Build
import android.util.DisplayMetrics
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import androidx.annotation.ColorRes
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants.UNIT_METERS
import com.mapbox.turf.TurfMeasurement
import de.timklge.karoopowerbar.KarooPowerbarExtension.Companion.TAG
import de.timklge.karoopowerbar.screens.SelectedSource
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import kotlin.math.roundToInt
fun remap(value: Double?, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double? {
if (value == null) return null
return (value - fromMin) * (toMax - toMin) / (fromMax - fromMin) + toMin
}
enum class PowerbarLocation {
TOP, BOTTOM
}
class Window(
private val context: Context,
val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM,
val showLabel: Boolean,
val barBackground: Boolean,
val powerbarBarSize: CustomProgressBarBarSize,
val powerbarFontSize: CustomProgressBarFontSize,
) {
companion object {
val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID";
val FIELD_TARGET_MIN_ID = "FIELD_WORKOUT_TARGET_MIN_VALUE_ID";
val FIELD_TARGET_MAX_ID = "FIELD_WORKOUT_TARGET_MAX_VALUE_ID";
}
private val rootView: View
private var layoutParams: WindowManager.LayoutParams? = null
private val windowManager: WindowManager
private val layoutInflater: LayoutInflater
private val powerbar: CustomProgressBar
var selectedSource: SelectedSource = SelectedSource.POWER
init {
layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE.or(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE),
PixelFormat.TRANSLUCENT
)
layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
rootView = layoutInflater.inflate(R.layout.popup_window, null)
powerbar = rootView.findViewById(R.id.progressBar)
powerbar.progress = null
windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
val displayMetrics = DisplayMetrics()
if (Build.VERSION.SDK_INT >= 30) {
val windowMetrics = windowManager.currentWindowMetrics
val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars())
val bounds = windowMetrics.bounds
displayMetrics.widthPixels = bounds.width() - insets.left - insets.right
displayMetrics.heightPixels = bounds.height() - insets.top - insets.bottom
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getMetrics(displayMetrics)
}
layoutParams?.gravity = when (powerbarLocation) {
PowerbarLocation.TOP -> Gravity.TOP
PowerbarLocation.BOTTOM -> Gravity.BOTTOM
}
if (powerbarLocation == PowerbarLocation.TOP) {
layoutParams?.y = 0
} else {
layoutParams?.y = 0
}
layoutParams?.width = displayMetrics.widthPixels
layoutParams?.alpha = 0.8f
}
private val karooSystem: KarooSystemService = KarooSystemService(context)
private var serviceJob: Job? = null
@SuppressLint("UnspecifiedRegisterReceiverFlag")
suspend fun open() {
serviceJob = CoroutineScope(Dispatchers.Default).launch {
val filter = IntentFilter("de.timklge.HIDE_POWERBAR")
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(hideReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(hideReceiver, filter)
}
karooSystem.connect { connected ->
Log.i(TAG, "Karoo system service connected: $connected")
}
powerbar.progressColor = context.resources.getColor(R.color.zone7)
powerbar.progress = null
powerbar.location = powerbarLocation
powerbar.showLabel = showLabel
powerbar.barBackground = barBackground
powerbar.fontSize = powerbarFontSize
powerbar.barSize = powerbarBarSize
powerbar.invalidate()
Log.i(TAG, "Streaming $selectedSource")
when (selectedSource){
SelectedSource.POWER -> streamPower(PowerStreamSmoothing.RAW)
SelectedSource.POWER_3S -> streamPower(PowerStreamSmoothing.SMOOTHED_3S)
SelectedSource.POWER_10S -> streamPower(PowerStreamSmoothing.SMOOTHED_10S)
SelectedSource.HEART_RATE -> streamHeartrate()
SelectedSource.SPEED -> streamSpeed(false)
SelectedSource.SPEED_3S -> streamSpeed(true)
SelectedSource.CADENCE -> streamCadence(false)
SelectedSource.CADENCE_3S -> streamCadence(true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress(::getRouteProgress)
SelectedSource.REMAINING_ROUTE -> streamRouteProgress(::getRemainingRouteProgress)
SelectedSource.GRADE -> streamGrade()
SelectedSource.NONE -> {}
}
}
try {
withContext(Dispatchers.Main) {
if (rootView.windowToken == null && rootView.parent == null) {
windowManager.addView(rootView, layoutParams)
}
}
} catch (e: Exception) {
Log.e(TAG, e.toString())
}
}
data class BarProgress(
val progress: Double?,
val label: String,
)
private fun getRouteProgress(userProfile: UserProfile, riddenDistance: Double?, routeEndAt: Double?, distanceToDestination: Double?): BarProgress {
val routeProgress = if (routeEndAt != null && riddenDistance != null) remap(riddenDistance, 0.0, routeEndAt, 0.0, 1.0) else null
val routeProgressInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> riddenDistance?.times(0.000621371)?.roundToInt() // Miles
else -> riddenDistance?.times(0.001)?.roundToInt() // Kilometers
}
return BarProgress(routeProgress, "$routeProgressInUserUnit")
}
private fun getRemainingRouteProgress(userProfile: UserProfile, riddenDistance: Double?, routeEndAt: Double?, distanceToDestination: Double?): BarProgress {
val routeProgress = if (routeEndAt != null && riddenDistance != null) remap(riddenDistance, 0.0, routeEndAt, 0.0, 1.0) else null
val distanceToDestinationInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> distanceToDestination?.times(0.000621371)?.roundToInt() // Miles
else -> distanceToDestination?.times(0.001)?.roundToInt() // Kilometers
}
return BarProgress(routeProgress, "$distanceToDestinationInUserUnit")
}
private suspend fun streamRouteProgress(routeProgressProvider: (UserProfile, Double?, Double?, Double?) -> BarProgress) {
data class StreamData(
val userProfile: UserProfile,
val distanceToDestination: Double?,
val navigationState: OnNavigationState,
val riddenDistance: Double?
)
var lastKnownRoutePolyline: String? = null
var lastKnownRouteLength: Double? = null
combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState(), karooSystem.streamDataFlow(DataType.Type.DISTANCE)) { userProfile, distanceToDestination, navigationState, riddenDistance ->
StreamData(
userProfile,
(distanceToDestination as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION),
navigationState,
(riddenDistance as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE)
)
}.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState, riddenDistance) ->
val state = navigationState.state
val routePolyline = when (state) {
is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline
is OnNavigationState.NavigationState.NavigatingToDestination -> state.polyline
else -> null
}
if (routePolyline != lastKnownRoutePolyline) {
lastKnownRoutePolyline = routePolyline
lastKnownRouteLength = when (state){
is OnNavigationState.NavigationState.NavigatingRoute -> state.routeDistance
is OnNavigationState.NavigationState.NavigatingToDestination -> try {
TurfMeasurement.length(LineString.fromPolyline(state.polyline, 5), UNIT_METERS)
} catch (e: Exception) {
Log.e(TAG, "Failed to calculate route length", e)
null
}
else -> null
}
}
val routeEndAt = lastKnownRouteLength?.plus((distanceToDestination ?: 0.0))
val barProgress = routeProgressProvider(userProfile, riddenDistance, routeEndAt, distanceToDestination)
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = barProgress.progress
powerbar.label = barProgress.label
powerbar.invalidate()
}
}
private suspend fun streamSpeed(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
val settingsFlow = context.streamSettings()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
combine(karooSystem.streamUserProfile(), speedFlow, settingsFlow) { userProfile, speed, settings ->
StreamData(userProfile, speed, settings)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val valueMetersPerSecond = streamData.value
val value = when (streamData.userProfile.preferredUnit.distance){
UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694)
else -> valueMetersPerSecond?.times(3.6)
}?.roundToInt()
if (value != null && valueMetersPerSecond != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Speed: Unavailable")
}
powerbar.invalidate()
}
}
private suspend fun streamGrade() {
@ColorRes
fun getInclineIndicatorColor(percent: Float): Int? {
return when(percent) {
in -Float.MAX_VALUE..<-7.5f -> R.color.eleDarkBlue // Dark blue
in -7.5f..<-4.6f -> R.color.eleLightBlue // Light blue
in -4.6f..<-2f -> R.color.eleWhite // White
in 2f..<4.6f -> R.color.eleDarkGreen // Dark green
in 4.6f..<7.5f -> R.color.eleLightGreen // Light green
in 7.5f..<12.5f -> R.color.eleYellow // Yellow
in 12.5f..<15.5f -> R.color.eleLightOrange // Light Orange
in 15.5f..<19.5f -> R.color.eleDarkOrange // Dark Orange
in 19.5f..<23.5f -> R.color.eleRed // Red
in 23.5f..Float.MAX_VALUE -> R.color.elePurple // Purple
else -> null
}
}
val gradeFlow = karooSystem.streamDataFlow(DataType.Type.ELEVATION_GRADE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
val settingsFlow = context.streamSettings()
combine(karooSystem.streamUserProfile(), gradeFlow, settingsFlow) { userProfile, grade, settings ->
StreamData(userProfile, grade, settings)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value
if (value != null) {
val minGradient = streamData.settings?.minGradient ?: PowerbarSettings.defaultMinGradient
val maxGradient = streamData.settings?.maxGradient ?: PowerbarSettings.defaultMaxGradient
powerbar.progressColor = getInclineIndicatorColor(value.toFloat()) ?: context.getColor(R.color.zone0)
powerbar.progress = remap(value.toDouble(), minGradient.toDouble(), maxGradient.toDouble(), 0.0, 1.0)
powerbar.label = "${String.format(Locale.getDefault(), "%.1f", value)}%"
Log.d(TAG, "Grade: $value")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Grade: Unavailable")
}
powerbar.invalidate()
}
}
private suspend fun streamCadence(smoothed: Boolean) {
val cadenceFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val cadenceTarget: DataPoint? = null)
val settingsFlow = context.streamSettings()
val cadenceTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_CADENCE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget ->
StreamData(userProfile, speed, settings, cadenceTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
powerbar.minTarget = remap(streamData.cadenceTarget?.values?.get(FIELD_TARGET_MIN_ID)?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.cadenceTarget?.values?.get(FIELD_TARGET_MAX_ID)?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.cadenceTarget?.values?.get(FIELD_TARGET_VALUE_ID)?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
}
powerbar.invalidate()
}
}
private suspend fun streamHeartrate() {
val hrFlow = karooSystem.streamDataFlow(DataType.Type.HEART_RATE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
val settingsFlow = context.streamSettings()
val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = null)
combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget ->
StreamData(userProfile, hr, settings, hrTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null
val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
val minHr = customMinHr ?: streamData.userProfile.restingHr
val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_MIN_ID), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_MAX_ID), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_VALUE_ID), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
}
powerbar.invalidate()
}
}
enum class PowerStreamSmoothing(val dataTypeId: String){
RAW(DataType.Type.POWER),
SMOOTHED_3S(DataType.Type.SMOOTHED_3S_AVERAGE_POWER),
SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER),
}
private suspend fun streamPower(smoothed: PowerStreamSmoothing) {
val powerFlow = karooSystem.streamDataFlow(smoothed.dataTypeId)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
val settingsFlow = context.streamSettings()
val powerTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_POWER_TARGET_ID") // TYPE_WORKOUT_HEART_RATE_TARGET_ID, TYPE_WORKOUT_CADENCE_TARGET_ID,
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = null)
combine(karooSystem.streamUserProfile(), powerFlow, settingsFlow, powerTargetFlow) { userProfile, hr, settings, powerTarget ->
StreamData(userProfile, hr, settings, powerTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_MIN_ID), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_MAX_ID), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_VALUE_ID), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
}
powerbar.invalidate()
}
}
private var currentHideJob: Job? = null
fun close() {
try {
context.unregisterReceiver(hideReceiver)
if (currentHideJob != null){
currentHideJob?.cancel()
currentHideJob = null
}
serviceJob?.cancel()
(context.getSystemService(WINDOW_SERVICE) as WindowManager).removeView(rootView)
rootView.invalidate()
(rootView.parent as? ViewGroup)?.removeAllViews()
} catch (e: Exception) {
Log.e(TAG, "Failed to dispose window", e)
}
}
private val hideReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == "de.timklge.HIDE_POWERBAR") {
val location = when (intent.getStringExtra("location")) {
"top" -> PowerbarLocation.TOP
"bottom" -> PowerbarLocation.BOTTOM
else -> PowerbarLocation.TOP
}
val duration = intent.getLongExtra("duration", 15_000)
Log.d(TAG, "Received broadcast to hide $location powerbar for $duration ms")
if (location == powerbarLocation) {
currentHideJob?.cancel()
currentHideJob = CoroutineScope(Dispatchers.Main).launch {
rootView.visibility = View.INVISIBLE
withContext(Dispatchers.Default) {
delay(duration)
}
rootView.visibility = View.VISIBLE
currentHideJob = null
}
}
}
}
}
}