Skip to content

Commit 1fab22d

Browse files
committed
Refactor Improv flow to be more modern and usable in FrontendScreen
1 parent d21c076 commit 1fab22d

19 files changed

Lines changed: 1368 additions & 754 deletions
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 façade 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: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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 javax.inject.Inject
14+
import javax.inject.Singleton
15+
import kotlinx.coroutines.CoroutineDispatcher
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.NonCancellable
19+
import kotlinx.coroutines.SupervisorJob
20+
import kotlinx.coroutines.channels.awaitClose
21+
import kotlinx.coroutines.flow.Flow
22+
import kotlinx.coroutines.flow.MutableSharedFlow
23+
import kotlinx.coroutines.flow.MutableStateFlow
24+
import kotlinx.coroutines.flow.SharedFlow
25+
import kotlinx.coroutines.flow.SharingStarted
26+
import kotlinx.coroutines.flow.asStateFlow
27+
import kotlinx.coroutines.flow.channelFlow
28+
import kotlinx.coroutines.flow.onCompletion
29+
import kotlinx.coroutines.flow.onStart
30+
import kotlinx.coroutines.flow.shareIn
31+
import kotlinx.coroutines.launch
32+
import kotlinx.coroutines.withContext
33+
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
34+
import timber.log.Timber
35+
36+
/**
37+
* Idle window before a refcount-zero `scanDevices()` subscription actually tears down the BLE
38+
* scan. Lets brief subscriber gaps (e.g. configuration changes, recomposition) avoid the
39+
* start/stop churn.
40+
*/
41+
@VisibleForTesting
42+
internal const val SCAN_IDLE_WINDOW_MS: Long = 500L
43+
44+
@Singleton
45+
class ImprovRepositoryImpl @VisibleForTesting constructor(
46+
private val permissionChecker: PermissionChecker,
47+
improvManagerFactory: ImprovManagerFactory,
48+
// Test-only override of [Build.VERSION.SDK_INT] so the SDK-branched [requiredPermissions] can
49+
// be exercised without Robolectric.
50+
private val sdkInt: Int,
51+
private val shareInScope: CoroutineScope,
52+
// Injected so tests can pin BLE start/stop hops onto the test scheduler.
53+
private val backgroundDispatcher: CoroutineDispatcher,
54+
) : ImprovRepository,
55+
ImprovManagerCallback {
56+
57+
@Inject
58+
constructor(permissionChecker: PermissionChecker, improvManagerFactory: ImprovManagerFactory) : this(
59+
permissionChecker = permissionChecker,
60+
improvManagerFactory = improvManagerFactory,
61+
sdkInt = Build.VERSION.SDK_INT,
62+
shareInScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
63+
backgroundDispatcher = Dispatchers.IO,
64+
)
65+
66+
private val manager: ImprovManager = improvManagerFactory.create(this)
67+
68+
private val devices = MutableStateFlow(emptyList<ImprovDevice>())
69+
private val stateEvents = MutableSharedFlow<DeviceState>(extraBufferCapacity = 16)
70+
private val errorEvents = MutableSharedFlow<ErrorState>(extraBufferCapacity = 16)
71+
72+
/**
73+
* Latest RPC result reported by the library. Read once when [ProvisioningEvent.Provisioned]
74+
* is emitted to extract the integration `domain`. Volatile for cross-thread visibility — the
75+
* callback may run on the library's internal thread.
76+
*/
77+
@Volatile
78+
private var lastRpcResult: List<String> = emptyList()
79+
80+
private val sharedScanFlow: SharedFlow<List<ImprovDevice>> = devices.asStateFlow()
81+
.onStart { startScanInternal() }
82+
.onCompletion { stopScanInternal() }
83+
.shareIn(shareInScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = SCAN_IDLE_WINDOW_MS), replay = 1)
84+
85+
override val requiredPermissions: List<String>
86+
@SuppressLint("InlinedApi")
87+
get() = buildList {
88+
add(Manifest.permission.ACCESS_FINE_LOCATION)
89+
if (sdkInt >= Build.VERSION_CODES.S) {
90+
add(Manifest.permission.BLUETOOTH_SCAN)
91+
add(Manifest.permission.BLUETOOTH_CONNECT)
92+
} else {
93+
add(Manifest.permission.BLUETOOTH)
94+
add(Manifest.permission.BLUETOOTH_ADMIN)
95+
}
96+
}
97+
98+
override fun hasPermissions(): Boolean = requiredPermissions.all { permissionChecker.hasPermission(it) }
99+
100+
override fun scanDevices(): Flow<List<ImprovDevice>> = sharedScanFlow
101+
102+
override fun provisionDevice(device: ImprovDevice, ssid: String, password: String): Flow<ProvisioningEvent> =
103+
channelFlow {
104+
var credentialsSent = false
105+
106+
// Forward error events for the duration of the session.
107+
val errorJob = launch {
108+
errorEvents.collect { error ->
109+
if (error != ErrorState.NO_ERROR) {
110+
send(ProvisioningEvent.ErrorOccurred(error))
111+
}
112+
}
113+
}
114+
115+
// Forward state events; drive the AUTHORIZED → sendWifi step; close on PROVISIONED.
116+
val stateJob = launch {
117+
stateEvents.collect { state ->
118+
send(ProvisioningEvent.StateChanged(state))
119+
120+
when (state) {
121+
DeviceState.AUTHORIZED -> if (!credentialsSent) {
122+
try {
123+
manager.sendWifi(ssid, password)
124+
credentialsSent = true
125+
} catch (e: SecurityException) {
126+
Timber.e(e, "Not allowed to send Wi-Fi credentials")
127+
close(e)
128+
}
129+
}
130+
131+
DeviceState.PROVISIONED -> {
132+
val domain = lastRpcResult.firstOrNull()
133+
?.toHttpUrlOrNull()
134+
?.queryParameter("domain")
135+
send(ProvisioningEvent.Provisioned(domain))
136+
close()
137+
}
138+
139+
else -> Unit
140+
}
141+
}
142+
}
143+
144+
try {
145+
manager.connectToDevice(device)
146+
} catch (e: SecurityException) {
147+
Timber.e(e, "Not allowed to connect to device")
148+
close(e)
149+
}
150+
151+
awaitClose {
152+
errorJob.cancel()
153+
stateJob.cancel()
154+
}
155+
}
156+
157+
// region ImprovManagerCallback
158+
159+
override fun onConnectionStateChange(device: ImprovDevice?) {
160+
if (device == null) {
161+
// Disconnect: reset rpc result so a future session starts clean.
162+
lastRpcResult = emptyList()
163+
}
164+
}
165+
166+
override fun onDeviceFound(device: ImprovDevice) {
167+
val current = devices.value
168+
if (!current.contains(device)) {
169+
devices.tryEmit(current + device)
170+
}
171+
}
172+
173+
override fun onErrorStateChange(errorState: ErrorState) {
174+
errorEvents.tryEmit(errorState)
175+
}
176+
177+
override fun onScanningStateChange(scanning: Boolean) {
178+
// Library scanning state is implicit in subscription to scanDevices(); not re-exposed.
179+
}
180+
181+
override fun onStateChange(state: DeviceState) {
182+
stateEvents.tryEmit(state)
183+
if (state == DeviceState.PROVISIONED) {
184+
// Clear the device list so the next scan starts fresh.
185+
devices.tryEmit(emptyList())
186+
}
187+
}
188+
189+
override fun onRpcResult(result: List<String>) {
190+
lastRpcResult = result
191+
}
192+
193+
// endregion
194+
195+
private suspend fun startScanInternal() = withContext(backgroundDispatcher) {
196+
if (!hasPermissions()) return@withContext
197+
try {
198+
manager.findDevices()
199+
} catch (e: SecurityException) {
200+
Timber.e(e, "Not allowed to start scanning")
201+
} catch (e: Exception) {
202+
Timber.w(e, "Unexpectedly cannot start scanning")
203+
}
204+
}
205+
206+
// [NonCancellable] is mandatory: `stopScanInternal()` is invoked from the upstream's
207+
// `onCompletion` after shareIn's WhileSubscribed timeout cancels it, so the caller's job is
208+
// already cancelled. Without [NonCancellable], `withContext` would short-circuit on
209+
// `ensureActive` and the BLE scan would never be torn down.
210+
private suspend fun stopScanInternal() = withContext(NonCancellable + backgroundDispatcher) {
211+
try {
212+
manager.stopScan()
213+
} catch (e: Exception) {
214+
Timber.w(e, "Cannot stop scanning")
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)