Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit2.kotlin.coroutines.adapter)
implementation(libs.semver4j)
implementation(libs.kotlinx.coroutines.jdk9)
kapt(libs.hilt.compiler)
ksp(libs.androidx.room.compiler)
ksp(libs.androidx.room.compiler)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".service.DeviceControlsProviderService"
android:label="@string/app_name"
android:permission="android.permission.BIND_CONTROLS"
android:exported="true">
<intent-filter>
<action android:name="android.service.controls.ControlsProviderService" />
</intent-filter>
</service>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class DeviceRepository @Inject constructor(private val deviceDao: DeviceDao) {
return deviceDao.getAllDevices()
}

@WorkerThread
suspend fun findDeviceByAddress(address: String): Device? {
return deviceDao.findDeviceByAddress(address)
}

@WorkerThread
fun findLiveDeviceByAddress(address: String): Flow<Device?> {
return deviceDao.findLiveDeviceByAddress(address)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package ca.cgagnier.wlednativeandroid.service

import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.controls.Control
import android.service.controls.ControlsProviderService
import android.service.controls.DeviceTypes
import android.service.controls.actions.BooleanAction
import android.service.controls.actions.ControlAction
import android.service.controls.actions.FloatAction
import android.service.controls.templates.ControlButton
import android.service.controls.templates.RangeTemplate
import android.service.controls.templates.ToggleRangeTemplate
import android.service.controls.templates.ToggleTemplate
import androidx.annotation.RequiresApi
import ca.cgagnier.wlednativeandroid.R
import ca.cgagnier.wlednativeandroid.model.Device
import ca.cgagnier.wlednativeandroid.model.wledapi.JsonPost
import ca.cgagnier.wlednativeandroid.repository.DeviceRepository
import ca.cgagnier.wlednativeandroid.service.device.StateFactory
import ca.cgagnier.wlednativeandroid.service.device.api.request.StateChangeRequest
import ca.cgagnier.wlednativeandroid.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.jdk9.asPublisher
import kotlinx.coroutines.launch
import java.util.concurrent.Flow
import java.util.function.Consumer
import javax.inject.Inject

@RequiresApi(Build.VERSION_CODES.R)
@AndroidEntryPoint
class DeviceControlsProviderService : ControlsProviderService() {
@Inject
lateinit var repository: DeviceRepository
@Inject
lateinit var stateFactory: StateFactory

private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)

@OptIn(ExperimentalCoroutinesApi::class)
override fun createPublisherForAllAvailable(): Flow.Publisher<Control> {
return repository.allDevices
.take(1)
.flatMapLatest { deviceList ->
deviceList.asFlow()
}.map { device ->
createStatelessControl(device)
}.asPublisher()
}

override fun createPublisherFor(controlIds: List<String>): Flow.Publisher<Control> {
val flows = controlIds
.map { controlId ->
repository.findLiveDeviceByAddress(controlId)
.map { device ->
if (device != null)
createStatefulControl(device)
else
createErrorControl(controlId)
}
}

return flows.merge().asPublisher()
}

override fun performControlAction(
controlId: String,
action: ControlAction,
consumer: Consumer<Int>
) {
scope.launch {
// controlId is device address
val device = repository.findDeviceByAddress(controlId)
device?.let {
when (action) {
is BooleanAction -> toggleDevicePower(device, action.newState)
is FloatAction -> setDeviceBrightness(device, action.newValue.toInt())

else -> consumer.accept(ControlAction.RESPONSE_FAIL)
}

consumer.accept(ControlAction.RESPONSE_OK)
} ?: consumer.accept(ControlAction.RESPONSE_FAIL)
}
}

private fun createStatelessControl(device: Device): Control {
return Control.StatelessBuilder(device.address, createAppIntentForDevice(device))
.setTitle(device.name)
.setDeviceType(DeviceTypes.TYPE_LIGHT)
.build()
}

private fun createStatefulControl(device: Device): Control {
val control = Control.StatefulBuilder(device.address, createAppIntentForDevice(device))
.setTitle(device.name)
.setDeviceType(DeviceTypes.TYPE_LIGHT)
.setStatus(Control.STATUS_OK)

// set a proper message instead of the ones in Control.STATUS_*
if (device.isOnline) {
// set status text based on state
if (device.isPoweredOn) {
control.setStatusText("On")
} else {
control.setStatusText("Off")
}

// and add controls
control.setControlTemplate(
ToggleRangeTemplate(
device.address,
ControlButton(
device.isPoweredOn,
applicationContext.getString(R.string.device_controls_control_button_action_description)
),
// TODO: not sure about the mapping here
RangeTemplate(
device.address,
1f,
255f,
device.brightness.toFloat(),
1f,
null
)
)
)
} else {
// offline
control.setStatusText("Offline")
// set the template to toggle with forced value of off
control.setControlTemplate(
ToggleTemplate(
device.address,
ControlButton(
false,
applicationContext.getString(R.string.device_controls_control_button_action_description)
)
)
)
}

return control.build()
}

/**
* Create error like control for use when corresponding device was removed in app
*/
private fun createErrorControl(controlId: String): Control {
val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

return Control.StatefulBuilder(controlId, pendingIntent)
.setTitle(applicationContext.getString(R.string.device_controls_device_not_found))
.setStatus(Control.STATUS_NOT_FOUND)
.build()
}

/**
* Create [PendingIntent] that launches device specific screen in the app
*/
private fun createAppIntentForDevice(device: Device): PendingIntent {
// TODO: we can just remove everything but numbers and be left with a string of a unique integer
val integerId = device.macAddress.hashCode()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using String.hashCode() for the PendingIntent request code is not ideal. Hash codes are not guaranteed to be unique, which could lead to collisions where different devices get the same PendingIntent, causing unexpected behavior.

A more robust approach is to generate a unique integer from the device's MAC address.

        val integerId = try {
            // Use the last 6 characters of the MAC address, which should be unique enough for this purpose.
            device.macAddress.replace(":", "").takeLast(6).toInt(16)
        } catch (e: Exception) {
            // Fallback to hashCode if parsing fails for some reason (e.g. invalid MAC format)
            device.macAddress.hashCode()
        }


val intent = Intent(this, MainActivity::class.java) // TODO: maybe open device specific screen
.putExtra(EXTRA_DEVICE_MAC, device.macAddress)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val action = PendingIntent.getActivity(
this,
integerId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

return action
}

// copied from DeviceListViewModel
private fun toggleDevicePower(device: Device, isOn: Boolean) {
val deviceSetPost = JsonPost(isOn = isOn)
scope.launch(Dispatchers.IO) {
stateFactory.getState(device).requestsManager.addRequest(
StateChangeRequest(device, deviceSetPost)
)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method is copied from DeviceListViewModel, which introduces code duplication. This should be refactored by extracting the device action logic into a shared component (e.g., a DeviceActionManager or a UseCase) that can be injected into both the ViewModel and this Service.

Additionally, this function is called from a coroutine that already runs on Dispatchers.IO. The inner scope.launch(Dispatchers.IO) is redundant and creates an unnecessary nested coroutine. You can remove the inner launch.

    private fun toggleDevicePower(device: Device, isOn: Boolean) {
        val deviceSetPost = JsonPost(isOn = isOn)
        stateFactory.getState(device).requestsManager.addRequest(
            StateChangeRequest(device, deviceSetPost)
        )
    }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicated scope.launch removed, I'm leaving those functions in for now


// copied from DeviceListViewModel
private fun setDeviceBrightness(device: Device, brightness: Int) {
val deviceSetPost = JsonPost(brightness = brightness)
scope.launch(Dispatchers.IO) {
stateFactory.getState(device).requestsManager.addRequest(
StateChangeRequest(device, deviceSetPost)
)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method is copied from DeviceListViewModel, which introduces code duplication. This should be refactored by extracting the device action logic into a shared component (e.g., a DeviceActionManager or a UseCase) that can be injected into both the ViewModel and this Service.

Additionally, this function is called from a coroutine that already runs on Dispatchers.IO. The inner scope.launch(Dispatchers.IO) is redundant and creates an unnecessary nested coroutine. You can remove the inner launch.

    private fun setDeviceBrightness(device: Device, brightness: Int) {
        val deviceSetPost = JsonPost(brightness = brightness)
        stateFactory.getState(device).requestsManager.addRequest(
            StateChangeRequest(device, deviceSetPost)
        )
    }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicated scope.launch removed, I'm leaving those functions in for now


override fun onDestroy() {
super.onDestroy()
job.cancel()
}

companion object {
private const val TAG = "DeviceControlsProviderService"

const val EXTRA_DEVICE_MAC = "device_controls_device_mac"
}
}
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,6 @@
<string name="downloading_file">Downloading File</string>
<string name="help">WLED Help</string>
<string name="support_me">Support Moustachauve</string>
<string name="device_controls_device_not_found">Device not found</string>
<string name="device_controls_control_button_action_description">Toggle</string>
</resources>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ roomVersion = "2.7.1"
runtimeLivedata = "1.8.0"
semver4j = "3.1.0"
webkit = "1.13.0"
kotlinxCoroutinesJdk9 = "1.10.2"

[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose" }
Expand Down Expand Up @@ -93,6 +94,7 @@ protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit2-kotlin-coroutines-adapter = { module = "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter", version.ref = "retrofit2KotlinCoroutinesAdapter" }
semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j" }
kotlinx-coroutines-jdk9 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-jdk9", version.ref = "kotlinxCoroutinesJdk9" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Expand Down