diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a89b6c4be..920adb325 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -246,6 +246,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) googleImplementation(libs.location.services) + googleImplementation(libs.play.services.maps) fdroidImplementation(libs.osmdroid.android) fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } diff --git a/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt new file mode 100644 index 000000000..8ae95519c --- /dev/null +++ b/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package com.geeksville.mesh + +import android.content.Context + +@Suppress("UNUSED_PARAMETER") +fun initializeMaps(context: Context) { + // No-op for F-Droid +} diff --git a/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt new file mode 100644 index 000000000..5ae9b3963 --- /dev/null +++ b/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package com.geeksville.mesh + +import android.content.Context +import com.google.android.gms.maps.MapsInitializer + +fun initializeMaps(context: Context) { + MapsInitializer.initialize(context) +} diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index e64e1ad94..e6a744cdd 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -28,7 +28,13 @@ import timber.log.Timber * application components, including analytics and platform-specific helpers, and manages analytics consent based on * user preferences. */ -@HiltAndroidApp class MeshUtilApplication : Application() +@HiltAndroidApp +class MeshUtilApplication : Application() { + override fun onCreate() { + super.onCreate() + initializeMaps(this) + } +} fun logAssert(executeReliableWrite: Boolean) { if (!executeReliableWrite) { diff --git a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt index 725f2b59f..c749eba1c 100644 --- a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt +++ b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt @@ -19,6 +19,8 @@ package org.meshtastic.core.prefs.map import android.content.SharedPreferences import com.google.maps.android.compose.MapType +import org.meshtastic.core.prefs.DoublePrefDelegate +import org.meshtastic.core.prefs.FloatPrefDelegate import org.meshtastic.core.prefs.NullableStringPrefDelegate import org.meshtastic.core.prefs.StringSetPrefDelegate import org.meshtastic.core.prefs.di.GoogleMapsSharedPreferences @@ -30,6 +32,11 @@ interface GoogleMapsPrefs { var selectedGoogleMapType: String? var selectedCustomTileUrl: String? var hiddenLayerUrls: Set + var cameraTargetLat: Double + var cameraTargetLng: Double + var cameraZoom: Float + var cameraTilt: Float + var cameraBearing: Float } @Singleton @@ -38,4 +45,9 @@ class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs NullableStringPrefDelegate(prefs, "selected_google_map_type", MapType.NORMAL.name) override var selectedCustomTileUrl: String? by NullableStringPrefDelegate(prefs, "selected_custom_tile_url", null) override var hiddenLayerUrls: Set by StringSetPrefDelegate(prefs, "hidden_layer_urls", emptySet()) + override var cameraTargetLat: Double by DoublePrefDelegate(prefs, "camera_target_lat", 0.0) + override var cameraTargetLng: Double by DoublePrefDelegate(prefs, "camera_target_lng", 0.0) + override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f) + override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f) + override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f) } diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt new file mode 100644 index 000000000..0ecbb818e --- /dev/null +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt @@ -0,0 +1,39 @@ +/* + * 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 . + */ + +package org.meshtastic.core.prefs + +import android.content.SharedPreferences +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class DoublePrefDelegate( + private val preferences: SharedPreferences, + private val key: String, + private val defaultValue: Double, +) : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): Double = preferences + .getFloat(key, defaultValue.toFloat()) + .toDouble() // SharedPreferences doesn't have putDouble, so convert to float + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: Double) { + preferences + .edit() + .putFloat(key, value.toFloat()) + .apply() // SharedPreferences doesn't have putDouble, so convert to float + } +} diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt new file mode 100644 index 000000000..a2b12fcce --- /dev/null +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt @@ -0,0 +1,34 @@ +/* + * 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 . + */ + +package org.meshtastic.core.prefs + +import android.content.SharedPreferences +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class FloatPrefDelegate( + private val preferences: SharedPreferences, + private val key: String, + private val defaultValue: Float, +) : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): Float = preferences.getFloat(key, defaultValue) + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: Float) { + preferences.edit().putFloat(key, value).apply() + } +} diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 22b5444df..6b84a7e2f 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -86,7 +86,6 @@ import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.MarkerInfoWindowComposable import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.TileOverlay -import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.google.maps.android.compose.widgets.ScaleBar import kotlinx.coroutines.flow.map @@ -162,15 +161,13 @@ fun MapView( var mapTypeMenuExpanded by remember { mutableStateOf(false) } var showCustomTileManagerSheet by remember { mutableStateOf(false) } - val cameraPositionState = rememberCameraPositionState { - position = - CameraPosition.fromLatLngZoom( - LatLng( - ourNodeInfo?.position?.latitudeI?.times(DEG_D) ?: 0.0, - ourNodeInfo?.position?.longitudeI?.times(DEG_D) ?: 0.0, - ), - 7f, - ) + val cameraPositionState = mapViewModel.cameraPositionState + + // Save camera position when it stops moving + LaunchedEffect(cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + mapViewModel.saveCameraPosition(cameraPositionState.position) + } } // Location tracking functionality @@ -221,6 +218,7 @@ fun MapView( .build() try { + @Suppress("MissingPermission") fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null) Timber.d("Started location tracking") } catch (e: SecurityException) { @@ -351,31 +349,6 @@ fun MapView( editingWaypoint = newWaypoint } }, - onMapLoaded = { - val pointsToBound: List = - when { - !nodeTracks.isNullOrEmpty() -> nodeTracks.map { it.toLatLng() } - - allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() -> - allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() } - - else -> emptyList() - } - - if (pointsToBound.isNotEmpty()) { - val bounds = LatLngBounds.builder().apply { pointsToBound.forEach(::include) }.build() - - val padding = if (!pointsToBound.isEmpty()) 100 else 48 - - try { - coroutineScope.launch { - cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding)) - } - } catch (e: IllegalStateException) { - Timber.w("MapView Could not animate to bounds: ${e.message}") - } - } - }, ) { key(currentCustomTileProviderUrl) { currentCustomTileProviderUrl?.let { url -> @@ -706,7 +679,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S return speedText } -private fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) +internal fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) private fun Node.toLatLng(): LatLng? = this.position.toLatLng() diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 088bfb874..f08a2c0d9 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -22,8 +22,11 @@ import android.net.Uri import androidx.core.net.toFile import androidx.lifecycle.viewModelScope import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.TileProvider import com.google.android.gms.maps.model.UrlTileProvider +import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.MapType import com.google.maps.android.data.geojson.GeoJsonLayer import com.google.maps.android.data.kml.KmlLayer @@ -90,6 +93,24 @@ constructor( uiPreferencesDataSource: UiPreferencesDataSource, ) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) { + private val targetLatLng = + googleMapsPrefs.cameraTargetLat + .takeIf { it != 0.0 } + ?.let { lat -> googleMapsPrefs.cameraTargetLng.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } } + ?: ourNodeInfo.value?.position?.toLatLng() + ?: LatLng(0.0, 0.0) + + val cameraPositionState = + CameraPositionState( + position = + CameraPosition( + targetLatLng, + googleMapsPrefs.cameraZoom, + googleMapsPrefs.cameraTilt, + googleMapsPrefs.cameraBearing, + ), + ) + val theme: StateFlow = uiPreferencesDataSource.theme private val _errorFlow = MutableSharedFlow() @@ -238,6 +259,16 @@ constructor( loadPersistedLayers() } + fun saveCameraPosition(cameraPosition: CameraPosition) { + viewModelScope.launch { + googleMapsPrefs.cameraTargetLat = cameraPosition.target.latitude + googleMapsPrefs.cameraTargetLng = cameraPosition.target.longitude + googleMapsPrefs.cameraZoom = cameraPosition.zoom + googleMapsPrefs.cameraTilt = cameraPosition.tilt + googleMapsPrefs.cameraBearing = cameraPosition.bearing + } + } + private fun loadPersistedMapType() { val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl if (savedCustomUrl != null) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1030f63a8..08c483538 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,6 +93,7 @@ location-services = { module = "com.google.android.gms:play-services-location", maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } +play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "19.0.0" } protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } zxing-core = { module = "com.google.zxing:core", version = "3.5.3" }