From e14975af40bc749395cbeb12955d590d50128515 Mon Sep 17 00:00:00 2001 From: christianrowlands Date: Thu, 25 Jan 2024 11:32:56 -0500 Subject: [PATCH] First pass at a Wi-Fi Spectrum chart. Only added 2.4 GHz, and Vico does not yet support custom data labels --- networksurvey/build.gradle | 4 +- .../fragments/WifiDetailsFragment.kt | 4 +- .../fragments/WifiNetworksFragment.java | 41 +++- .../fragments/WifiSpectrumFragment.kt | 127 ++++++++++ .../ui/cellular/CustomMarkerLabelFormatter.kt | 37 ++- .../ui/wifi/SpectrumChartStyle.kt | 64 +++++ .../ui/wifi/SpectrumPointConnector.kt | 46 ++++ .../ui/wifi/WifiSpectrumChart.kt | 231 ++++++++++++++++++ .../ui/wifi/WifiSpectrumChartViewModel.kt | 113 +++++++++ .../ui/wifi/WifiSpectrumScreen.kt | 122 +++++++++ .../res/drawable-hdpi/ic_spectrum_chart.png | Bin 0 -> 714 bytes .../res/drawable-mdpi/ic_spectrum_chart.png | Bin 0 -> 450 bytes .../res/drawable-xhdpi/ic_spectrum_chart.png | Bin 0 -> 995 bytes .../res/drawable-xxhdpi/ic_spectrum_chart.png | Bin 0 -> 1522 bytes .../drawable-xxxhdpi/ic_spectrum_chart.png | Bin 0 -> 2012 bytes .../src/main/res/menu/wifi_networks_menu.xml | 9 + .../src/main/res/navigation/wifi.xml | 19 ++ networksurvey/src/main/res/values/strings.xml | 5 +- 18 files changed, 814 insertions(+), 8 deletions(-) create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiSpectrumFragment.kt create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/SpectrumChartStyle.kt create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/SpectrumPointConnector.kt create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChart.kt create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChartViewModel.kt create mode 100644 networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumScreen.kt create mode 100644 networksurvey/src/main/res/drawable-hdpi/ic_spectrum_chart.png create mode 100644 networksurvey/src/main/res/drawable-mdpi/ic_spectrum_chart.png create mode 100644 networksurvey/src/main/res/drawable-xhdpi/ic_spectrum_chart.png create mode 100644 networksurvey/src/main/res/drawable-xxhdpi/ic_spectrum_chart.png create mode 100644 networksurvey/src/main/res/drawable-xxxhdpi/ic_spectrum_chart.png create mode 100644 networksurvey/src/main/res/menu/wifi_networks_menu.xml diff --git a/networksurvey/build.gradle b/networksurvey/build.gradle index 8b8578b69..2cc211c80 100644 --- a/networksurvey/build.gradle +++ b/networksurvey/build.gradle @@ -110,8 +110,8 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'app.futured.donut:donut:2.2.3' - implementation 'com.patrykandpatrick.vico:core:2.0.0-alpha.5' - implementation 'com.patrykandpatrick.vico:compose-m3:2.0.0-alpha.5' + implementation 'com.patrykandpatrick.vico:core:2.0.0-alpha.6' + implementation 'com.patrykandpatrick.vico:compose-m3:2.0.0-alpha.6' implementation 'com.github.yuriy-budiyev:code-scanner:2.1.2' diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiDetailsFragment.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiDetailsFragment.kt index 00f73bc32..5e1c7f191 100644 --- a/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiDetailsFragment.kt +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiDetailsFragment.kt @@ -34,12 +34,12 @@ class WifiDetailsFragment : AServiceDataFragment(), IWifiSurveyRecordListener { private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == NetworkSurveyConstants.PROPERTY_WIFI_SCAN_INTERVAL_SECONDS) { - val bluetoothScanRateMs = PreferenceUtils.getScanRatePreferenceMs( + val wifiScanRateMs = PreferenceUtils.getScanRatePreferenceMs( NetworkSurveyConstants.PROPERTY_WIFI_SCAN_INTERVAL_SECONDS, NetworkSurveyConstants.DEFAULT_WIFI_SCAN_INTERVAL_SECONDS, context ) - viewModel.setScanRateSeconds(bluetoothScanRateMs / 1_000) + viewModel.setScanRateSeconds(wifiScanRateMs / 1_000) } } diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiNetworksFragment.java b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiNetworksFragment.java index ceff63ceb..7f2ff3142 100644 --- a/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiNetworksFragment.java +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiNetworksFragment.java @@ -15,6 +15,9 @@ import android.os.Looper; import android.provider.Settings; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -22,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; +import androidx.core.view.MenuProvider; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; @@ -52,7 +56,7 @@ * * @since 0.1.2 */ -public class WifiNetworksFragment extends Fragment implements IWifiSurveyRecordListener +public class WifiNetworksFragment extends Fragment implements IWifiSurveyRecordListener, MenuProvider { private FragmentWifiNetworksListBinding binding; private SortedList wifiRecordSortedList; @@ -134,6 +138,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, if (paused) lastScanTime = System.currentTimeMillis(); }); + FragmentActivity activity = getActivity(); + if (activity != null) + { + activity.addMenuProvider(this, getViewLifecycleOwner()); + } + return binding.getRoot(); } @@ -207,6 +217,23 @@ public void onWifiBeaconSurveyRecords(List wifiBeaconRecords) }); } + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) + { + menuInflater.inflate(R.menu.wifi_networks_menu, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) + { + if (menuItem.getItemId() == R.id.action_open_spectrum) + { + navigateToWifiSpectrumScreen(); + return true; + } + return false; + } + /** * Navigates to the Wi-Fi details screen for the selected Wi-Fi network. */ @@ -220,6 +247,18 @@ public void navigateToWifiDetails(WifiNetwork wifiNetwork) wifiNetwork)); } + /** + * Navigates to the Wi-Fi spectrum screen. + */ + public void navigateToWifiSpectrumScreen() + { + FragmentActivity activity = getActivity(); + if (activity == null) return; + + Navigation.findNavController(activity, getId()) + .navigate(WifiNetworksFragmentDirections.actionWifiListFragmentToWifiSpectrumFragment()); + } + /** * Updates the view with the information stored in the view model. * diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiSpectrumFragment.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiSpectrumFragment.kt new file mode 100644 index 000000000..42f426ada --- /dev/null +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/fragments/WifiSpectrumFragment.kt @@ -0,0 +1,127 @@ +package com.craxiom.networksurvey.fragments + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import com.craxiom.networksurvey.constants.NetworkSurveyConstants +import com.craxiom.networksurvey.listeners.IWifiSurveyRecordListener +import com.craxiom.networksurvey.model.WifiRecordWrapper +import com.craxiom.networksurvey.services.NetworkSurveyService +import com.craxiom.networksurvey.ui.wifi.WifiSpectrumChartViewModel +import com.craxiom.networksurvey.ui.wifi.WifiSpectrumScreen +import com.craxiom.networksurvey.util.NsTheme +import com.craxiom.networksurvey.util.PreferenceUtils + +/** + * The fragment that displays the details of a single Wifi network from the scan results. + */ +class WifiSpectrumFragment : AServiceDataFragment(), IWifiSurveyRecordListener { + private lateinit var viewModel: WifiSpectrumChartViewModel + + private lateinit var sharedPreferences: SharedPreferences + private val preferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == NetworkSurveyConstants.PROPERTY_WIFI_SCAN_INTERVAL_SECONDS) { + val wifiScanRateMs = PreferenceUtils.getScanRatePreferenceMs( + NetworkSurveyConstants.PROPERTY_WIFI_SCAN_INTERVAL_SECONDS, + NetworkSurveyConstants.DEFAULT_WIFI_SCAN_INTERVAL_SECONDS, + context + ) + viewModel.setScanRateSeconds(wifiScanRateMs / 1_000) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val composeView = ComposeView(requireContext()) + + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + viewModel = viewModel() + + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + sharedPreferences.registerOnSharedPreferenceChangeListener( + preferenceChangeListener + ) + val scanRateMs = PreferenceUtils.getScanRatePreferenceMs( + NetworkSurveyConstants.PROPERTY_WIFI_SCAN_INTERVAL_SECONDS, + NetworkSurveyConstants.DEFAULT_WIFI_SCAN_INTERVAL_SECONDS, + context + ) + viewModel.setScanRateSeconds(scanRateMs / 1_000) + viewModel.initializeCharts() + + NsTheme { + WifiSpectrumScreen( + viewModel = viewModel, + wifiSpectrumFragment = this@WifiSpectrumFragment + ) + } + } + } + + return composeView + } + + override fun onResume() { + super.onResume() + + startAndBindToService() + } + + override fun onPause() { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + + super.onPause() + } + + override fun onSurveyServiceConnected(service: NetworkSurveyService?) { + if (service == null) return + service.registerWifiSurveyRecordListener(this) + } + + override fun onSurveyServiceDisconnecting(service: NetworkSurveyService?) { + if (service == null) return + service.unregisterWifiSurveyRecordListener(this) + } + + override fun onWifiBeaconSurveyRecords(wifiBeaconRecords: MutableList?) { + val wifiNetworkInfoList: List = wifiBeaconRecords + ?.filter { it.wifiBeaconRecord.data.hasSignalStrength() && it.wifiBeaconRecord.data.ssid != null && it.wifiBeaconRecord.data.hasChannel() } + ?.map { + WifiNetworkInfo( + it.wifiBeaconRecord.data.ssid!!, + it.wifiBeaconRecord.data.signalStrength.value.toInt(), + it.wifiBeaconRecord.data.channel.value + ) + } + ?: emptyList() + + viewModel.onWifiScanResults(wifiNetworkInfoList) + } + + + /** + * Navigates to the Settings UI (primarily for the user to change the scan rate) + */ + fun navigateToSettings() { + findNavController().navigate(WifiDetailsFragmentDirections.actionWifiDetailsToSettings()) + } +} + +data class WifiNetworkInfo( + val ssid: String, + val signalStrength: Int, + val channel: Int +) diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/cellular/CustomMarkerLabelFormatter.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/cellular/CustomMarkerLabelFormatter.kt index 7168dc46b..d2f2f8dfa 100644 --- a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/cellular/CustomMarkerLabelFormatter.kt +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/cellular/CustomMarkerLabelFormatter.kt @@ -1,11 +1,11 @@ package com.craxiom.networksurvey.ui.cellular +import android.os.Build import android.text.Spannable +import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import com.patrykandpatrick.vico.core.chart.values.ChartValues -import com.patrykandpatrick.vico.core.extension.appendCompat -import com.patrykandpatrick.vico.core.extension.transformToSpannable import com.patrykandpatrick.vico.core.marker.Marker import com.patrykandpatrick.vico.core.marker.MarkerLabelFormatter @@ -30,3 +30,36 @@ class CustomMarkerLabelFormatter(private val colorCode: Boolean = true, private } } } + +internal fun SpannableStringBuilder.appendCompat( + text: CharSequence, + what: Any, + flags: Int, +): SpannableStringBuilder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + append(text, what, flags) + } else { + append(text, 0, text.length) + setSpan(what, length - text.length, length, flags) + this + } + +internal fun Iterable.transformToSpannable( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "", + limit: Int = -1, + truncated: CharSequence = "…", + transform: SpannableStringBuilder.(T) -> Unit, +): Spannable { + val buffer = SpannableStringBuilder() + buffer.append(prefix) + var count = 0 + for (element in this) { + if (++count > 1) buffer.append(separator) + if (limit < 0 || count <= limit) buffer.transform(element) else break + } + if (limit in 0.., + lineLayerColors: List, +): ChartStyle { + val isSystemInDarkTheme = isSystemInDarkTheme() + // TODO Use the textComponent for the SSID labels when support is added to the library + /*val textComponent = rememberTextComponent( + color = Color(if (isSystemInDarkTheme) DefaultColors.Dark.axisLabelColor else DefaultColors.Light.axisLabelColor), + )*/ + + return remember(columnLayerColors, lineLayerColors, isSystemInDarkTheme) { + val defaultColors = if (isSystemInDarkTheme) DefaultColors.Dark else DefaultColors.Light + ChartStyle( + ChartStyle.Axis( + axisLabelColor = Color(defaultColors.axisLabelColor), + axisGuidelineColor = Color(defaultColors.axisGuidelineColor), + axisLineColor = Color(defaultColors.axisLineColor), + ), + ChartStyle.ColumnLayer( + columnLayerColors.map { columnChartColor -> + LineComponent( + columnChartColor.toArgb(), + DefaultDimens.COLUMN_WIDTH, + Shapes.roundedCornerShape(DefaultDimens.COLUMN_ROUNDNESS_PERCENT), + ) + }, + ), + ChartStyle.LineLayer( + lineLayerColors.map { lineChartColor -> + LineCartesianLayer.LineSpec( + pointConnector = SpectrumPointConnector(), + //dataLabel = textComponent, + thicknessDp = 3f, + shader = DynamicShaders.color(lineChartColor), + //backgroundShader = null, + ) + }, + ), + ChartStyle.Marker(), + Color(defaultColors.elevationOverlayColor), + ) + } +} + +@Composable +internal fun rememberSpectrumChartStyle(chartColors: List) = + rememberSpectrumChartStyle(columnLayerColors = chartColors, lineLayerColors = chartColors) diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/SpectrumPointConnector.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/SpectrumPointConnector.kt new file mode 100644 index 000000000..8542ad130 --- /dev/null +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/SpectrumPointConnector.kt @@ -0,0 +1,46 @@ +package com.craxiom.networksurvey.ui.wifi + +import android.graphics.Path +import android.graphics.RectF +import com.patrykandpatrick.vico.core.chart.dimensions.HorizontalDimensions +import com.patrykandpatrick.vico.core.chart.layer.LineCartesianLayer + +/** + * The point connector for the spectrum chart. This point connector rounds out the curve to make + * each wifi channel look more like one large curve. + */ +class SpectrumPointConnector : LineCartesianLayer.LineSpec.PointConnector { + override fun connect( + path: Path, + prevX: Float, + prevY: Float, + x: Float, + y: Float, + horizontalDimensions: HorizontalDimensions, + bounds: RectF, + ) { + // for all the WIFI_CHART_MIN points, just draw a straight line + if (prevY == y) { + path.lineTo(x, y) + return + } + + if (prevY > y) { + // Left side + val controlPoint1X = prevX + (x - prevX) * 0.16f + val controlPoint1Y = prevY + (y - prevY) * 0.5f + val controlPoint2X = prevX + (x - prevX) * 0.60f + val controlPoint2Y = prevY + (y - prevY) * 0.93f + + path.cubicTo(controlPoint1X, controlPoint1Y, controlPoint2X, controlPoint2Y, x, y) + } else { + // Right side + val controlPoint1X = prevX + (x - prevX) * 0.6f + val controlPoint1Y = prevY + (y - prevY) * 0.16f + val controlPoint2X = prevX + (x - prevX) * 0.89f + val controlPoint2Y = prevY + (y - prevY) * 0.70f + + path.cubicTo(controlPoint1X, controlPoint1Y, controlPoint2X, controlPoint2Y, x, y) + } + } +} diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChart.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChart.kt new file mode 100644 index 000000000..5d9875759 --- /dev/null +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChart.kt @@ -0,0 +1,231 @@ +package com.craxiom.networksurvey.ui.wifi + +import android.graphics.Typeface +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.craxiom.networksurvey.R +import com.craxiom.networksurvey.fragments.WifiNetworkInfo +import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis +import com.patrykandpatrick.vico.compose.chart.CartesianChartHost +import com.patrykandpatrick.vico.compose.chart.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.chart.layout.fullWidth +import com.patrykandpatrick.vico.compose.chart.rememberCartesianChart +import com.patrykandpatrick.vico.compose.chart.scroll.rememberChartScrollSpec +import com.patrykandpatrick.vico.compose.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.component.rememberTextComponent +import com.patrykandpatrick.vico.compose.dimensions.dimensionsOf +import com.patrykandpatrick.vico.compose.legend.horizontalLegend +import com.patrykandpatrick.vico.compose.legend.legendItem +import com.patrykandpatrick.vico.compose.legend.verticalLegend +import com.patrykandpatrick.vico.compose.style.ProvideChartStyle +import com.patrykandpatrick.vico.compose.style.currentChartStyle +import com.patrykandpatrick.vico.core.axis.AxisItemPlacer +import com.patrykandpatrick.vico.core.axis.vertical.VerticalAxis +import com.patrykandpatrick.vico.core.chart.layout.HorizontalLayout +import com.patrykandpatrick.vico.core.chart.values.AxisValueOverrider +import com.patrykandpatrick.vico.core.component.shape.Shapes +import com.patrykandpatrick.vico.core.legend.HorizontalLegend +import com.patrykandpatrick.vico.core.legend.LegendItem + +/** + * A chart that shows signal values (e.g. RSSI) over time. + * + * @param viewModel The view model that contains the data to display. + */ +@Composable +internal fun WifiSpectrumChart( + viewModel: WifiSpectrumChartViewModel +) { + ComposeChart(viewModel) +} + +@Composable +private fun ComposeChart(viewModel: WifiSpectrumChartViewModel) { + val wifiList by viewModel.wifiNetworkInfoList.collectAsStateWithLifecycle() + + + ProvideChartStyle(rememberSpectrumChartStyle(chartColors)) { + //val defaultLines = currentChartStyle.lineLayer.lines + CartesianChartHost( + modifier = Modifier.height(230.dp), + modelProducer = viewModel.modelProducer2Point4, + marker = null,//rememberMarker(""), + runInitialAnimation = false, + chartScrollSpec = rememberChartScrollSpec(isScrollEnabled = false), + chart = + rememberCartesianChart( + rememberLineCartesianLayer( + axisValueOverrider = AxisValueOverrider.fixed( + maxY = WIFI_SPECTRUM_MAX, + minY = WIFI_SPECTRUM_MIN, + ), + //remember(defaultLines) { defaultLines.map { it.copy(backgroundShader = null) } }, + ), + startAxis = + rememberStartAxis( + horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside, + itemPlacer = remember { AxisItemPlacer.Vertical.default({ _ -> 5 }) }, + ), + bottomAxis = + rememberBottomAxis( + titleComponent = + rememberTextComponent( + background = rememberShapeComponent(Shapes.pillShape, color2), + color = Color.White, + padding = axisTitlePadding, + margins = bottomAxisTitleMargins, + typeface = Typeface.MONOSPACE, + ), + title = stringResource(R.string.channel), + ), + //legend = rememberSsidLegend(wifiList), + //fadingEdges = rememberFadingEdges(), + ), + horizontalLayout = horizontalLayout, + ) + } +} + +@Composable +private fun rememberSsidLegend( + wifiList: List +): HorizontalLegend { + + val legendItems: List + if (wifiList.isEmpty()) { + legendItems = listOf( + legendItem( + icon = rememberShapeComponent(Shapes.pillShape, chartColors[0]), + label = + rememberTextComponent( + color = currentChartStyle.axis.axisLabelColor, + textSize = legendItemLabelTextSize, + typeface = Typeface.MONOSPACE, + ), + labelText = "No Wifi Networks Found", + ) + ) + } else { + // For this to work the list would need to be filtered for 2.4 GHz and non null ssids + legendItems = wifiList.mapIndexed { index, wifiNetworkInfo -> + legendItem( + icon = rememberShapeComponent( + Shapes.pillShape, + chartColors[index % chartColors.size] + ), + label = + rememberTextComponent( + color = currentChartStyle.axis.axisLabelColor, + textSize = legendItemLabelTextSize, + typeface = Typeface.MONOSPACE, + ), + labelText = wifiNetworkInfo.ssid, + ) + } + } + return horizontalLegend( + items = legendItems, + iconSize = legendItemIconSize, + iconPadding = legendItemIconPaddingValue, + spacing = legendItemSpacing, + padding = legendPadding, + ) +} + +@Composable +private fun rememberLegend() = + verticalLegend( + items = + chartColors.mapIndexed { index, chartColor -> + legendItem( + icon = rememberShapeComponent(Shapes.pillShape, chartColor), + label = + rememberTextComponent( + color = currentChartStyle.axis.axisLabelColor, + textSize = legendItemLabelTextSize, + typeface = Typeface.MONOSPACE, + ), + labelText = stringResource(R.string.series_x, index + 1), + ) + }, + iconSize = legendItemIconSize, + iconPadding = legendItemIconPaddingValue, + spacing = legendItemSpacing, + padding = legendPadding, + ) + +/*private val color1 = Color(0xffb983ff) +private val color2 = Color(0xff91b1fd) +private val color3 = Color(0xff8fdaff) +private val color4 = Color(0xfffab94d)*/ + +private val color1 = Color(0xff4a148c) +private val color2 = Color(0xff880e4f) +private val color3 = Color(0xffb71c1c) +private val color4 = Color(0xffd50000) +private val color5 = Color(0xffe65100) +private val color6 = Color(0xfff57f17) +private val color7 = Color(0xffff6f00) +private val color8 = Color(0xffe65100) +private val color9 = Color(0xfff9a825) +private val color10 = Color(0xff9e9d24) +private val color11 = Color(0xff558b2f) +private val color12 = Color(0xff2e7d32) +private val color13 = Color(0xff00695c) +private val color14 = Color(0xff004d40) +private val color15 = Color(0xff01579b) +private val color16 = Color(0xff0d47a1) +private val color17 = Color(0xff1a237e) +private val color18 = Color(0xff311b92) + +private val chartColors = listOf( + color1, + color2, + color3, + color4, + color5, + color6, + color7, + color8, + color9, + color10, + color11, + color12, + color13, + color14, + color15, + color16, + color17, + color18 +) +private val startAxisLabelVerticalPaddingValue = 2.dp +private val startAxisLabelHorizontalPaddingValue = 8.dp +private val startAxisLabelMarginValue = 4.dp +private val startAxisLabelBackgroundCornerRadius = 4.dp +private val legendItemLabelTextSize = 12.sp +private val legendItemIconSize = 8.dp +private val legendItemIconPaddingValue = 10.dp +private val legendItemSpacing = 4.dp +private val legendTopPaddingValue = 8.dp +private val legendPadding = dimensionsOf(top = legendTopPaddingValue) + +private val axisTitleHorizontalPaddingValue = 8.dp +private val axisTitleVerticalPaddingValue = 2.dp +private val axisTitlePadding = + dimensionsOf(axisTitleHorizontalPaddingValue, axisTitleVerticalPaddingValue) +private val axisTitleMarginValue = 4.dp +private val bottomAxisTitleMargins = dimensionsOf(top = axisTitleMarginValue) + +private val lineColor = Color(0xFF03A9F4) + +//private val chartColors = listOf(lineColor) +private val horizontalLayout = HorizontalLayout.fullWidth() diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChartViewModel.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChartViewModel.kt new file mode 100644 index 000000000..02db3b153 --- /dev/null +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumChartViewModel.kt @@ -0,0 +1,113 @@ +package com.craxiom.networksurvey.ui.wifi + + +import androidx.lifecycle.ViewModel +import com.craxiom.networksurvey.fragments.WifiNetworkInfo +import com.patrykandpatrick.vico.core.model.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.model.LineCartesianLayerModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber + +// Subtracting 10 from the max and min so that the chart shows spectrum usage a bit better +const val WIFI_SPECTRUM_MAX = MAX_WIFI_RSSI - 10 +const val WIFI_SPECTRUM_MIN = MIN_WIFI_RSSI - 10 + +// The offset for the signal strength values for the channels to the left and right +private const val WEAKER_SIGNAL_OFFSET = 5 + +const val WIFI_CHART_MIN = WIFI_SPECTRUM_MIN - 10 + +private val BAND_2_4_GHZ_CHANNELS = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) +private val BAND_2_4_GHZ_CHANNELS_CHART_VIEW = + listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) + +/** + * Abstract base class for the view model for a signal chart. + */ +class WifiSpectrumChartViewModel : ViewModel() { + + internal val modelProducer2Point4 = CartesianChartModelProducer.build() + + private val _scanRateSeconds = MutableStateFlow(-1) + val scanRate = _scanRateSeconds.asStateFlow() + + private val _wifiNetworkInfoList = MutableStateFlow>(emptyList()) + val wifiNetworkInfoList: StateFlow> = _wifiNetworkInfoList + + fun initializeCharts() { + if (_wifiNetworkInfoList.value.isEmpty()) { + Timber.i("No valid wifi records found in the wifi scan results") + clearCharts() + return + } else { + onWifiScanResults(_wifiNetworkInfoList.value) + } + } + + /** + * Sets the scan rate in seconds. + */ + fun setScanRateSeconds(scanRateSeconds: Int) { + _scanRateSeconds.value = scanRateSeconds + } + + /** + * Called when a new Wifi scan result is received. Updates the chart with the results + */ + fun onWifiScanResults(wifiNetworkInfoList: List) { + _wifiNetworkInfoList.value = wifiNetworkInfoList + if (wifiNetworkInfoList.isEmpty()) { + Timber.i("No valid wifi records found in the wifi scan results") + clearCharts() + return + } + + modelProducer2Point4.tryRunTransaction { + add( + create2Point4SeriesModel(wifiNetworkInfoList = wifiNetworkInfoList) + ) + } + } + + private fun create2Point4SeriesModel(wifiNetworkInfoList: List): LineCartesianLayerModel.Partial { + return LineCartesianLayerModel.partial { + wifiNetworkInfoList + .filter { it.channel in BAND_2_4_GHZ_CHANNELS } + .forEach { wifiNetwork -> + val signalStrengths = BAND_2_4_GHZ_CHANNELS_CHART_VIEW.map { channel -> + when (channel) { + wifiNetwork.channel -> constrictSignalStrength(wifiNetwork.signalStrength.toFloat()) + wifiNetwork.channel - 1, wifiNetwork.channel + 1 -> (wifiNetwork.signalStrength - WEAKER_SIGNAL_OFFSET).toFloat() + else -> WIFI_CHART_MIN + } + } + series(BAND_2_4_GHZ_CHANNELS_CHART_VIEW, signalStrengths) + } + } + + // TODO Handle the 5 GHz range + } + + private fun clearCharts() { + modelProducer2Point4.tryRunTransaction { + add(LineCartesianLayerModel.partial { + series(BAND_2_4_GHZ_CHANNELS_CHART_VIEW, List(16) { WIFI_CHART_MIN }) + }) + } + } + + /** + * Brings the provided signal strength value within the range of the chart. + */ + private fun constrictSignalStrength(signalStrength: Float): Float { + return if (signalStrength > WIFI_SPECTRUM_MAX) { + WIFI_SPECTRUM_MAX + } else if (signalStrength < WIFI_SPECTRUM_MIN) { + WIFI_SPECTRUM_MIN + } else { + signalStrength + } + } +} diff --git a/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumScreen.kt b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumScreen.kt new file mode 100644 index 000000000..939b3a382 --- /dev/null +++ b/networksurvey/src/main/java/com/craxiom/networksurvey/ui/wifi/WifiSpectrumScreen.kt @@ -0,0 +1,122 @@ +package com.craxiom.networksurvey.ui.wifi + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.craxiom.networksurvey.fragments.WifiSpectrumFragment + +/** + * A Compose screen that shows the usage of the Wi-Fi spectrum. + */ +@Composable +internal fun WifiSpectrumScreen( + viewModel: WifiSpectrumChartViewModel, + wifiSpectrumFragment: WifiSpectrumFragment +) { + val scanRate by viewModel.scanRate.collectAsStateWithLifecycle() + + LazyColumn( + state = rememberLazyListState(), + contentPadding = PaddingValues(padding), + verticalArrangement = Arrangement.spacedBy(padding), + ) { + chartItems(viewModel, scanRate, wifiSpectrumFragment) + } +} + +private fun LazyListScope.chartItems( + viewModel: WifiSpectrumChartViewModel, + scanRate: Int, + wifiSpectrumFragment: WifiSpectrumFragment +) { + item { + Card( + modifier = Modifier + .fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors() + ) { + Row( + modifier = Modifier + .padding(horizontal = padding) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = "Scan Rate: ", + style = MaterialTheme.typography.labelMedium + ) + Text( + text = "$scanRate seconds", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.weight(1f)) + + ScanRateInfoButton() + + OpenSettingsBtn(wifiSpectrumFragment) + } + } + } + + cardItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Wi-Fi 2.4 GHz Spectrum", + style = MaterialTheme.typography.titleMedium + ) + WifiSpectrumChart(viewModel) + } + } +} + +private fun LazyListScope.cardItem(content: @Composable () -> Unit) { + item { + Card(shape = MaterialTheme.shapes.large, colors = CardDefaults.elevatedCardColors()) { + Box(Modifier.padding(padding)) { + content() + } + } + } +} + +@Composable +fun OpenSettingsBtn(fragment: WifiSpectrumFragment) { + + IconButton(onClick = { + fragment.navigateToSettings() + }) { + Icon( + Icons.Default.Settings, + contentDescription = "Settings Button", + ) + } +} + +private val padding = 16.dp diff --git a/networksurvey/src/main/res/drawable-hdpi/ic_spectrum_chart.png b/networksurvey/src/main/res/drawable-hdpi/ic_spectrum_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..56279180e5921ec387da1450e7a72f8dd6e10a25 GIT binary patch literal 714 zcmV;*0yX`KP)vvx}=e~E&%5m5Itxvn}@Ao?AzVJ6pJ8afE=KC2|^7eOa1^x3Rr4gG;BpQVY~5z%q++x`yu zn%uI6`k>WkNuq9-HL)97V6UB{*VpEfoovQ?NOVM8ao#n!9(%c!r6KC(!B^Pn{C@Zd zO|hR#85`;qy<9EM4;%a^^u&HHSwoNDlFz)Txd?Z>AB6t^PJ2JItf3n)?6X+pbKn&W z_$&y22qwLsS=Lb1OGeFF(ebdtXF>RDEMMufOv=#ENqFY{SmZCH%1T$&Od2Ae4{za! z&tj1uhu5${bWZJnHeXj!*3bZS_^c$d6Yxgd-4%O<>W>6ZXiY=&BqFUX(Y(ZP93V7XOfC9q=5!!5LT_=Oy9Kg$s6`vf0J=&7#9NWS8SCt)Z@P zQ0$e2zd-c$V~Nj_MBO&%^nMV24;=7jXy_@7mjy-18oCODJ}X4l(6mimm};USzVDNS zH$c&eTlT8T_wADKN+S9O(eJ!X@HqCSiGoj}cTIQ1d3fo(K$uIT&O0UgnDDhzaMpRp wL??FmoH#B*Lx)9QOT**iU()A$sjH^+7ZAbNk(FBXh5!Hn07*qoM6N<$g4cy%UjP6A literal 0 HcmV?d00001 diff --git a/networksurvey/src/main/res/drawable-mdpi/ic_spectrum_chart.png b/networksurvey/src/main/res/drawable-mdpi/ic_spectrum_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..7f4a821744bcf737de68c59daab7582f14efeb8b GIT binary patch literal 450 zcmV;z0X_bSP)pX*F-Arh{&HolUq;0lCFtV)Btila46zdsK}aF z(eMce+PA_lLP2>IJ4kwwvTp#5+PA_#GU~$;FTc)et`eCGFP~#W&Cy z;;%zK#BVm<%{zq)*nxgs7l`CLY$`7Y$55yILAU8Nw7FqfD8q^gJ8GkjhJ-HjUVfTy-ybGV0B sx3;{&oru50`qt(B_{H^S^o+l&Z-8~y7eoiQE&u=k07*qoM6N<$f;ueHs{jB1 literal 0 HcmV?d00001 diff --git a/networksurvey/src/main/res/drawable-xhdpi/ic_spectrum_chart.png b/networksurvey/src/main/res/drawable-xhdpi/ic_spectrum_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..4dcdbbed213d12504085b63590534e1d59de96c1 GIT binary patch literal 995 zcmV<9104K`P)oqfI(uQzmXW|Z@2;F3-A)_`2ua1gPq_9 z@DbPwR&ZX?;hldAUIE)!&j)C?U96701JAhu{e1&0=lr6=4fOdDLfbmRto`_r=!Fpb)K1Y<9-8Ry4#hfOZpo^!Kx-Kzxzw zYgsEAVj5`n4lZO(f%q`Leg|tsgWupOxEI{adNv^cJopV9%32!v+y(vuKZ0Ap2?1SavtDdN&>WrBmBDM|gqVTb;3~-Z1JZ>DB~ zcf|%qzdMZ!X!F5&ls^}I=J_YVu|Yi&lE)f=KE^wv)4!VaAjFIw5F1ggz%Ah5fUX7b zwbk6=Dlf4}+cHV*SlIQ4lk~Pi#jDeN+&y6#HtZ z_c+XYj{&g}=c&}jIml7}16cURrIcC%jca08)h zU_c--(BH^rKy3TcFLvD@tVpYIZ5e9{#1Bf?wwLPGmU!E#AzB4^8Ej!K6k_H64968> z1xky^s1R+sYoRw1D`*cnu0VXfx9#vXW){eWT#Z7-Va-aVJ5~hibmbxxQHgTU5BuJ1-{RK>@2H*0j RQMUj9002ovPDHLkV1nr|%tinJ literal 0 HcmV?d00001 diff --git a/networksurvey/src/main/res/drawable-xxhdpi/ic_spectrum_chart.png b/networksurvey/src/main/res/drawable-xxhdpi/ic_spectrum_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..3925fdbf8a4355dce26bcf03e112449ad5000698 GIT binary patch literal 1522 zcmV8}(;6vZEipe%|gh$4^}MKp-}8b1g@A_hOGQ4?YUf{M7HQR5a7gAtdAq9jHo0hPFX zFvbt=agV$HPsekps`Q=v`gQlb*L2PJ zAIpp#HRxCBziiM17=!jw*G8S|^F|h9kkuZaH?m}dYA^;pPA!i*#pjJI#-Q9umyr#s zAsbXfHmHVdPz~9j8nQt(7=vD+)<>Q1^ST9VFQl%cZll&=s*kyhK|LKwv*vu&rT%uu z`ipvwI?=b867<6$)>}kp$kcKW#B=Z~12%YA&H^ueHKV*MYf<9uB3^V!r-G4E*~ zA1J*vh&88CU&Z=+ecZzMOEK>Ywe#_T(PM+u$D~{51|PRDzR~2K?c)QZ#|CYUwe@IU z;Nup?Pe$=%+v4K`qsImvinR~eYwhy!Dww}Jw)>7c#>e}PG3a4xLDXEI*A8rdL98jQ_3yzlhLppRnBw|uM#Z1+xVf5gZ7PPYwWon_R& zvF0WpZvxxh5!leyk-cssW6q8X&{g%c!qnD0R_X@@%x)tPXKhOx5{ zM&|a4{h4}*I^E~DfaV5mHO2KSH0u1AH^&@9(yyr}Oh@Atgj-1Gk?L;oIUeR*iq>s# zsd|F|%?;A-=Qju)UR#fHgxlgb>M3+_YQS2jQm-RCt!Ddp83=uuo^~1->*}lAuWM|O zz$D+kKzO^OXS);~oY!({rvJPJW6%L=Q&dM&g`vV2WYu^iJ@jOQYA^5(KGR6{nXhHOv`O$|cmyfx9>jhZD{gjZjbCsqFcSA+>{%B>t_GrrQ~R?guN zrWGl-dN5c8nj564Mw%7h>f;v1m&81&a&MmT)h2hfk6RdDV{$7u6dAuI=4pc3Xc|%q z!W!<;$GneObP$ZFLE0fuAuTRxHE;6-x*Nq3!>c86D=kc=FQS>dk6UT4E9L6%K5nI2 zDObnvaVwo;%GJ5+vvvOa*MhL0gCNn(z Y4;{3F;dA7AS^xk507*qoM6N<$g3*5YmH+?% literal 0 HcmV?d00001 diff --git a/networksurvey/src/main/res/drawable-xxxhdpi/ic_spectrum_chart.png b/networksurvey/src/main/res/drawable-xxxhdpi/ic_spectrum_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..6860ac74f6a8b2b7fbc9be8da8abe0edf2b0d008 GIT binary patch literal 2012 zcmV<22P622P)Ce?f6vo*E!X6SpkpLoML{T(ufq(`R0fP#nCopoh(2-J!ts%s zAp>G3+-sr%fr$nLCK?c!Xh2}10fC7I1ST2~m}o#?q5*-41_Xu-_(=4c(|V5Y1yb*P z(e0vFMPGgjB#)Hhl5xajYUb4m20=o-;bF5j1+AqGfYGhj2Y zd=7cqCmP0i`VOQG(7SJ&)A=0V4WyniqIc*5>(uxiqP4K5=PA*j6tkUk^&MzHCoJ{u zpbLAyXlzE@DM&vhe~9LATwf8|0NCB{&*3?=Xj@?$tA4~f0dmDLxwsT|W61UBGhSNB7F zycH5ZPV|c_zlLM_f=~uXT@#Uhv0BM7?T|7rxbjc(@xCCS0V`a+zl$bwOgp5^dY41z zh(>Wt;~@jC6U}ideNl=cxgT`(zQV_g0DCFZcZVd;c^uPt5MVM+@{SOF=jy4n1yafl z6}|8B?Bz219FjWs@bNN8`H`Xlmu~~dG#d1H6f|h8?>H~un6kjuF_L@- z_;{lUV89iwzF$RSIHoL;<8sP5o?{wI00W+O_3h{5RggSWkX}b##xaehmj+1w2}n;L ztmBxfz;@VF2vz*Twdv9^LIWlH)o>a-Qw#P+bO_ zFS=NC1MIDNw`jX)wdmAtoJT3p8w1Wl`tIMzF?9f2;`D0_I^ErBmz`=r^oVGYmgk&Z z2VFJ*wtoADtK&=5F!s+v@-7g)?&>P5w?#`iZ&yLufM-OvI4$A$vPh0==|`^Sm`;(L zqeXWi{f6zOYXy1}=mO4u*pO9!ciz+Sm}nx`(-j6!gijz*N0V_{z7td;W$Frkxqs1-ueA z=J)5U>kRrYy%g?TVa?=nq_@bZbXcouZcj*mxrr7n?vr*I<2*8*zpgkeL@JLv~JiyB54nge!sAEEqu zr*1(`^Co{E1-r1?9G=6+J+SlFA*$bhs?sNTD@r_Bg+Pr444(WhI&p1ZJDwJReKm84 zawqv0aC`#Hw1X~QFKpYkU1DbE)b}3h+_sOJ41hH|exIl6E71&&ErXPw3i}v?M>smh z*B7hNT!w*7f%_4U_8W^ij)ARR>*g^(kWgtZ{a#@fmnjWuFaS2osm+W(N&Ud0Wt-cI z!JN8u$xVSuhm^T@eHO6cus1=QEnNX@yM_#V8njR3^U&=ycO$3YDFiw+K+2pZ`jAdc zryAyRY!P4^wsho?H9FJ3>0Y3thLuHteQCo_(gP^4fCFpFE`j|vJ};eO*C~!^T&^vs zXaMZxN5|QE7x))7G|B@2bv67X^UbNIF&hHLR0u+ac!x8?EX5*%8>& zJcVO2K}7=;*j!KLllH)-ta~#7M!>eZm+rT+fPHjW{5^pTSPARx6V-BYBP}2U24Ih4 zqHP=>sTneWRe3fvQjlmsV90=bM5~gRq-^1N6WA(QQchKP?g;iVUs7%rA8!Th`{|@?@wxsYpt=DU zxpKPmtngGc(*MN3ZG1criJk7ssS3}+O6<+9+(ACx3Z$>m3O?Qulsp=c0lI8}{#S-c z@=N)69*73i1Jd7C>F(fVqQwPtlPjkSDN8b9^5l0*>&hA*&y()=_Xe!Vh{@BVuAHu0 z^YK>l>%Y3_lKfr!mBM{rCX!&COCD8Dx{6o$$f8VYwgYUV=w1Bx uRY_EP>7rHV21Fu}NF)-8L?V&@E&l>H;xrEN9i0mR0000 + + + diff --git a/networksurvey/src/main/res/navigation/wifi.xml b/networksurvey/src/main/res/navigation/wifi.xml index 7b0c68113..687cb6ffd 100644 --- a/networksurvey/src/main/res/navigation/wifi.xml +++ b/networksurvey/src/main/res/navigation/wifi.xml @@ -33,6 +33,13 @@ app:nullable="false" /> + + + + + + MNC (Mobile Network Code): Unique network identifier within the country. Together the MCC and MNC represent a specific service provider (e.g. T-Mobile).\n\n TAC (Tracking Area Code): Identifier for a group of cell towers that is used for paging.\n\n CID (Cell Identity): Identifier for a specific cell tower. Together the MCC, MNC, and CID uniquely identifies a specific LTE cell panel from all others in the world.\n\n - eNB ID (eNodeB Identifier): The cell tower identifier, which will contain one or more cell panels. This is the first 20 bites of the CID.\n\n + eNB ID (eNodeB Identifier): The cell tower identifier, which will contain one or more cell panels. This is the first 20 bits of the CID.\n\n Sector ID: Identifies a sector (aka panel) within a cell tower. This is the last 8 bits of the CID.\n\n EARFCN (E-UTRA Absolute Radio Frequency Channel Number): Number representing LTE carrier frequency.\n\n Band: That band number representing the frequency range the EARFCN falls in.\n\n @@ -534,5 +534,8 @@ but work independently, so you have full control over how you handle your data.< Auto Configure With QR Code QR Code Scanner + Wi-Fi Spectrum + Channel + SSID\n