mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
617 lines
25 KiB
Kotlin
617 lines
25 KiB
Kotlin
/*
|
|
* Copyright (c) 2025 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 com.geeksville.mesh.ui
|
|
|
|
import android.app.Activity
|
|
import android.content.Context
|
|
import android.content.ContextWrapper
|
|
import android.net.InetAddresses
|
|
import android.os.Build
|
|
import android.util.Patterns
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.selection.selectable
|
|
import androidx.compose.foundation.selection.selectableGroup
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.AlertDialog
|
|
import androidx.compose.material.Button
|
|
import androidx.compose.material.Checkbox
|
|
import androidx.compose.material.FloatingActionButton
|
|
import androidx.compose.material.Icon
|
|
import androidx.compose.material.LinearProgressIndicator
|
|
import androidx.compose.material.MaterialTheme
|
|
import androidx.compose.material.OutlinedTextField
|
|
import androidx.compose.material.RadioButton
|
|
import androidx.compose.material.Surface
|
|
import androidx.compose.material.Text
|
|
import androidx.compose.material.TextButton
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.livedata.observeAsState
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.input.KeyboardType
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.geeksville.mesh.ConfigProtos
|
|
import com.geeksville.mesh.R
|
|
import com.geeksville.mesh.android.BuildUtils.debug
|
|
import com.geeksville.mesh.android.BuildUtils.info
|
|
import com.geeksville.mesh.android.BuildUtils.reportError
|
|
import com.geeksville.mesh.android.BuildUtils.warn
|
|
import com.geeksville.mesh.android.GeeksvilleApplication
|
|
import com.geeksville.mesh.android.getBluetoothPermissions
|
|
import com.geeksville.mesh.android.getLocationPermissions
|
|
import com.geeksville.mesh.android.gpsDisabled
|
|
import com.geeksville.mesh.android.hasLocationPermission
|
|
import com.geeksville.mesh.android.isGooglePlayAvailable
|
|
import com.geeksville.mesh.android.permissionMissing
|
|
import com.geeksville.mesh.model.BTScanModel
|
|
import com.geeksville.mesh.model.BluetoothViewModel
|
|
import com.geeksville.mesh.model.UIViewModel
|
|
import com.geeksville.mesh.service.MeshService
|
|
import kotlinx.coroutines.delay
|
|
|
|
fun String?.isIPAddress(): Boolean {
|
|
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
@Suppress("DEPRECATION")
|
|
this != null && Patterns.IP_ADDRESS.matcher(this).matches()
|
|
} else {
|
|
InetAddresses.isNumericAddress(this.toString())
|
|
}
|
|
}
|
|
|
|
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
|
@Composable
|
|
fun SettingsScreen(
|
|
uiViewModel: UIViewModel = hiltViewModel(),
|
|
scanModel: BTScanModel = hiltViewModel(),
|
|
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
|
|
onSetRegion: () -> Unit,
|
|
) {
|
|
val currentRegion = uiViewModel.region
|
|
val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
|
|
val scrollState = rememberScrollState()
|
|
val scanStatusText by scanModel.errorText.observeAsState("")
|
|
val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED)
|
|
val devices by scanModel.devices.observeAsState(emptyMap())
|
|
val scanning by scanModel.spinner.observeAsState(false)
|
|
val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false)
|
|
val context = LocalContext.current
|
|
val app = (context.applicationContext as GeeksvilleApplication)
|
|
val isGooglePlayAvailable = context.isGooglePlayAvailable()
|
|
val info by uiViewModel.myNodeInfo.collectAsState()
|
|
|
|
val isAnalyticsAllowed = app.isAnalyticsAllowed
|
|
val selectedDevice = scanModel.selectedNotNull
|
|
val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState()
|
|
|
|
val isGpsDisabled = context.gpsDisabled()
|
|
LaunchedEffect(isGpsDisabled) {
|
|
if (isGpsDisabled) {
|
|
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
|
|
}
|
|
}
|
|
LaunchedEffect(bluetoothEnabled) {
|
|
if (bluetoothEnabled == false) {
|
|
uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled))
|
|
}
|
|
}
|
|
// when scanning is true - wait 10000ms and then stop scanning
|
|
LaunchedEffect(scanning) {
|
|
if (scanning) {
|
|
delay(SCAN_PERIOD)
|
|
scanModel.stopScan()
|
|
}
|
|
}
|
|
|
|
// State for manual IP address input
|
|
var manualIpAddress by remember { mutableStateOf("") }
|
|
|
|
// State for the device scan dialog
|
|
var showScanDialog by remember { mutableStateOf(false) }
|
|
val scanResults by scanModel.scanResult.observeAsState(emptyMap())
|
|
|
|
// State for the location permission rationale dialog
|
|
var showLocationRationaleDialog by remember { mutableStateOf(false) }
|
|
|
|
// State for the Bluetooth permission rationale dialog
|
|
var showBluetoothRationaleDialog by remember { mutableStateOf(false) }
|
|
|
|
// State for the Report Bug dialog
|
|
var showReportBugDialog by remember { mutableStateOf(false) }
|
|
|
|
// Remember the permission launchers
|
|
val requestLocationPermissionLauncher = rememberLauncherForActivityResult(
|
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
|
onResult = { permissions ->
|
|
if (permissions.entries.all { it.value }) {
|
|
uiViewModel.provideLocation.value = true
|
|
uiViewModel.meshService?.startProvideLocation()
|
|
} else {
|
|
debug("User denied location permission")
|
|
uiViewModel.showSnackbar(context.getString(R.string.why_background_required))
|
|
}
|
|
bluetoothViewModel.permissionsUpdated()
|
|
}
|
|
)
|
|
|
|
val requestBluetoothPermissionLauncher = rememberLauncherForActivityResult(
|
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
|
onResult = { permissions ->
|
|
if (permissions.entries.all { it.value }) {
|
|
info("Bluetooth permissions granted")
|
|
// We need to call the scan function which is in the Fragment
|
|
// Since we can't directly call scanLeDevice() from Composable,
|
|
// we might need to rethink how scanning is triggered or
|
|
// pass the scan trigger as a lambda.
|
|
// For now, let's assume we trigger the scan outside the Composable
|
|
// after permissions are granted. We can add a callback to the ViewModel.
|
|
scanModel.startScan()
|
|
} else {
|
|
warn("Bluetooth permissions denied")
|
|
uiViewModel.showSnackbar(context.permissionMissing)
|
|
}
|
|
bluetoothViewModel.permissionsUpdated()
|
|
}
|
|
)
|
|
|
|
// Observe scan results to show the dialog
|
|
if (scanResults.isNotEmpty()) {
|
|
showScanDialog = true
|
|
}
|
|
|
|
LaunchedEffect(connectionState, regionUnset) {
|
|
when (connectionState) {
|
|
MeshService.ConnectionState.CONNECTED ->
|
|
// Include region unset warning in status string if applicable
|
|
if (regionUnset) R.string.must_set_region else R.string.connected_to
|
|
|
|
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
|
|
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
|
|
else -> null
|
|
}.let {
|
|
val firmwareString =
|
|
info?.firmwareString ?: context.getString(R.string.unknown)
|
|
if (it != null) {
|
|
scanModel.setErrorText(context.getString(it, firmwareString))
|
|
}
|
|
}
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(16.dp)
|
|
.verticalScroll(scrollState)
|
|
) {
|
|
// Scan Status Text
|
|
Text(
|
|
text = scanStatusText.orEmpty(),
|
|
fontSize = 14.sp,
|
|
textAlign = TextAlign.Start,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
|
|
// Set Region Button
|
|
val isConnected = connectionState == MeshService.ConnectionState.CONNECTED
|
|
if (isConnected && regionUnset) {
|
|
Button(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
onClick = {
|
|
onSetRegion()
|
|
}
|
|
) {
|
|
Text(stringResource(R.string.set_region))
|
|
}
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
}
|
|
|
|
// Device List and Manual Input
|
|
Text(
|
|
text = stringResource(R.string.device),
|
|
style = MaterialTheme.typography.h6,
|
|
modifier = Modifier.padding(vertical = 8.dp)
|
|
)
|
|
|
|
// Progress bar while scanning
|
|
if (scanning) {
|
|
LinearProgressIndicator(
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
|
|
Column(modifier = Modifier.selectableGroup()) {
|
|
devices.values.forEach { device ->
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.selectable(
|
|
selected = (device.fullAddress == selectedDevice),
|
|
onClick = {
|
|
if (device.fullAddress == "n") {
|
|
uiViewModel.showSnackbar("Demo Mode enabled")
|
|
scanModel.showMockInterface()
|
|
}
|
|
if (!device.bonded) {
|
|
uiViewModel.showSnackbar(context.getString(R.string.starting_pairing))
|
|
}
|
|
scanModel.onSelected(device)
|
|
}
|
|
)
|
|
.padding(horizontal = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
RadioButton(
|
|
selected = (device.fullAddress == selectedDevice),
|
|
onClick = {
|
|
if (device.fullAddress == "n") {
|
|
uiViewModel.showSnackbar("Demo Mode enabled")
|
|
scanModel.showMockInterface()
|
|
}
|
|
if (!device.bonded) {
|
|
uiViewModel.showSnackbar(context.getString(R.string.starting_pairing))
|
|
}
|
|
scanModel.onSelected(device)
|
|
}
|
|
)
|
|
Text(
|
|
text = device.name,
|
|
style = MaterialTheme.typography.body1,
|
|
modifier = Modifier.padding(start = 16.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Manual IP Address Input
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.selectable(
|
|
selected = ("t$manualIpAddress" == selectedDevice),
|
|
onClick = {
|
|
if (manualIpAddress.isIPAddress()) {
|
|
scanModel.onSelected(
|
|
BTScanModel.DeviceListEntry(
|
|
"",
|
|
"t$manualIpAddress",
|
|
true
|
|
)
|
|
)
|
|
} else {
|
|
// Optionally show a warning for invalid IP
|
|
}
|
|
}
|
|
)
|
|
.padding(horizontal = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
RadioButton(
|
|
selected = ("t$manualIpAddress" == selectedDevice),
|
|
onClick = {
|
|
if (manualIpAddress.isIPAddress()) {
|
|
scanModel.onSelected(
|
|
BTScanModel.DeviceListEntry(
|
|
"",
|
|
"t$manualIpAddress",
|
|
true
|
|
)
|
|
)
|
|
} else {
|
|
// Optionally show a warning for invalid IP
|
|
}
|
|
}
|
|
)
|
|
OutlinedTextField(
|
|
value = manualIpAddress,
|
|
onValueChange = { manualIpAddress = it },
|
|
label = { Text(stringResource(R.string.ip_address)) },
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.padding(start = 16.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
// Provide Location Checkbox
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable(
|
|
enabled = !isGpsDisabled
|
|
) {
|
|
val isChecked = !receivingLocationUpdates // Toggle the state
|
|
uiViewModel.provideLocation.value = isChecked
|
|
if (isChecked && !context.hasLocationPermission()) {
|
|
showLocationRationaleDialog = true // Show the Compose dialog
|
|
}
|
|
if (isChecked) {
|
|
uiViewModel.meshService?.startProvideLocation()
|
|
} else {
|
|
uiViewModel.meshService?.stopProvideLocation()
|
|
}
|
|
}
|
|
.padding(horizontal = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Checkbox(
|
|
checked = receivingLocationUpdates,
|
|
onCheckedChange = { isChecked ->
|
|
uiViewModel.provideLocation.value = isChecked
|
|
if (isChecked && !context.hasLocationPermission()) {
|
|
showLocationRationaleDialog = true
|
|
}
|
|
if (isChecked) {
|
|
uiViewModel.meshService?.startProvideLocation()
|
|
} else {
|
|
uiViewModel.meshService?.stopProvideLocation()
|
|
}
|
|
},
|
|
enabled = !isGpsDisabled // Disable if GPS is disabled
|
|
)
|
|
Text(
|
|
text = stringResource(R.string.provide_location_to_mesh),
|
|
style = MaterialTheme.typography.body1,
|
|
modifier = Modifier.padding(start = 16.dp)
|
|
)
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
// Warning Not Paired
|
|
val showWarningNotPaired = !devices.any { it.value.bonded }
|
|
if (showWarningNotPaired) {
|
|
Text(
|
|
text = stringResource(R.string.warning_not_paired),
|
|
color = MaterialTheme.colors.error,
|
|
style = MaterialTheme.typography.body2,
|
|
modifier = Modifier.padding(horizontal = 16.dp)
|
|
)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
}
|
|
|
|
if (isAnalyticsAllowed) {
|
|
// Analytics Okay Checkbox
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable(
|
|
enabled = isGooglePlayAvailable,
|
|
) {
|
|
val app = (context.applicationContext as GeeksvilleApplication)
|
|
app.isAnalyticsAllowed = !app.isAnalyticsAllowed // Toggle the MutableState
|
|
}
|
|
.padding(horizontal = 16.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Checkbox(
|
|
checked = isAnalyticsAllowed,
|
|
onCheckedChange = { isChecked ->
|
|
debug("User changed analytics to $isChecked")
|
|
(context.applicationContext as GeeksvilleApplication).isAnalyticsAllowed =
|
|
isChecked
|
|
},
|
|
enabled = isGooglePlayAvailable
|
|
)
|
|
Text(
|
|
text = stringResource(R.string.analytics_okay),
|
|
style = MaterialTheme.typography.body1,
|
|
modifier = Modifier.padding(start = 16.dp)
|
|
)
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
// Report Bug Button
|
|
Button(
|
|
onClick = { showReportBugDialog = true }, // Set state to show Report Bug dialog
|
|
enabled = isAnalyticsAllowed,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 16.dp)
|
|
) {
|
|
Text(stringResource(R.string.report_bug))
|
|
}
|
|
}
|
|
// Floating Action Button (Change Radio)
|
|
Box(modifier = Modifier.fillMaxSize()) {
|
|
FloatingActionButton(
|
|
onClick = {
|
|
val bluetoothPermissions = context.getBluetoothPermissions()
|
|
if (bluetoothPermissions.isEmpty()) {
|
|
// If no permissions needed, trigger the scan directly (or via ViewModel)
|
|
scanModel.startScan()
|
|
} else {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
|
context.findActivity()
|
|
.shouldShowRequestPermissionRationale(bluetoothPermissions.first())
|
|
) {
|
|
showBluetoothRationaleDialog = true
|
|
} else {
|
|
requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
|
|
}
|
|
}
|
|
},
|
|
modifier = Modifier
|
|
.align(Alignment.BottomEnd)
|
|
.padding(16.dp)
|
|
) {
|
|
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.change_radio))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compose Device Scan Dialog
|
|
if (showScanDialog) {
|
|
Dialog(onDismissRequest = {
|
|
showScanDialog = false
|
|
scanModel.clearScanResults()
|
|
}) {
|
|
Surface(shape = MaterialTheme.shapes.medium) {
|
|
Column(modifier = Modifier.padding(16.dp)) {
|
|
Text(
|
|
text = "Select a Bluetooth device",
|
|
style = MaterialTheme.typography.h6,
|
|
modifier = Modifier.padding(bottom = 16.dp)
|
|
)
|
|
Column(modifier = Modifier.selectableGroup()) {
|
|
scanResults.values.forEach { device ->
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.selectable(
|
|
selected = false, // No pre-selection in this dialog
|
|
onClick = {
|
|
scanModel.onSelected(device)
|
|
scanModel.clearScanResults()
|
|
showScanDialog = false
|
|
}
|
|
)
|
|
.padding(vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(text = device.name)
|
|
}
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
TextButton(onClick = {
|
|
scanModel.clearScanResults()
|
|
showScanDialog = false
|
|
}) {
|
|
Text(stringResource(R.string.cancel))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compose Location Permission Rationale Dialog
|
|
if (showLocationRationaleDialog) {
|
|
AlertDialog(
|
|
onDismissRequest = { showLocationRationaleDialog = false },
|
|
title = { Text(stringResource(R.string.background_required)) },
|
|
text = { Text(stringResource(R.string.why_background_required)) },
|
|
confirmButton = {
|
|
Button(onClick = {
|
|
showLocationRationaleDialog = false
|
|
if (!context.hasLocationPermission()) {
|
|
requestLocationPermissionLauncher.launch(context.getLocationPermissions())
|
|
}
|
|
}) {
|
|
Text(stringResource(R.string.accept))
|
|
}
|
|
},
|
|
dismissButton = {
|
|
Button(onClick = { showLocationRationaleDialog = false }) {
|
|
Text(stringResource(R.string.cancel))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// Compose Bluetooth Permission Rationale Dialog
|
|
if (showBluetoothRationaleDialog) {
|
|
val bluetoothPermissions = context.getBluetoothPermissions()
|
|
AlertDialog(
|
|
onDismissRequest = { showBluetoothRationaleDialog = false },
|
|
title = { Text(stringResource(R.string.required_permissions)) },
|
|
text = { Text(stringResource(R.string.permission_missing_31)) },
|
|
confirmButton = {
|
|
Button(onClick = {
|
|
showBluetoothRationaleDialog = false
|
|
if (bluetoothPermissions.isNotEmpty()) {
|
|
requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
|
|
} else {
|
|
// If somehow no permissions are required, just scan
|
|
scanModel.startScan()
|
|
}
|
|
}) {
|
|
Text(stringResource(R.string.okay))
|
|
}
|
|
},
|
|
dismissButton = {
|
|
Button(onClick = { showBluetoothRationaleDialog = false }) {
|
|
Text(stringResource(R.string.cancel))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// Compose Report Bug Dialog
|
|
if (showReportBugDialog) {
|
|
AlertDialog(
|
|
onDismissRequest = { showReportBugDialog = false },
|
|
title = { Text(stringResource(R.string.report_a_bug)) },
|
|
text = { Text(stringResource(R.string.report_bug_text)) },
|
|
confirmButton = {
|
|
Button(onClick = {
|
|
showReportBugDialog = false
|
|
reportError("Clicked Report A Bug")
|
|
uiViewModel.showSnackbar("Bug report sent!")
|
|
}) {
|
|
Text(stringResource(R.string.report))
|
|
}
|
|
},
|
|
dismissButton = {
|
|
Button(onClick = {
|
|
showReportBugDialog = false
|
|
debug("Decided not to report a bug")
|
|
}) {
|
|
Text(stringResource(R.string.cancel))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private tailrec fun Context.findActivity(): Activity = when (this) {
|
|
is Activity -> this
|
|
is ContextWrapper -> baseContext.findActivity()
|
|
else -> error("No activity found")
|
|
}
|
|
|
|
private const val SCAN_PERIOD: Long = 10000 // 10 seconds
|