Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
48 changes: 39 additions & 9 deletions app/src/main/java/com/networkscanner/app/ui/SettingsViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package com.networkscanner.app.ui

import android.app.Application
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import com.networkscanner.app.R
import com.networkscanner.app.theme.ThemeManager
import com.networkscanner.app.util.PrivilegedNeighborSource
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -62,6 +60,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application

private val _neighborSource = MutableStateFlow(PrivilegedNeighborSource.mode(application))
val neighborSource: StateFlow<PrivilegedNeighborSource.Mode> = _neighborSource.asStateFlow()
private val _neighborAvailability =
MutableStateFlow<Map<PrivilegedNeighborSource.Mode, PrivilegedNeighborSource.Availability>>(emptyMap())
val neighborAvailability: StateFlow<Map<PrivilegedNeighborSource.Mode, PrivilegedNeighborSource.Availability>> =
_neighborAvailability.asStateFlow()
private val _neighborProbe = MutableStateFlow<NeighborProbe>(NeighborProbe.Idle)
val neighborProbe: StateFlow<NeighborProbe> = _neighborProbe.asStateFlow()
private var probeJob: Job? = null

fun setThemeMode(mode: ThemeManager.ThemeMode) {
Expand All @@ -82,23 +86,49 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
applyLanguage(languageCode)
}

/** Clears a stale failure and re-reads each backend's status, without prompting. */
fun onNeighborPickerOpened() {
if (_neighborProbe.value is NeighborProbe.Failed) _neighborProbe.value = NeighborProbe.Idle
refreshNeighborAvailability()
}

private fun refreshNeighborAvailability() {
viewModelScope.launch {
_neighborAvailability.value = PrivilegedNeighborSource.Mode.entries.associateWith {
PrivilegedNeighborSource.availability(it)
}
}
}

/** Persists [mode] only once its backend has granted access. */
fun setNeighborSource(mode: PrivilegedNeighborSource.Mode) {
probeJob?.cancel()
_neighborProbe.value = NeighborProbe.Running(mode)
probeJob = viewModelScope.launch {
if (!PrivilegedNeighborSource.probe(mode)) {
val message = if (mode == PrivilegedNeighborSource.Mode.ROOT) {
R.string.neighbor_source_root_unavailable
} else {
R.string.neighbor_source_shizuku_unavailable
}
Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show()
val granted = PrivilegedNeighborSource.probe(mode)
refreshNeighborAvailability()
if (!granted) {
_neighborProbe.value = NeighborProbe.Failed(mode)
return@launch
}
prefs.edit { putString(PrivilegedNeighborSource.PREF_KEY, mode.name) }
_neighborSource.value = mode
_neighborProbe.value = NeighborProbe.Idle
}
}

/** Abandons a pending access check, e.g. when the user keeps the current source instead. */
fun cancelNeighborProbe() {
probeJob?.cancel()
_neighborProbe.value = NeighborProbe.Idle
}

fun supportsDynamicColors(): Boolean = ThemeManager.supportsDynamicColors()
}

/** Progress of the access check run when a MAC address source is chosen. */
sealed interface NeighborProbe {
data object Idle : NeighborProbe
data class Running(val mode: PrivilegedNeighborSource.Mode) : NeighborProbe
data class Failed(val mode: PrivilegedNeighborSource.Mode) : NeighborProbe
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package com.networkscanner.app.ui.screens.settings

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.networkscanner.app.R
import com.networkscanner.app.ui.NeighborProbe
import com.networkscanner.app.util.PrivilegedNeighborSource.Availability
import com.networkscanner.app.util.PrivilegedNeighborSource.Mode

/** Display label for a MAC address source, used for the sheet options and the row value badge. */
@Composable
fun neighborSourceLabel(mode: Mode): String = when (mode) {
Mode.NONE -> stringResource(R.string.neighbor_source_none)
Mode.SHIZUKU -> stringResource(R.string.neighbor_source_shizuku)
Mode.ROOT -> stringResource(R.string.neighbor_source_root)
}

/** One-line status under an option, or null when there is nothing worth saying. */
@Composable
private fun neighborSourceStatus(mode: Mode, availability: Availability?, probe: NeighborProbe): String? = when {
probe is NeighborProbe.Running && probe.mode == mode ->
stringResource(R.string.neighbor_status_waiting)
probe is NeighborProbe.Failed && probe.mode == mode ->
stringResource(
if (mode == Mode.ROOT) R.string.neighbor_source_root_unavailable
else R.string.neighbor_source_shizuku_unavailable
)
mode == Mode.NONE || availability == null -> null
mode == Mode.ROOT -> stringResource(
if (availability == Availability.UNAVAILABLE) R.string.neighbor_status_root_not_found
else R.string.neighbor_status_root_will_prompt
)
else -> stringResource(
when (availability) {
Availability.READY -> R.string.neighbor_status_ready
Availability.WILL_PROMPT -> R.string.neighbor_status_shizuku_will_prompt
Availability.DENIED -> R.string.neighbor_status_shizuku_denied
Availability.OUTDATED -> R.string.neighbor_status_shizuku_outdated
Availability.UNAVAILABLE -> R.string.neighbor_status_shizuku_not_running
}
)
}

/**
* Single-choice picker for where MAC addresses are read from, styled like [LanguagePickerSheet].
* Unlike the language picker it stays open while the chosen backend is asked for access, so a
* refusal is shown inline on that option; the caller dismisses it once the choice is applied.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NeighborSourcePickerSheet(
selected: Mode,
availability: Map<Mode, Availability>,
probe: NeighborProbe,
onSelected: (Mode) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val haptics = LocalHapticFeedback.current

ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.pref_neighbor_source_title),
style = MaterialTheme.typography.titleLarge
)
Text(
text = stringResource(R.string.pref_neighbor_source_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.selectableGroup()
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Mode.entries.forEach { mode ->
val isSelected = mode == selected
val probing = probe is NeighborProbe.Running && probe.mode == mode
val failed = probe is NeighborProbe.Failed && probe.mode == mode
val status = neighborSourceStatus(mode, availability[mode], probe)
val shape = RoundedCornerShape(20.dp)
val contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface
Surface(
shape = shape,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.selectable(
selected = isSelected,
enabled = !probing,
role = Role.RadioButton
) {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
onSelected(mode)
}
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = neighborSourceLabel(mode),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = contentColor
)
status?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = if (failed) MaterialTheme.colorScheme.error
else contentColor.copy(alpha = 0.72f)
)
}
}
when {
probing -> CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
isSelected -> Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
package com.networkscanner.app.ui.screens.settings

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -104,43 +99,3 @@ fun ClickableSettingItem(
}
)
}

@Composable
fun <T> SegmentedSettingItem(
title: String,
summary: String? = null,
icon: ImageVector? = null,
options: List<Pair<T, String>>,
selected: T,
onSelected: (T) -> Unit
) {
val haptics = LocalHapticFeedback.current
ListItem(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(title) },
supportingContent = {
Column {
summary?.let { Text(it) }
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
options.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = selected == value,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
onSelected(value)
},
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size)
) {
Text(label)
}
}
}
}
},
leadingContent = icon?.let { { SettingLeadingIcon(it) } }
)
}
Loading