From 51055cf1c38651f5059c1615dc5fe3a18a74d688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sim=C3=B3=20Albert=20i=20Beltran?= Date: Thu, 30 Jan 2025 23:34:16 +0100 Subject: [PATCH] Add coverage on map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Simó Albert i Beltran --- .../neostumbler/constants/PreferenceKeys.kt | 2 + .../ui/composables/MLSWarningDialog.kt | 5 + .../settings/CoverageLayerSettings.kt | 181 ++++++++++++++++++ ...encryptedEndpointWarning.kt => Warning.kt} | 6 +- .../geosubmit/GeosubmitEndpointSettings.kt | 3 +- .../neostumbler/ui/screens/MapScreen.kt | 50 +++++ .../ui/screens/settings/SettingsScreen.kt | 2 + .../neostumbler/ui/viewmodel/MapViewModel.kt | 14 ++ .../neostumbler/utils/SuggestedService.kt | 3 +- .../xyz/malkki/neostumbler/utils/tileJson.kt | 44 +++++ app/src/main/res/raw/suggested_services.json | 5 +- app/src/main/res/values/strings.xml | 8 + 12 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/CoverageLayerSettings.kt rename app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/{geosubmit/UnencryptedEndpointWarning.kt => Warning.kt} (90%) create mode 100644 app/src/main/java/xyz/malkki/neostumbler/utils/tileJson.kt diff --git a/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt b/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt index 02a64213..f8e43e70 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/constants/PreferenceKeys.kt @@ -16,6 +16,8 @@ object PreferenceKeys { const val GEOSUBMIT_PATH = "geosubmit_path" const val GEOSUBMIT_API_KEY = "geosubmit_api_key" + const val COVERAGE_TILE_JSON_URL = "coverage_tile_json_url" + const val MOVEMENT_DETECTOR = "movement_detector" const val SCANNER_NOTIFICATION_STYLE = "scanner_notification_style" diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/MLSWarningDialog.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/MLSWarningDialog.kt index 3e3b830e..032d1e4b 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/MLSWarningDialog.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/MLSWarningDialog.kt @@ -88,6 +88,11 @@ fun MLSWarningDialog() { set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_PATH), path) remove(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_API_KEY)) + + set(stringPreferencesKey( + PreferenceKeys.COVERAGE_TILE_JSON_URL), + defaultServiceParams.value!!.coverageTileJsonUrl + ) } } diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/CoverageLayerSettings.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/CoverageLayerSettings.kt new file mode 100644 index 00000000..5e885fd5 --- /dev/null +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/CoverageLayerSettings.kt @@ -0,0 +1,181 @@ +package xyz.malkki.neostumbler.ui.composables.settings + +import android.util.Patterns +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +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.unit.dp +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import xyz.malkki.neostumbler.R +import xyz.malkki.neostumbler.StumblerApplication +import xyz.malkki.neostumbler.constants.PreferenceKeys +import xyz.malkki.neostumbler.ui.composables.settings.SettingsItem +import xyz.malkki.neostumbler.ui.composables.settings.geosubmit.SuggestedServicesDialog + +private fun DataStore.coverageLayerTileJsonUrl(): Flow = data + .map { preferences -> + preferences[stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON_URL)] + } + .distinctUntilChanged() + +@Composable +fun CoverageLayerSettings() { + val context = LocalContext.current + + val coroutineScope = rememberCoroutineScope() + + val settingsStore = (context.applicationContext as StumblerApplication).settingsStore + val tileJsonUrl = settingsStore.coverageLayerTileJsonUrl().collectAsState(initial = null) + + val dialogOpen = rememberSaveable { mutableStateOf(false) } + + if (dialogOpen.value) { + CoverageLayerDialog( + currentTileJsonUrl = tileJsonUrl.value, + onDialogClose = { newTileJsonUrl -> + coroutineScope.launch { + settingsStore.updateData { prefs -> + prefs.toMutablePreferences().apply { + if (newTileJsonUrl.isNullOrEmpty()) { + remove(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON_URL)) + } else { + set(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON_URL), newTileJsonUrl) + } + } + } + dialogOpen.value = false + } + } + ) + } + + SettingsItem( + title = stringResource(R.string.coverage_layer), + description = tileJsonUrl.value ?: stringResource(R.string.coverage_layer_no_configured_tile_json_url), + onClick = { + dialogOpen.value = true + } + ) +} + +@Composable +private fun CoverageLayerDialog(currentTileJsonUrl: String?, onDialogClose: (String?) -> Unit) { + val tileJsonUrl = rememberSaveable { + mutableStateOf(currentTileJsonUrl) + } + + val showSuggestedServicesDialog = rememberSaveable { + mutableStateOf(false) + } + + if (showSuggestedServicesDialog.value) { + SuggestedServicesDialog( + onServiceSelected = { service -> + if (service != null) { + tileJsonUrl.value = service.coverageTileJsonUrl + } + + showSuggestedServicesDialog.value = false + } + ) + } + + BasicAlertDialog( + onDismissRequest = { onDialogClose(null) } + ) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + style = MaterialTheme.typography.titleLarge, + text = stringResource(id = R.string.coverage_layer), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + + TextField( + modifier = Modifier.fillMaxWidth(), + value = tileJsonUrl.value ?: "", + onValueChange = { newTileJsonUrl -> + tileJsonUrl.value = newTileJsonUrl + }, + label = { Text(text = stringResource(id = R.string.coverage_layer_tile_json_url)) }, + singleLine = true + ) + + if (!tileJsonUrl.value.isValidUrl) { + Warning(R.string.no_valid_url_warning) + } + + if (tileJsonUrl.value.isUnencryptedUrl) { + Warning(R.string.unencrypted_endpoint_warning) + } + + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + showSuggestedServicesDialog.value = true + } + ) { + Text( + text = stringResource(id = R.string.suggested_services_title) + ) + } + + Row { + Spacer(modifier = Modifier.weight(1.0f)) + + TextButton( + onClick = { onDialogClose(tileJsonUrl.value) }, + enabled = tileJsonUrl.value?.isValidUrl ?: false || tileJsonUrl.value?.isEmpty() ?: false + ) { + Text(text = stringResource(id = R.string.save)) + } + } + } + } + } +} + +private val String?.isValidUrl: Boolean + get() = Patterns.WEB_URL.matcher(this ?: "").matches() + +private val String?.isUnencryptedUrl: Boolean + get() = this?.startsWith("http:") ?: false diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/UnencryptedEndpointWarning.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/Warning.kt similarity index 90% rename from app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/UnencryptedEndpointWarning.kt rename to app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/Warning.kt index ad534ad6..ab0d7317 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/UnencryptedEndpointWarning.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/Warning.kt @@ -1,4 +1,4 @@ -package xyz.malkki.neostumbler.ui.composables.settings.geosubmit +package xyz.malkki.neostumbler.ui.composables.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.sp import xyz.malkki.neostumbler.R @Composable -fun UnencryptedEndpointWarning() { +fun Warning(stringResource: Int) { Row( modifier = Modifier .wrapContentSize() @@ -38,7 +38,7 @@ fun UnencryptedEndpointWarning() { Text(modifier = Modifier .fillMaxWidth() .wrapContentHeight(), - text = stringResource(id = R.string.unencrypted_endpoint_warning), + text = stringResource(id = stringResource), style = MaterialTheme.typography.labelSmall.copy(fontSize = 14.sp), color = MaterialTheme.colorScheme.onErrorContainer ) diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/GeosubmitEndpointSettings.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/GeosubmitEndpointSettings.kt index 56033fd5..0c3eb869 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/GeosubmitEndpointSettings.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/GeosubmitEndpointSettings.kt @@ -38,6 +38,7 @@ import xyz.malkki.neostumbler.StumblerApplication import xyz.malkki.neostumbler.constants.PreferenceKeys import xyz.malkki.neostumbler.geosubmit.GeosubmitParams import xyz.malkki.neostumbler.ui.composables.settings.SettingsItem +import xyz.malkki.neostumbler.ui.composables.settings.Warning private fun DataStore.geosubmitParams(): Flow = data .map { preferences -> @@ -161,7 +162,7 @@ private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClo ) if (endpoint.value.isUnencryptedUrl) { - UnencryptedEndpointWarning() + Warning(R.string.unencrypted_endpoint_warning) } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt index 13856c34..d3925370 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/MapScreen.kt @@ -3,6 +3,8 @@ package xyz.malkki.neostumbler.ui.screens import android.Manifest import android.annotation.SuppressLint import android.content.Context +import android.os.Handler +import android.os.Looper import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -65,15 +67,23 @@ import org.maplibre.android.maps.Style import org.maplibre.android.module.http.HttpRequestUtil import org.maplibre.android.plugins.annotation.FillManager import org.maplibre.android.plugins.annotation.FillOptions +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.sources.VectorSource import xyz.malkki.neostumbler.R import xyz.malkki.neostumbler.extensions.checkMissingPermissions import xyz.malkki.neostumbler.ui.composables.KeepScreenOn import xyz.malkki.neostumbler.ui.composables.PermissionsDialog import xyz.malkki.neostumbler.ui.viewmodel.MapViewModel import xyz.malkki.neostumbler.ui.viewmodel.MapViewModel.MapTileSource +import xyz.malkki.neostumbler.utils.getTileJsonLayerIds private val HEAT_LOW = ColorUtils.setAlphaComponent(0xd278ff, 120) private val HEAT_HIGH = ColorUtils.setAlphaComponent(0xaa00ff, 120) +private val COVERAGE_SOURCE_ID = "coverage-source" +private val COVERAGE_LAYER_PREFIX = "coverage-layer-" +private val COVERAGE_COLOR = "#ff8000" +private val COVERAGE_OPACITY = 0.4f @Composable fun MapScreen(mapViewModel: MapViewModel = viewModel()) { @@ -101,6 +111,10 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { val mapStyle = mapViewModel.mapStyle.collectAsState(initial = null) + val coverageTileJsonUrl = mapViewModel.coverageTileJsonUrl.collectAsState(initial = null) + + val coverageTileJsonLayerIds = mapViewModel.coverageTileJsonLayerIds.collectAsState(initial = emptyList()) + val latestReportPosition = mapViewModel.latestReportPosition.collectAsState(initial = null) val heatMapTiles = mapViewModel.heatMapTiles.collectAsState(initial = emptyList()) @@ -197,6 +211,8 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { fillManager.value = FillManager(mapView, map, style, LocationComponentConstants.SHADOW_LAYER, null) } + + addCoverage(map, coverageTileJsonUrl.value, coverageTileJsonLayerIds.value) } mapView @@ -233,6 +249,8 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { map.setStyle(Style.Builder().fromJson(mapStyle.value!!.styleJson!!)) } } + + addCoverage(map, coverageTileJsonUrl.value, coverageTileJsonLayerIds.value) } fillManager.value?.let { @@ -284,6 +302,38 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { } } +private fun addCoverageLayer(style: Style, layerIds: List) { + for (id in layerIds) { + val layer = style.getLayer(COVERAGE_LAYER_PREFIX + id) + if (layer == null) { + style.addLayer( + FillLayer(COVERAGE_LAYER_PREFIX + id, COVERAGE_SOURCE_ID).apply { + withProperties( + PropertyFactory.fillColor(COVERAGE_COLOR), + PropertyFactory.fillOpacity(COVERAGE_OPACITY) + ) + setSourceLayer(id) + } + ) + } else { + (layer as? FillLayer)?.setSourceLayer(id) + } + } +} + +private fun addCoverage(mapLibreMap: MapLibreMap, tileJsonUrl: String?, layerIds: List) { + if (tileJsonUrl != null) { + mapLibreMap.getStyle { style -> + val vectorSource = style.getSource(COVERAGE_SOURCE_ID) as? VectorSource + if (vectorSource == null || vectorSource.uri != tileJsonUrl) { + style.removeSource(COVERAGE_SOURCE_ID) + style.addSource(VectorSource(COVERAGE_SOURCE_ID, tileJsonUrl)) + } + addCoverageLayer(style, layerIds) + } + } +} + private fun createHeatMapFill(tiles: Collection): List { return tiles.map { tile -> val color = ColorUtils.blendARGB(HEAT_LOW, HEAT_HIGH, tile.heatPct) diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/settings/SettingsScreen.kt index 9603b195..31992fee 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/screens/settings/SettingsScreen.kt @@ -23,6 +23,7 @@ import xyz.malkki.neostumbler.ui.composables.SettingsToggle import xyz.malkki.neostumbler.ui.composables.TroubleshootingSettingsItem import xyz.malkki.neostumbler.ui.composables.settings.AutoScanToggle import xyz.malkki.neostumbler.ui.composables.settings.AutoUploadToggle +import xyz.malkki.neostumbler.ui.composables.settings.CoverageLayerSettings import xyz.malkki.neostumbler.ui.composables.settings.DbPruneSettings import xyz.malkki.neostumbler.ui.composables.settings.FusedLocationToggle import xyz.malkki.neostumbler.ui.composables.settings.IgnoreScanThrottlingToggle @@ -48,6 +49,7 @@ fun SettingsScreen() { title = stringResource(id = R.string.settings_group_reports) ) { GeosubmitEndpointSettings() + CoverageLayerSettings() AutoUploadToggle() DbPruneSettings() } diff --git a/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt index 0021cf81..752c1b40 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt @@ -36,6 +36,7 @@ import xyz.malkki.neostumbler.extensions.checkMissingPermissions import xyz.malkki.neostumbler.extensions.get import xyz.malkki.neostumbler.extensions.parallelMap import xyz.malkki.neostumbler.location.LocationSourceProvider +import xyz.malkki.neostumbler.utils.getTileJsonLayerIds import kotlin.math.abs import kotlin.math.floor import kotlin.time.Duration.Companion.seconds @@ -72,6 +73,14 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { } .shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) + val coverageTileJsonUrl: Flow = settingsStore.data + .map { prefs -> + prefs.get(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON_URL)) + } + + private val _coverageTileJsonLayerIds = MutableStateFlow>(emptyList()) + val coverageTileJsonLayerIds: StateFlow> = _coverageTileJsonLayerIds + private val showMyLocation = MutableStateFlow(getApplication().checkMissingPermissions(Manifest.permission.ACCESS_COARSE_LOCATION).isEmpty()) private val _mapCenter = MutableStateFlow(LatLng.ORIGIN) @@ -169,6 +178,11 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { val httpClient = (application as StumblerApplication).httpClientProvider.await() _httpClient.value = httpClient + coverageTileJsonUrl.collect { coverageTileJsonUrl -> + getTileJsonLayerIds(coverageTileJsonUrl, httpClient) { layerIds -> + _coverageTileJsonLayerIds.value = layerIds + } + } } } diff --git a/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt b/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt index c3d2a38c..15a0f000 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt @@ -17,7 +17,8 @@ data class SuggestedService( val website: String, val termsOfUse: String, val hostedBy: String, - val endpoint: Endpoint + val endpoint: Endpoint, + val coverageTileJsonUrl: String ) { companion object { fun getSuggestedServices(context: Context): List { diff --git a/app/src/main/java/xyz/malkki/neostumbler/utils/tileJson.kt b/app/src/main/java/xyz/malkki/neostumbler/utils/tileJson.kt new file mode 100644 index 00000000..5fd3fa7d --- /dev/null +++ b/app/src/main/java/xyz/malkki/neostumbler/utils/tileJson.kt @@ -0,0 +1,44 @@ +package xyz.malkki.neostumbler.utils + +import java.io.IOException +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import timber.log.Timber + +fun getTileJsonLayerIds(tileJsonUrl: String?, httpClient: Call.Factory, callback: (List) -> Unit) { + val layerIds = mutableListOf() + if (tileJsonUrl != null) { + httpClient.newCall(Request.Builder().url(tileJsonUrl).build()).enqueue(object : Callback { + override fun onFailure(call: Call, error: IOException) { + Timber.e(error, "TileJSON request failed") + callback(layerIds) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (response.isSuccessful) { + response.body?.string()?.let { jsonString -> + runCatching { + JSONObject(jsonString).optJSONArray("vector_layers") + }.onSuccess { vectorLayers -> + vectorLayers?.let { + for (i in 0 until it.length()) { + layerIds.add(it.getJSONObject(i).getString("id")) + } + } + }.onFailure { error -> + Timber.e(error, "TileJSON parser failed") + } + } + } + } + callback(layerIds) + } + }) + } else { + callback(layerIds) + } +} diff --git a/app/src/main/res/raw/suggested_services.json b/app/src/main/res/raw/suggested_services.json index 2d5549db..632ea15a 100644 --- a/app/src/main/res/raw/suggested_services.json +++ b/app/src/main/res/raw/suggested_services.json @@ -8,6 +8,7 @@ "endpoint": { "baseUrl": "https://api.beacondb.net", "path": "/v2/geosubmit" - } + }, + "coverageTileJsonUrl": "https://cdn.beacondb.net/tiles/beacondb.json" } -] \ No newline at end of file +] diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b40c6497..498dce26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,6 +129,14 @@ No endpoint configured + Coverage layer on map + + TileJSON URL for coverage map (optional) + + No configured TileJSON URL for coverage map" + + No valid URL + Unencrypted endpoint Suggested services