Skip to content

Commit 3ae91f7

Browse files
TimoPtrjpelgrom
andauthored
Refactor Improv flow to be more modern and usable in FrontendScreen (#6876)
* Refactor Improv flow to be more modern and usable in FrontendScreen * Fix text align * Fix lint issue after rebase * Fix tests * Apply suggestions from code review Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com> * Adjust test and typo * Update app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/ImprovRepository.kt Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com> * Fix theme issue in ImprovPermission and Sheet --------- Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com>
1 parent 5db8523 commit 3ae91f7

21 files changed

Lines changed: 1404 additions & 779 deletions

app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ data class ExoPlayerResizePayload(
228228
data class ImprovScanMessage(override val id: Int? = null) : IncomingExternalBusMessage
229229

230230
/**
231-
* Message requesting the app to begin Wi-Fi onboarding for the named improv device the user
231+
* Message requesting the app to begin Wi-Fi onboarding for the named Improv device the user
232232
* picked from the discovery list.
233233
*
234234
* The app should onboard [ImprovConfigureDevicePayload.name] and, once the device has been
Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
package io.homeassistant.companion.android.frontend.improv
22

3+
import android.content.Context
34
import android.content.pm.PackageManager
5+
import com.wifi.improv.ImprovManager
6+
import dagger.Binds
47
import dagger.Module
58
import dagger.Provides
69
import dagger.hilt.InstallIn
10+
import dagger.hilt.android.qualifiers.ApplicationContext
711
import dagger.hilt.components.SingletonComponent
12+
import javax.inject.Singleton
813

914
/**
1015
* Hilt bindings for the frontend's Improv (Wi-Fi onboarding for BLE devices) integration.
1116
*/
1217
@Module
1318
@InstallIn(SingletonComponent::class)
14-
object FrontendImprovModule {
19+
abstract class FrontendImprovModule {
1520

16-
@Provides
17-
fun provideBluetoothCapabilities(packageManager: PackageManager): BluetoothCapabilities = BluetoothCapabilities {
18-
packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
21+
@Binds
22+
@Singleton
23+
abstract fun bindImprovRepository(impl: ImprovRepositoryImpl): ImprovRepository
24+
25+
companion object {
26+
@Provides
27+
fun provideBluetoothCapabilities(packageManager: PackageManager): BluetoothCapabilities =
28+
BluetoothCapabilities {
29+
packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
30+
}
31+
32+
@Provides
33+
@Singleton
34+
fun provideImprovManagerFactory(@ApplicationContext context: Context): ImprovManagerFactory =
35+
ImprovManagerFactory { callback -> ImprovManager(context.applicationContext, callback) }
1936
}
2037
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.homeassistant.companion.android.frontend.improv
2+
3+
import com.wifi.improv.ImprovManager
4+
import com.wifi.improv.ImprovManagerCallback
5+
6+
/**
7+
* Creates [ImprovManager] instances on demand for [ImprovRepositoryImpl].
8+
*
9+
* Exists so the repository does not have to hold an Android `Context` — the factory closes over
10+
* the application context at the Hilt provision site, leaving the repository fully unit-testable
11+
* with a mock factory.
12+
*/
13+
fun interface ImprovManagerFactory {
14+
fun create(callback: ImprovManagerCallback): ImprovManager
15+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.homeassistant.companion.android.frontend.improv
2+
3+
import com.wifi.improv.ImprovDevice
4+
import kotlinx.coroutines.flow.Flow
5+
6+
/**
7+
* Repository interface for the [Improv Wi-Fi onboarding protocol](https://www.improv-wifi.com).
8+
*
9+
* Exposes the protocol as two Flow operations, each fully driven by the collector's lifecycle.
10+
*
11+
* Both operations require the Android runtime permissions reported by [requiredPermissions];
12+
* callers are responsible for requesting them before subscribing.
13+
*/
14+
interface ImprovRepository {
15+
16+
/**
17+
* Android runtime permissions required for the BLE scan and the per-device GATT handshake.
18+
* The set varies by platform SDK.
19+
*/
20+
val requiredPermissions: List<String>
21+
22+
/** Whether every entry in [requiredPermissions] are currently granted to the app. */
23+
fun hasPermissions(): Boolean
24+
25+
/**
26+
* Emits the cumulative list of devices discovered by the active BLE scan.
27+
*
28+
* Hot: subscribing starts the scan (or joins one already in flight); the scan tears down
29+
* shortly after the last subscriber detaches.
30+
*/
31+
fun scanDevices(): Flow<List<ImprovDevice>>
32+
33+
/**
34+
* Runs the BLE provisioning handshake against [device]:
35+
*
36+
* 1. Open a GATT connection.
37+
* 2. Wait for the device to report it's authorized — some hardware requires a physical
38+
* button press here.
39+
* 3. Send [ssid] / [password] over the Improv characteristic.
40+
* 4. Forward the device's state machine until it reports it has been provisioned.
41+
*
42+
* Emits a [ProvisioningEvent] for every state transition, error report, and the terminal
43+
* [ProvisioningEvent.Provisioned] carrying the integration `domain` the device advertises
44+
* (e.g. `"esphome"`) — or `null` when none was reported. The flow completes normally after
45+
* that terminal event; cancelling the collector earlier aborts the session.
46+
*
47+
* Credentials are confined to this call's parameters the repository does not retain them.
48+
*/
49+
fun provisionDevice(device: ImprovDevice, ssid: String, password: String): Flow<ProvisioningEvent>
50+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package io.homeassistant.companion.android.frontend.improv
2+
3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.os.Build
6+
import androidx.annotation.VisibleForTesting
7+
import com.wifi.improv.DeviceState
8+
import com.wifi.improv.ErrorState
9+
import com.wifi.improv.ImprovDevice
10+
import com.wifi.improv.ImprovManager
11+
import com.wifi.improv.ImprovManagerCallback
12+
import io.homeassistant.companion.android.common.util.PermissionChecker
13+
import io.homeassistant.companion.android.common.util.SdkVersion
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
import kotlinx.coroutines.CoroutineDispatcher
17+
import kotlinx.coroutines.CoroutineScope
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.NonCancellable
20+
import kotlinx.coroutines.SupervisorJob
21+
import kotlinx.coroutines.channels.awaitClose
22+
import kotlinx.coroutines.flow.Flow
23+
import kotlinx.coroutines.flow.MutableSharedFlow
24+
import kotlinx.coroutines.flow.MutableStateFlow
25+
import kotlinx.coroutines.flow.SharedFlow
26+
import kotlinx.coroutines.flow.SharingStarted
27+
import kotlinx.coroutines.flow.asStateFlow
28+
import kotlinx.coroutines.flow.channelFlow
29+
import kotlinx.coroutines.flow.onCompletion
30+
import kotlinx.coroutines.flow.onStart
31+
import kotlinx.coroutines.flow.shareIn
32+
import kotlinx.coroutines.launch
33+
import kotlinx.coroutines.withContext
34+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
35+
import timber.log.Timber
36+
37+
/**
38+
* Idle window before a refcount-zero `scanDevices()` subscription actually tears down the BLE
39+
* scan. Lets brief subscriber gaps (e.g. configuration changes, recomposition) avoid the
40+
* start/stop churn.
41+
*/
42+
@VisibleForTesting
43+
internal const val SCAN_IDLE_WINDOW_MS: Long = 500L
44+
45+
@Singleton
46+
class ImprovRepositoryImpl @VisibleForTesting constructor(
47+
private val permissionChecker: PermissionChecker,
48+
improvManagerFactory: ImprovManagerFactory,
49+
private val shareInScope: CoroutineScope,
50+
// Injected so tests can pin BLE start/stop hops onto the test scheduler.
51+
private val backgroundDispatcher: CoroutineDispatcher,
52+
) : ImprovRepository,
53+
ImprovManagerCallback {
54+
55+
@Inject
56+
constructor(permissionChecker: PermissionChecker, improvManagerFactory: ImprovManagerFactory) : this(
57+
permissionChecker = permissionChecker,
58+
improvManagerFactory = improvManagerFactory,
59+
shareInScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
60+
backgroundDispatcher = Dispatchers.IO,
61+
)
62+
63+
private val manager: ImprovManager = improvManagerFactory.create(this)
64+
65+
private val devices = MutableStateFlow(emptyList<ImprovDevice>())
66+
private val stateEvents = MutableSharedFlow<DeviceState>(extraBufferCapacity = 16)
67+
private val errorEvents = MutableSharedFlow<ErrorState>(extraBufferCapacity = 16)
68+
69+
/**
70+
* Latest RPC result reported by the library. Read once when [ProvisioningEvent.Provisioned]
71+
* is emitted to extract the integration `domain`. Volatile for cross-thread visibility — the
72+
* callback may run on the library's internal thread.
73+
*/
74+
@Volatile
75+
private var lastRpcResult: List<String> = emptyList()
76+
77+
private val sharedScanFlow: SharedFlow<List<ImprovDevice>> = devices.asStateFlow()
78+
.onStart { startScanInternal() }
79+
.onCompletion { stopScanInternal() }
80+
.shareIn(shareInScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = SCAN_IDLE_WINDOW_MS), replay = 1)
81+
82+
override val requiredPermissions: List<String>
83+
@SuppressLint("InlinedApi")
84+
get() = buildList {
85+
add(Manifest.permission.ACCESS_FINE_LOCATION)
86+
if (SdkVersion.isAtLeast(Build.VERSION_CODES.S)) {
87+
add(Manifest.permission.BLUETOOTH_SCAN)
88+
add(Manifest.permission.BLUETOOTH_CONNECT)
89+
} else {
90+
add(Manifest.permission.BLUETOOTH)
91+
add(Manifest.permission.BLUETOOTH_ADMIN)
92+
}
93+
}
94+
95+
override fun hasPermissions(): Boolean = requiredPermissions.all { permissionChecker.hasPermission(it) }
96+
97+
override fun scanDevices(): Flow<List<ImprovDevice>> = sharedScanFlow
98+
99+
override fun provisionDevice(device: ImprovDevice, ssid: String, password: String): Flow<ProvisioningEvent> =
100+
channelFlow {
101+
var credentialsSent = false
102+
103+
// Forward error events for the duration of the session.
104+
val errorJob = launch {
105+
errorEvents.collect { error ->
106+
if (error != ErrorState.NO_ERROR) {
107+
send(ProvisioningEvent.ErrorOccurred(error))
108+
}
109+
}
110+
}
111+
112+
// Forward state events; drive the AUTHORIZED → sendWifi step; close on PROVISIONED.
113+
val stateJob = launch {
114+
stateEvents.collect { state ->
115+
send(ProvisioningEvent.StateChanged(state))
116+
117+
when (state) {
118+
DeviceState.AUTHORIZED -> if (!credentialsSent) {
119+
try {
120+
manager.sendWifi(ssid, password)
121+
credentialsSent = true
122+
} catch (e: SecurityException) {
123+
Timber.e(e, "Not allowed to send Wi-Fi credentials")
124+
close(e)
125+
}
126+
}
127+
128+
DeviceState.PROVISIONED -> {
129+
val domain = lastRpcResult.firstOrNull()
130+
?.toHttpUrlOrNull()
131+
?.queryParameter("domain")
132+
send(ProvisioningEvent.Provisioned(domain))
133+
close()
134+
}
135+
136+
else -> Unit
137+
}
138+
}
139+
}
140+
141+
try {
142+
manager.connectToDevice(device)
143+
} catch (e: SecurityException) {
144+
Timber.e(e, "Not allowed to connect to device")
145+
close(e)
146+
}
147+
148+
awaitClose {
149+
errorJob.cancel()
150+
stateJob.cancel()
151+
}
152+
}
153+
154+
// region ImprovManagerCallback
155+
156+
override fun onConnectionStateChange(device: ImprovDevice?) {
157+
if (device == null) {
158+
// Disconnect: reset rpc result so a future session starts clean.
159+
lastRpcResult = emptyList()
160+
}
161+
}
162+
163+
override fun onDeviceFound(device: ImprovDevice) {
164+
val current = devices.value
165+
if (!current.contains(device)) {
166+
devices.tryEmit(current + device)
167+
}
168+
}
169+
170+
override fun onErrorStateChange(errorState: ErrorState) {
171+
errorEvents.tryEmit(errorState)
172+
}
173+
174+
override fun onScanningStateChange(scanning: Boolean) {
175+
// Library scanning state is implicit in subscription to scanDevices(); not re-exposed.
176+
}
177+
178+
override fun onStateChange(state: DeviceState) {
179+
stateEvents.tryEmit(state)
180+
if (state == DeviceState.PROVISIONED) {
181+
// Clear the device list so the next scan starts fresh.
182+
devices.tryEmit(emptyList())
183+
}
184+
}
185+
186+
override fun onRpcResult(result: List<String>) {
187+
lastRpcResult = result
188+
}
189+
190+
// endregion
191+
192+
private suspend fun startScanInternal() = withContext(backgroundDispatcher) {
193+
if (!hasPermissions()) return@withContext
194+
try {
195+
manager.findDevices()
196+
} catch (e: SecurityException) {
197+
Timber.e(e, "Not allowed to start scanning")
198+
} catch (e: Exception) {
199+
Timber.w(e, "Unexpectedly cannot start scanning")
200+
}
201+
}
202+
203+
// [NonCancellable] is mandatory: `stopScanInternal()` is invoked from the upstream's
204+
// `onCompletion` after shareIn's WhileSubscribed timeout cancels it, so the caller's job is
205+
// already cancelled. Without [NonCancellable], `withContext` would short-circuit on
206+
// `ensureActive` and the BLE scan would never be torn down.
207+
private suspend fun stopScanInternal() = withContext(NonCancellable + backgroundDispatcher) {
208+
try {
209+
manager.stopScan()
210+
} catch (e: Exception) {
211+
Timber.w(e, "Cannot stop scanning")
212+
}
213+
}
214+
}

0 commit comments

Comments
 (0)