diff --git a/app/src/main/java/xyz/malkki/neostumbler/CoverageParams.kt b/app/src/main/java/xyz/malkki/neostumbler/CoverageParams.kt new file mode 100644 index 00000000..4bbf56bf --- /dev/null +++ b/app/src/main/java/xyz/malkki/neostumbler/CoverageParams.kt @@ -0,0 +1,6 @@ +package xyz.malkki.neostumbler + +data class CoverageParams( + val tileJson: String?, + val layerId: String?, +) 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..4005ed45 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,9 @@ object PreferenceKeys { const val GEOSUBMIT_PATH = "geosubmit_path" const val GEOSUBMIT_API_KEY = "geosubmit_api_key" + const val COVERAGE_TILE_JSON = "coverage_tile_json" + const val COVERAGE_LAYER_ID = "coverage_layer_id" + 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/settings/geosubmit/GeosubmitEndpointSettings.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/composables/settings/geosubmit/GeosubmitEndpointSettings.kt index 46f4d7e5..0e873f1c 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 @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import xyz.malkki.neostumbler.CoverageParams import xyz.malkki.neostumbler.R import xyz.malkki.neostumbler.StumblerApplication import xyz.malkki.neostumbler.constants.PreferenceKeys @@ -49,6 +50,15 @@ private fun DataStore.geosubmitParams(): Flow = da } .distinctUntilChanged() +private fun DataStore.coverageParams(): Flow = data + .map { preferences -> + val tileJson = preferences[stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON)] + val layerId = preferences[stringPreferencesKey(PreferenceKeys.COVERAGE_LAYER_ID)] + + CoverageParams(tileJson, layerId) + } + .distinctUntilChanged() + @Composable fun GeosubmitEndpointSettings() { val context = LocalContext.current @@ -56,32 +66,47 @@ fun GeosubmitEndpointSettings() { val coroutineScope = rememberCoroutineScope() val settingsStore = (context.applicationContext as StumblerApplication).settingsStore - val params = settingsStore.geosubmitParams().collectAsState(initial = null) + val geosubmitParams = settingsStore.geosubmitParams().collectAsState(initial = null) + val coverageParams = settingsStore.coverageParams().collectAsState(initial = null) val dialogOpen = rememberSaveable { mutableStateOf(false) } if (dialogOpen.value) { GeosubmitEndpointDialog( - currentParams = params.value, - onDialogClose = { newParams -> - if (newParams != null) { - coroutineScope.launch { + currentGeosubmitParams = geosubmitParams.value, + currentCoverageParams = coverageParams.value, + onDialogClose = { newGeosubmitParams, newCoverageParams -> + coroutineScope.launch { + if (newGeosubmitParams != null) { settingsStore.updateData { prefs -> prefs.toMutablePreferences().apply { - set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_ENDPOINT), newParams.baseUrl) - set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_PATH), newParams.path) + set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_ENDPOINT), newGeosubmitParams.baseUrl) + set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_PATH), newGeosubmitParams.path) - if (newParams.apiKey != null) { - set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_API_KEY), newParams.apiKey) + if (newGeosubmitParams.apiKey != null) { + set(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_API_KEY), newGeosubmitParams.apiKey) } else { remove(stringPreferencesKey(PreferenceKeys.GEOSUBMIT_API_KEY)) } } } - - dialogOpen.value = false } - } else { + if (newCoverageParams != null) { + settingsStore.updateData { prefs -> + prefs.toMutablePreferences().apply { + if (newCoverageParams.tileJson != null) { + set(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON), newCoverageParams.tileJson) + } else { + remove(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON)) + } + if (newCoverageParams.layerId != null) { + set(stringPreferencesKey(PreferenceKeys.COVERAGE_LAYER_ID), newCoverageParams.layerId) + } else { + remove(stringPreferencesKey(PreferenceKeys.COVERAGE_LAYER_ID)) + } + } + } + } dialogOpen.value = false } } @@ -90,7 +115,7 @@ fun GeosubmitEndpointSettings() { SettingsItem( title = stringResource(R.string.endpoint), - description = params.value?.baseUrl ?: "", + description = geosubmitParams.value?.baseUrl ?: "", onClick = { dialogOpen.value = true } @@ -98,15 +123,21 @@ fun GeosubmitEndpointSettings() { } @Composable -private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClose: (GeosubmitParams?) -> Unit) { +private fun GeosubmitEndpointDialog(currentGeosubmitParams: GeosubmitParams?, currentCoverageParams: CoverageParams?, onDialogClose: (GeosubmitParams?, CoverageParams?) -> Unit) { val endpoint = rememberSaveable { - mutableStateOf(currentParams?.baseUrl) + mutableStateOf(currentGeosubmitParams?.baseUrl) } val path = rememberSaveable { - mutableStateOf(currentParams?.path) + mutableStateOf(currentGeosubmitParams?.path) } val apiKey = rememberSaveable { - mutableStateOf(currentParams?.apiKey) + mutableStateOf(currentGeosubmitParams?.apiKey) + } + val tileJson = rememberSaveable { + mutableStateOf(currentCoverageParams?.tileJson) + } + val layerId = rememberSaveable { + mutableStateOf(currentCoverageParams?.layerId) } val showSuggestedServicesDialog = rememberSaveable { @@ -120,6 +151,8 @@ private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClo endpoint.value = service.endpoint.baseUrl path.value = service.endpoint.path apiKey.value = service.endpoint.apiKey + tileJson.value = service.coverage.tileJson + layerId.value = service.coverage.layerId } showSuggestedServicesDialog.value = false @@ -128,7 +161,7 @@ private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClo } BasicAlertDialog( - onDismissRequest = { onDialogClose(null) } + onDismissRequest = { onDialogClose(null, null) } ) { Surface( modifier = Modifier @@ -186,6 +219,34 @@ private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClo Spacer(modifier = Modifier.height(24.dp)) + TextField( + modifier = Modifier.fillMaxWidth(), + value = tileJson.value ?: "", + onValueChange = { newTileJson -> + tileJson.value = newTileJson + }, + label = { Text(text = stringResource(id = R.string.tile_json)) }, + singleLine = true + ) + + if (tileJson.value.isUnencryptedUrl) { + UnencryptedEndpointWarning() + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = layerId.value ?: "", + onValueChange = { newLayerId -> + layerId.value = newLayerId + }, + label = { Text(text = stringResource(id = R.string.layer_id)) }, + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + Button( modifier = Modifier.align(Alignment.CenterHorizontally), onClick = { @@ -201,7 +262,10 @@ private fun GeosubmitEndpointDialog(currentParams: GeosubmitParams?, onDialogClo Spacer(modifier = Modifier.weight(1.0f)) TextButton( - onClick = { onDialogClose(GeosubmitParams(endpoint.value!!, path.value!!, apiKey.value)) }, + onClick = { onDialogClose( + GeosubmitParams(endpoint.value!!, path.value!!, apiKey.value), + CoverageParams(tileJson.value, layerId.value) + )}, enabled = !endpoint.value.isNullOrBlank() && !path.value.isNullOrBlank() ) { Text(text = stringResource(id = R.string.save)) 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 43214a4e..eb075c1b 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 @@ -29,6 +29,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,6 +62,10 @@ 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.CoverageParams import xyz.malkki.neostumbler.R import xyz.malkki.neostumbler.extensions.checkMissingPermissions import xyz.malkki.neostumbler.ui.composables.KeepScreenOn @@ -70,9 +75,12 @@ import xyz.malkki.neostumbler.ui.viewmodel.MapViewModel.MapTileSource private val HEAT_LOW = ColorUtils.setAlphaComponent(0xd278ff, 120) private val HEAT_HIGH = ColorUtils.setAlphaComponent(0xaa00ff, 120) +private val COVERAGE_COLOR = "#20bbc9" +private val COVERAGE_OPACITY = 0.4f @Composable fun MapScreen(mapViewModel: MapViewModel = viewModel()) { + val context = LocalContext.current val lifecycle = LocalLifecycleOwner.current.lifecycle @@ -97,6 +105,12 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { val mapStyle = mapViewModel.mapStyle.collectAsState(initial = null) + val tileJson = mapViewModel.tileJson.collectAsState(initial = null) + + val layerId = mapViewModel.layerId.collectAsState(initial = null) + + val getCoverageParams: () -> CoverageParams = { CoverageParams(tileJson.value, layerId.value) } + val latestReportPosition = mapViewModel.latestReportPosition.collectAsState(initial = null) val heatMapTiles = mapViewModel.heatMapTiles.collectAsState(initial = emptyList()) @@ -171,15 +185,7 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { } }) - val styleBuilder = Style.Builder().apply { - if (mapStyle.value!!.styleJson != null) { - fromJson(mapStyle.value!!.styleJson!!) - } else { - fromUri(mapStyle.value!!.styleUrl!!) - } - } - - map.setStyle(styleBuilder) { style -> + map.setStyle(styleBuilder(mapStyle, getCoverageParams())) { style -> map.locationComponent.activateLocationComponent( LocationComponentActivationOptions.builder(context, style) //Set location engine to null, because we provide locations by ourself @@ -225,12 +231,15 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { //Ugly, but we don't want to update the map style unless it has actually changed //TODO: think about a better way to do this - if (map.style != null) { - if (mapStyle.value!!.styleUrl != null && map.style!!.uri != mapStyle.value!!.styleUrl) { - map.setStyle(Style.Builder().fromUri(mapStyle.value!!.styleUrl!!)) - } else if (mapStyle.value!!.styleJson != null && map.style!!.json != mapStyle.value!!.styleJson) { - map.setStyle(Style.Builder().fromJson(mapStyle.value!!.styleJson!!)) - } + if ( + map.style != null + && ( + mapStyle.value!!.styleUrl != null && map.style!!.uri != mapStyle.value!!.styleUrl + || + mapStyle.value!!.styleJson != null && map.style!!.json != mapStyle.value!!.styleJson + ) + ) { + map.setStyle(styleBuilder(mapStyle, getCoverageParams())) } } @@ -279,6 +288,36 @@ fun MapScreen(mapViewModel: MapViewModel = viewModel()) { } } +private fun styleBuilder(mapStyle: State, coverageParams: CoverageParams): Style.Builder { + val builder = Style.Builder().apply { + if (mapStyle.value!!.styleJson != null) { + fromJson(mapStyle.value!!.styleJson!!) + } else { + fromUri(mapStyle.value!!.styleUrl!!) + } + } + + if ( coverageParams.tileJson != null ) { + val layer = FillLayer("coverage-layer", "coverage-source") + .withProperties( + PropertyFactory.fillColor(COVERAGE_COLOR), + PropertyFactory.fillOpacity(COVERAGE_OPACITY) + ) + if ( coverageParams.layerId != null ) { + layer.setSourceLayer(coverageParams.layerId) + } + builder.withSource( + VectorSource( + "coverage-source", + coverageParams.tileJson + ) + ) + .withLayer(layer) + } + + return builder +} + 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/viewmodel/MapViewModel.kt b/app/src/main/java/xyz/malkki/neostumbler/ui/viewmodel/MapViewModel.kt index 29568cf9..8a17c0a7 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 @@ -72,6 +72,16 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { } .shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) + val tileJson: Flow = settingsStore.data + .map { prefs -> + prefs.get(stringPreferencesKey(PreferenceKeys.COVERAGE_TILE_JSON)) + } + + val layerId: Flow = settingsStore.data + .map { prefs -> + prefs.get(stringPreferencesKey(PreferenceKeys.COVERAGE_LAYER_ID)) + } + private val showMyLocation = MutableStateFlow(getApplication().checkMissingPermissions(Manifest.permission.ACCESS_COARSE_LOCATION).isEmpty()) private val _mapCenter = MutableStateFlow(LatLng(0.0, 0.0)) 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 288f03a5..cea1f704 100644 --- a/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt +++ b/app/src/main/java/xyz/malkki/neostumbler/utils/SuggestedService.kt @@ -13,7 +13,8 @@ data class SuggestedService( val website: String, val termsOfUse: String, val hostedBy: String, - val endpoint: Endpoint + val endpoint: Endpoint, + val coverage: Coverage ) { companion object { fun getSuggestedServices(context: Context): List { @@ -31,4 +32,10 @@ data class SuggestedService( val path: String, val apiKey: String? = null ) -} \ No newline at end of file + + @Serializable + data class Coverage( + val tileJson: String? = null, + val layerId: String? = null + ) +} diff --git a/app/src/main/res/raw/suggested_services.json b/app/src/main/res/raw/suggested_services.json index 26f09756..11d3669c 100644 --- a/app/src/main/res/raw/suggested_services.json +++ b/app/src/main/res/raw/suggested_services.json @@ -7,6 +7,10 @@ "endpoint": { "baseUrl": "https://api.beacondb.net", "path": "/v2/geosubmit" + }, + "coverage": { + "tileJson": "https://cdn.beacondb.net/tiles/beacondb.json", + "layerId": "beacondb" } } ] \ 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 83f6a594..28661cab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,6 +122,9 @@ Path API key (optional) + TileJSON for coverage map (optional) + Layer ID of TileJSON for coverage map (optional) + Unencrypted endpoint Suggested services