chore(ble): record bond loss events to decide how to handle them (#7462)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 23:47:48 +00:00
1 parent 461e215a2d
commit 43b64f2ebe
5 files changed
+614 -5

No files matched your search

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.bluetooth.BluetoothDevice
import co.touchlab.kermit.Severity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class BondEventLogTest {
private val mac = "EF:E2:0A:BE:95:6A"
@Test
fun `key missing is a warning with the loss reason and only the address tail`() {
val line = BondEvent.KeyMissing(mac, lossReason = 3).toLogLine("36.1")
assertEquals(Severity.Warn, line.severity)
assertEquals("BLE bond event key-missing sdk=36.1 lossReason=3 device=...:6A", line.message)
assertFalse(mac in line.message)
}
@Test
fun `key missing without a loss reason leaves the field out`() {
val line = BondEvent.KeyMissing(mac, lossReason = null).toLogLine("36.0")
assertEquals("BLE bond event key-missing sdk=36.0 device=...:6A", line.message)
}
@Test
fun `losing an established bond is a warning`() {
val line = bondStateLine(previous = BluetoothDevice.BOND_BONDED, current = BluetoothDevice.BOND_NONE)
assertEquals(Severity.Warn, line.severity)
assertEquals("BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A", line.message)
}
@Test
fun `a pairing attempt that ends unbonded is a warning`() {
val line = bondStateLine(previous = BluetoothDevice.BOND_BONDING, current = BluetoothDevice.BOND_NONE)
assertEquals(Severity.Warn, line.severity)
}
@Test
fun `starting and completing a bond are info`() {
val started = bondStateLine(previous = BluetoothDevice.BOND_NONE, current = BluetoothDevice.BOND_BONDING)
val completed = bondStateLine(previous = BluetoothDevice.BOND_BONDING, current = BluetoothDevice.BOND_BONDED)
assertEquals(Severity.Info, started.severity)
assertEquals("BLE bond event bond-state NONE->BONDING sdk=35 device=...:6A", started.message)
assertEquals(Severity.Info, completed.severity)
}
@Test
fun `a bond state outside the known three is logged as its number`() {
val line = bondStateLine(previous = BluetoothDevice.ERROR, current = BluetoothDevice.BOND_BONDED)
assertEquals("BLE bond event bond-state ${BluetoothDevice.ERROR}->BONDED sdk=35 device=...:6A", line.message)
}
@Test
fun `an encrypted link is info and a failed or dropped encryption is a warning`() {
val encrypted = BondEvent.EncryptionChange(mac, encrypted = true, status = 0).toLogLine("36.0")
val failed = BondEvent.EncryptionChange(mac, encrypted = false, status = 6).toLogLine("36.0")
val errorStatus = BondEvent.EncryptionChange(mac, encrypted = true, status = 6).toLogLine("36.0")
assertEquals(Severity.Info, encrypted.severity)
assertEquals(Severity.Warn, failed.severity)
assertEquals(
"BLE bond event encryption-change sdk=36.0 encrypted=false status=6 device=...:6A",
failed.message,
)
assertEquals(Severity.Warn, errorStatus.severity)
}
@Test
fun `key missing and encryption change are only registered from API 36`() {
assertEquals(listOf(BluetoothDevice.ACTION_BOND_STATE_CHANGED), bondEventActions(sdkInt = 35))
assertEquals(
listOf(
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
BluetoothDevice.ACTION_KEY_MISSING,
BluetoothDevice.ACTION_ENCRYPTION_CHANGE,
),
bondEventActions(sdkInt = 36),
)
}
private fun bondStateLine(previous: Int, current: Int) =
BondEvent.BondStateChanged(mac, previous = previous, current = current).toLogLine("35")
}
@@ -0,0 +1,273 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.Manifest
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.os.Looper
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.testing.CapturingLogWriter
import org.meshtastic.core.testing.RobolectricBleBonding
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Robolectric's API 36 sandbox needs `jdk.internal.access` exported, which the host test JVM does not do, so the API 36
* broadcasts run on API 35 with the receiver told it is on 36. Log lines therefore carry `sdk=35`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BondEventReceiverTest {
private val radio = "EF:E2:0A:BE:95:6A"
private val headphones = "11:22:33:44:55:66"
private val app
get() = RuntimeEnvironment.getApplication()
private val scope = CoroutineScope(Job() + UnconfinedTestDispatcher())
private lateinit var logs: CapturingLogWriter
@Before
fun setUp() {
logs = CapturingLogWriter.install()
}
@After
fun tearDown() {
scope.cancel()
CapturingLogWriter.uninstall()
}
@Test
fun `registers once for all three bond broadcasts as an exported receiver on API 36`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
val receiver = BondEventReceiver(app, scope, sdkInt = 36)
receiver.watch(radio)
receiver.watch(headphones)
val registration = bondReceivers().single()
assertEquals(
setOf(
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
BluetoothDevice.ACTION_KEY_MISSING,
BluetoothDevice.ACTION_ENCRYPTION_CHANGE,
),
registration.intentFilter.actionsIterator().asSequence().toSet(),
)
assertEquals(Context.RECEIVER_EXPORTED, registration.flags and Context.RECEIVER_EXPORTED)
}
@Test
fun `is registered by the time watch returns on a dispatcher that has not run yet`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
val pending = CoroutineScope(Job() + StandardTestDispatcher())
BondEventReceiver(app, pending).watch(radio)
assertEquals(1, bondReceivers().size)
pending.cancel()
}
@Test
fun `registers only for bond state changes below API 36`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope).watch(radio)
assertEquals(
setOf(BluetoothDevice.ACTION_BOND_STATE_CHANGED),
bondReceivers().single().intentFilter.actionsIterator().asSequence().toSet(),
)
}
@Test
fun `waits for BLUETOOTH_CONNECT before registering`() {
shadowOf(app).denyPermissions(Manifest.permission.BLUETOOTH_CONNECT)
val receiver = BondEventReceiver(app, scope)
receiver.watch(radio)
assertTrue(bondReceivers().isEmpty())
RobolectricBleBonding.grantBluetoothConnectPermission()
receiver.watch(radio)
assertEquals(1, bondReceivers().size)
}
@Test
@Config(sdk = [30])
fun `registers without a runtime permission before API 31`() {
BondEventReceiver(app, scope).watch(radio)
assertEquals(1, bondReceivers().size)
}
@Test
fun `unregisters when its scope is cancelled`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope).watch(radio)
scope.cancel()
assertTrue(bondReceivers().isEmpty())
}
@Test
fun `logs key missing for a watched radio without its address`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope, sdkInt = 36).watch(radio)
send(
Intent(BluetoothDevice.ACTION_KEY_MISSING)
.putExtra(BluetoothDevice.EXTRA_DEVICE, device(radio))
.putExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON, BluetoothDevice.BOND_LOSS_REASON_LE_ENCRYPT_FAILURE),
)
assertEquals(
listOf(
"BLE bond event key-missing sdk=35 " +
"lossReason=${BluetoothDevice.BOND_LOSS_REASON_LE_ENCRYPT_FAILURE} device=...:6A",
),
logs.messages(Severity.Warn),
)
logs.assertNotLogged(radio)
}
@Test
fun `logs encryption changes and bond state transitions from their intent extras`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope, sdkInt = 36).watch(radio)
send(
Intent(BluetoothDevice.ACTION_ENCRYPTION_CHANGE)
.putExtra(BluetoothDevice.EXTRA_DEVICE, device(radio))
.putExtra(BluetoothDevice.EXTRA_ENCRYPTION_ENABLED, false)
.putExtra(BluetoothDevice.EXTRA_ENCRYPTION_STATUS, 6),
)
RobolectricBleBonding.sendBondStateChanged(
radio,
newState = BluetoothDevice.BOND_NONE,
previousState = BluetoothDevice.BOND_BONDED,
)
assertEquals(
listOf(
"BLE bond event encryption-change sdk=35 encrypted=false status=6 device=...:6A",
"BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A",
),
logs.messages(Severity.Warn),
)
}
@Test
fun `ignores bond events for devices that are not a radio`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope, connectingAddress = { null }).watch(radio)
RobolectricBleBonding.sendBondStateChanged(
headphones,
newState = BluetoothDevice.BOND_NONE,
previousState = BluetoothDevice.BOND_BONDED,
)
assertTrue(logs.messages().none { it.startsWith("BLE bond event") }, logs.messages().toString())
}
@Test
fun `logs the radio being connected to even when it was not watched`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
BondEventReceiver(app, scope, connectingAddress = { headphones.lowercase() }).watch(radio)
RobolectricBleBonding.sendBondStateChanged(
headphones,
newState = BluetoothDevice.BOND_BONDING,
previousState = BluetoothDevice.BOND_NONE,
)
assertEquals(
listOf("BLE bond event bond-state NONE->BONDING sdk=35 device=...:66"),
logs.messages(Severity.Info),
)
}
@Test
fun `the repository logs bond events for a radio it is asked about`() {
RobolectricBleBonding.grantBluetoothConnectPermission()
val dispatcher = UnconfinedTestDispatcher()
val repository =
AndroidBluetoothRepository(
app,
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher),
startedLifecycle(),
)
repository.isBonded(radio)
RobolectricBleBonding.sendBondStateChanged(
radio,
newState = BluetoothDevice.BOND_NONE,
previousState = BluetoothDevice.BOND_BONDED,
)
assertEquals(
listOf("BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A"),
logs.messages(Severity.Warn).filter { it.startsWith("BLE bond event") },
)
}
private fun startedLifecycle(): Lifecycle {
val owner =
object : LifecycleOwner {
override val lifecycle: LifecycleRegistry =
LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.STARTED }
}
return owner.lifecycle
}
private fun bondReceivers(): List<ShadowApplication.Wrapper> = shadowOf(app).registeredReceivers.filter {
it.intentFilter.hasAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
private fun device(mac: String): BluetoothDevice = RobolectricBleBonding.adapter.getRemoteDevice(mac)
private fun send(intent: Intent) {
app.sendBroadcast(intent)
shadowOf(Looper.getMainLooper()).idle()
}
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@@ -68,6 +69,8 @@ class AndroidBluetoothRepository(
private val deviceCache = mutableMapOf<String, MeshtasticBleDevice>()
private val bondEvents = BondEventReceiver(context, processLifecycle.coroutineScope + dispatchers.default)
init {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
@@ -95,6 +98,7 @@ class AndroidBluetoothRepository(
@SuppressLint("MissingPermission")
override suspend fun bond(device: BleDevice) {
val macAddress = device.address
bondEvents.watch(macAddress)
val remoteDevice =
bluetoothAdapter?.getRemoteDevice(macAddress) ?: throw Exception("Bluetooth adapter unavailable")
@@ -327,10 +331,13 @@ class AndroidBluetoothRepository(
}
@SuppressLint("MissingPermission")
override fun isBonded(address: String): Boolean = try {
bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false
} catch (e: SecurityException) {
Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" }
false
override fun isBonded(address: String): Boolean {
bondEvents.watch(address)
return try {
bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false
} catch (e: SecurityException) {
Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" }
false
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.bluetooth.BluetoothDevice
import android.os.Build
import co.touchlab.kermit.Severity
import org.meshtastic.core.model.util.anonymize
/** HCI Encryption Change status for success, as carried by `EXTRA_ENCRYPTION_STATUS`. */
private const val HCI_SUCCESS = 0
/** A Bluetooth bond broadcast for one device, reduced to the fields worth recording. */
internal sealed interface BondEvent {
val address: String
/** `ACTION_KEY_MISSING`. [lossReason] is `EXTRA_BOND_LOSS_REASON`, which only newer releases send. */
data class KeyMissing(override val address: String, val lossReason: Int?) : BondEvent
/** `ACTION_ENCRYPTION_CHANGE`. [status] is the controller's HCI status. */
data class EncryptionChange(override val address: String, val encrypted: Boolean, val status: Int) : BondEvent
/** `ACTION_BOND_STATE_CHANGED`, as `BluetoothDevice.BOND_*` values. */
data class BondStateChanged(override val address: String, val previous: Int, val current: Int) : BondEvent
}
internal data class BondEventLogLine(val severity: Severity, val message: String)
/** The bond broadcasts worth registering for on [sdkInt]; KEY_MISSING and ENCRYPTION_CHANGE exist from API 36. */
internal fun bondEventActions(sdkInt: Int): List<String> = buildList {
add(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
if (sdkInt >= Build.VERSION_CODES.BAKLAVA) {
add(BluetoothDevice.ACTION_KEY_MISSING)
add(BluetoothDevice.ACTION_ENCRYPTION_CHANGE)
}
}
/**
* Warn when the radio is left without a bond or an encrypted link, Info otherwise. A lost bond is the user's radio, not
* a defect, so it never logs at Error. The device is named only by [anonymize].
*/
internal fun BondEvent.toLogLine(sdk: String): BondEventLogLine {
val device = address.anonymize
return when (this) {
is BondEvent.KeyMissing -> {
val reason = lossReason?.let { " lossReason=$it" }.orEmpty()
BondEventLogLine(Severity.Warn, "BLE bond event key-missing sdk=$sdk$reason device=$device")
}
is BondEvent.EncryptionChange -> {
val severity = if (encrypted && status == HCI_SUCCESS) Severity.Info else Severity.Warn
BondEventLogLine(
severity,
"BLE bond event encryption-change sdk=$sdk encrypted=$encrypted status=$status device=$device",
)
}
is BondEvent.BondStateChanged -> {
val severity = if (current == BluetoothDevice.BOND_NONE) Severity.Warn else Severity.Info
BondEventLogLine(
severity,
"BLE bond event bond-state ${previous.bondStateName()}->${current.bondStateName()} " +
"sdk=$sdk device=$device",
)
}
}
}
private fun Int.bondStateName(): String = when (this) {
BluetoothDevice.BOND_NONE -> "NONE"
BluetoothDevice.BOND_BONDING -> "BONDING"
BluetoothDevice.BOND_BONDED -> "BONDED"
else -> toString()
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.Manifest
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Logs Bluetooth bond broadcasts for the radios the app bonds to or connects to, so field data can show how often bonds
* are lost and what follows. It changes nothing about bonding or reconnection.
*
* The receiver registers on the first [watch] made while BLUETOOTH_CONNECT is granted, and stays registered until
* [scope] is cancelled.
*/
internal class BondEventReceiver(
private val context: Context,
private val scope: CoroutineScope,
private val connectingAddress: () -> String? = { ActiveBleConnection.active?.address },
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
private val watched: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val registered = AtomicBoolean(false)
private val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.toBondEvent() ?: return
if (!isRadio(event.address)) return
val line = event.toLogLine(sdkLabel())
Logger.log(line.severity, Logger.tag, null, line.message)
}
}
/** Marks [address] as a radio whose bond events are logged, registering the receiver if it can now. */
fun watch(address: String) {
watched += address.uppercase()
if (!registered.get() && hasConnectPermission() && registered.compareAndSet(false, true)) {
// Undispatched so the receiver is registered before a bond() that follows can broadcast.
scope.launch(start = CoroutineStart.UNDISPATCHED) { receiveUntilCancelled() }
}
}
private suspend fun receiveUntilCancelled() {
val filter = IntentFilter().apply { bondEventActions(sdkInt).forEach(::addAction) }
// The Bluetooth app sends these under its own uid, which a NOT_EXPORTED receiver refuses. All three are
// protected broadcasts, so exporting admits no other sender.
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED)
try {
awaitCancellation()
} finally {
context.unregisterReceiver(receiver)
}
}
private fun isRadio(address: String): Boolean {
val normalized = address.uppercase()
return normalized in watched || normalized == connectingAddress()?.uppercase()
}
private fun hasConnectPermission(): Boolean = sdkInt < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
}
internal fun Intent.toBondEvent(): BondEvent? {
val address =
IntentCompat.getParcelableExtra(this, BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)?.address
?: return null
return when (action) {
BluetoothDevice.ACTION_BOND_STATE_CHANGED ->
BondEvent.BondStateChanged(
address = address,
previous = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR),
current = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR),
)
BluetoothDevice.ACTION_KEY_MISSING ->
BondEvent.KeyMissing(
address = address,
lossReason =
if (hasExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON)) {
getIntExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON, BluetoothDevice.ERROR)
} else {
null
},
)
BluetoothDevice.ACTION_ENCRYPTION_CHANGE ->
BondEvent.EncryptionChange(
address = address,
encrypted = getBooleanExtra(BluetoothDevice.EXTRA_ENCRYPTION_ENABLED, false),
status = getIntExtra(BluetoothDevice.EXTRA_ENCRYPTION_STATUS, BluetoothDevice.ERROR),
)
else -> null
}
}
/** `36.1` style from API 36, where `SDK_INT_FULL` carries the minor release; the plain API level before it. */
private fun sdkLabel(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val full = Build.VERSION.SDK_INT_FULL
"${Build.getMajorSdkVersion(full)}.${Build.getMinorSdkVersion(full)}"
} else {
Build.VERSION.SDK_INT.toString()
}