mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Migrate project to Kotlin Multiplatform (KMP) architecture (#4738)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
182ad933f4
commit
0ce322a0f5
163 changed files with 1837 additions and 877 deletions
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.app.map.cluster;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Point;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.meshtastic.app.map.model.MarkerWithLabel;
|
||||
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.views.MapView;
|
||||
import org.osmdroid.views.overlay.Overlay;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.ListIterator;
|
||||
|
||||
/**
|
||||
* An overlay allowing to perform markers clustering.
|
||||
* Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays.
|
||||
* Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker. <br/>
|
||||
*
|
||||
* This abstract class provides the framework. Sub-classes have to implement the clustering algorithm,
|
||||
* and the rendering of a cluster.
|
||||
*
|
||||
* @author M.Kergall
|
||||
*
|
||||
*/
|
||||
public abstract class MarkerClusterer extends Overlay {
|
||||
|
||||
/** impossible value for zoom level, to force clustering */
|
||||
protected static final int FORCE_CLUSTERING = -1;
|
||||
|
||||
protected ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected Point mPoint = new Point();
|
||||
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
|
||||
protected int mLastZoomLevel;
|
||||
protected Bitmap mClusterIcon;
|
||||
protected String mName, mDescription;
|
||||
|
||||
// abstract methods:
|
||||
|
||||
/** clustering algorithm */
|
||||
public abstract ArrayList<StaticCluster> clusterer(MapView mapView);
|
||||
/** Build the marker for a cluster. */
|
||||
public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView);
|
||||
/** build clusters markers to be used at next draw */
|
||||
public abstract void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView);
|
||||
|
||||
public MarkerClusterer() {
|
||||
super();
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
public void setName(String name){
|
||||
mName = name;
|
||||
}
|
||||
|
||||
public String getName(){
|
||||
return mName;
|
||||
}
|
||||
|
||||
public void setDescription(String description){
|
||||
mDescription = description;
|
||||
}
|
||||
|
||||
public String getDescription(){
|
||||
return mDescription;
|
||||
}
|
||||
|
||||
/** Set the cluster icon to be drawn when a cluster contains more than 1 marker.
|
||||
* If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */
|
||||
public void setIcon(Bitmap icon){
|
||||
mClusterIcon = icon;
|
||||
}
|
||||
|
||||
/** Add the Marker.
|
||||
* Important: Markers added in a MarkerClusterer should not be added in the map overlays. */
|
||||
public void add(MarkerWithLabel marker){
|
||||
mItems.add(marker);
|
||||
}
|
||||
|
||||
/** Force a rebuild of clusters at next draw, even without a zooming action.
|
||||
* Should be done when you changed the content of a MarkerClusterer. */
|
||||
public void invalidate(){
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
/** @return the Marker at id (starting at 0) */
|
||||
public MarkerWithLabel getItem(int id){
|
||||
return mItems.get(id);
|
||||
}
|
||||
|
||||
/** @return the list of Markers. */
|
||||
public ArrayList<MarkerWithLabel> getItems(){
|
||||
return mItems;
|
||||
}
|
||||
|
||||
protected void hideInfoWindows(){
|
||||
for (MarkerWithLabel m : mItems){
|
||||
if (m.isInfoWindowShown())
|
||||
m.closeInfoWindow();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void draw(Canvas canvas, MapView mapView, boolean shadow) {
|
||||
if (shadow)
|
||||
return;
|
||||
//if zoom has changed and mapView is now stable, rebuild clusters:
|
||||
int zoomLevel = mapView.getZoomLevel();
|
||||
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
|
||||
hideInfoWindows();
|
||||
mClusters = clusterer(mapView);
|
||||
renderer(mClusters, canvas, mapView);
|
||||
mLastZoomLevel = zoomLevel;
|
||||
}
|
||||
|
||||
for (StaticCluster cluster:mClusters){
|
||||
MarkerWithLabel marker = cluster.getMarker();
|
||||
marker.draw(canvas, mapView, false);
|
||||
}
|
||||
}
|
||||
|
||||
public Iterable<StaticCluster> reversedClusters() {
|
||||
return new Iterable<StaticCluster>() {
|
||||
@Override
|
||||
public Iterator<StaticCluster> iterator() {
|
||||
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
|
||||
return new Iterator<StaticCluster>() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return i.hasPrevious();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StaticCluster next() {
|
||||
return i.previous();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove() {
|
||||
i.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onLongPress(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onLongPress(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) {
|
||||
for (StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onTouchEvent(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onDoubleTap(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public BoundingBox getBounds(){
|
||||
if (mItems.size() == 0)
|
||||
return null;
|
||||
double minLat = Double.MAX_VALUE;
|
||||
double minLon = Double.MAX_VALUE;
|
||||
double maxLat = -Double.MAX_VALUE;
|
||||
double maxLon = -Double.MAX_VALUE;
|
||||
for (final MarkerWithLabel item : mItems) {
|
||||
final double latitude = item.getPosition().getLatitude();
|
||||
final double longitude = item.getPosition().getLongitude();
|
||||
minLat = Math.min(minLat, latitude);
|
||||
minLon = Math.min(minLon, longitude);
|
||||
maxLat = Math.max(maxLat, latitude);
|
||||
maxLon = Math.max(maxLon, longitude);
|
||||
}
|
||||
return new BoundingBox(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.app.map.cluster;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.meshtastic.app.map.model.MarkerWithLabel;
|
||||
|
||||
import org.osmdroid.bonuspack.R;
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.views.MapView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Radius-based Clustering algorithm:
|
||||
* create a cluster using the first point from the cloned list.
|
||||
* All points that are found within the neighborhood are added to this cluster.
|
||||
* Then all the neighbors and the main point are removed from the list of points.
|
||||
* It continues until the list is empty.
|
||||
*
|
||||
* Largely inspired from GridMarkerClusterer by M.Kergall
|
||||
*
|
||||
* @author sidorovroman92@gmail.com
|
||||
*/
|
||||
|
||||
public class RadiusMarkerClusterer extends MarkerClusterer {
|
||||
|
||||
protected int mMaxClusteringZoomLevel = 7;
|
||||
protected int mRadiusInPixels = 100;
|
||||
protected double mRadiusInMeters;
|
||||
protected Paint mTextPaint;
|
||||
private ArrayList<MarkerWithLabel> mClonedMarkers;
|
||||
protected boolean mAnimated;
|
||||
int mDensityDpi;
|
||||
|
||||
/** cluster icon anchor */
|
||||
public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
/** anchor point to draw the number of markers inside the cluster icon */
|
||||
public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
|
||||
public RadiusMarkerClusterer(Context ctx) {
|
||||
super();
|
||||
mTextPaint = new Paint();
|
||||
mTextPaint.setColor(Color.WHITE);
|
||||
mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density);
|
||||
mTextPaint.setFakeBoldText(true);
|
||||
mTextPaint.setTextAlign(Paint.Align.CENTER);
|
||||
mTextPaint.setAntiAlias(true);
|
||||
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
|
||||
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
|
||||
setIcon(clusterIcon);
|
||||
mAnimated = true;
|
||||
mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi;
|
||||
}
|
||||
|
||||
/** If you want to change the default text paint (color, size, font) */
|
||||
public Paint getTextPaint(){
|
||||
return mTextPaint;
|
||||
}
|
||||
|
||||
/** Set the radius of clustering in pixels. Default is 100px. */
|
||||
public void setRadius(int radius){
|
||||
mRadiusInPixels = radius;
|
||||
}
|
||||
|
||||
/** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled.
|
||||
* You can put a high value to disable this feature. */
|
||||
public void setMaxClusteringZoomLevel(int zoom){
|
||||
mMaxClusteringZoomLevel = zoom;
|
||||
}
|
||||
|
||||
/** Radius-Based clustering algorithm */
|
||||
@Override public ArrayList<StaticCluster> clusterer(MapView mapView) {
|
||||
|
||||
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
|
||||
convertRadiusToMeters(mapView);
|
||||
|
||||
mClonedMarkers = new ArrayList<MarkerWithLabel>(mItems); //shallow copy
|
||||
while (!mClonedMarkers.isEmpty()) {
|
||||
MarkerWithLabel m = mClonedMarkers.get(0);
|
||||
StaticCluster cluster = createCluster(m, mapView);
|
||||
clusters.add(cluster);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) {
|
||||
GeoPoint clusterPosition = m.getPosition();
|
||||
|
||||
StaticCluster cluster = new StaticCluster(clusterPosition);
|
||||
cluster.add(m);
|
||||
|
||||
mClonedMarkers.remove(m);
|
||||
|
||||
if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) {
|
||||
//above max level => block clustering:
|
||||
return cluster;
|
||||
}
|
||||
|
||||
Iterator<MarkerWithLabel> it = mClonedMarkers.iterator();
|
||||
while (it.hasNext()) {
|
||||
MarkerWithLabel neighbor = it.next();
|
||||
double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition());
|
||||
if (distance <= mRadiusInMeters) {
|
||||
cluster.add(neighbor);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) {
|
||||
MarkerWithLabel m = new MarkerWithLabel(mapView, "", null);
|
||||
m.setPosition(cluster.getPosition());
|
||||
m.setInfoWindow(null);
|
||||
m.setAnchor(mAnchorU, mAnchorV);
|
||||
|
||||
Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi),
|
||||
mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig());
|
||||
Canvas iconCanvas = new Canvas(finalIcon);
|
||||
iconCanvas.drawBitmap(mClusterIcon, 0, 0, null);
|
||||
String text = "" + cluster.getSize();
|
||||
int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent());
|
||||
iconCanvas.drawText(text,
|
||||
mTextAnchorU * finalIcon.getWidth(),
|
||||
mTextAnchorV * finalIcon.getHeight() - textHeight / 2,
|
||||
mTextPaint);
|
||||
m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon));
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
@Override public void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView) {
|
||||
for (StaticCluster cluster : clusters) {
|
||||
if (cluster.getSize() == 1) {
|
||||
//cluster has only 1 marker => use it as it is:
|
||||
cluster.setMarker(cluster.getItem(0));
|
||||
} else {
|
||||
//only draw 1 Marker at Cluster center, displaying number of Markers contained
|
||||
MarkerWithLabel m = buildClusterMarker(cluster, mapView);
|
||||
cluster.setMarker(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void convertRadiusToMeters(MapView mapView) {
|
||||
|
||||
Rect mScreenRect = mapView.getIntrinsicScreenRect(null);
|
||||
|
||||
int screenWidth = mScreenRect.right - mScreenRect.left;
|
||||
int screenHeight = mScreenRect.bottom - mScreenRect.top;
|
||||
|
||||
BoundingBox bb = mapView.getBoundingBox();
|
||||
|
||||
double diagonalInMeters = bb.getDiagonalLengthInMeters();
|
||||
double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight);
|
||||
double metersInPixel = diagonalInMeters / diagonalInPixels;
|
||||
|
||||
mRadiusInMeters = mRadiusInPixels * metersInPixel;
|
||||
}
|
||||
|
||||
public void setAnimation(boolean animate){
|
||||
mAnimated = animate;
|
||||
}
|
||||
|
||||
public void zoomOnCluster(MapView mapView, StaticCluster cluster){
|
||||
BoundingBox bb = cluster.getBoundingBox();
|
||||
if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) {
|
||||
bb = bb.increaseByScale(2.3f);
|
||||
mapView.zoomToBoundingBox(bb, true);
|
||||
} else //all points exactly at the same place:
|
||||
mapView.setExpectedCenter(bb.getCenterWithDateLine());
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) {
|
||||
if (mAnimated && cluster.getSize() > 1)
|
||||
zoomOnCluster(mapView, cluster);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.app.map.cluster;
|
||||
|
||||
import org.meshtastic.app.map.model.MarkerWithLabel;
|
||||
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Cluster of Markers.
|
||||
* @author M.Kergall
|
||||
*/
|
||||
public class StaticCluster {
|
||||
protected final ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected GeoPoint mCenter;
|
||||
protected MarkerWithLabel mMarker;
|
||||
|
||||
public StaticCluster(GeoPoint center) {
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public void setPosition(GeoPoint center){
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public GeoPoint getPosition() {
|
||||
return mCenter;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return mItems.size();
|
||||
}
|
||||
|
||||
public MarkerWithLabel getItem(int index) {
|
||||
return mItems.get(index);
|
||||
}
|
||||
|
||||
public boolean add(MarkerWithLabel t) {
|
||||
return mItems.add(t);
|
||||
}
|
||||
|
||||
/** set the Marker to be displayed for this cluster */
|
||||
public void setMarker(MarkerWithLabel marker){
|
||||
mMarker = marker;
|
||||
}
|
||||
|
||||
/** @return the Marker to be displayed for this cluster */
|
||||
public MarkerWithLabel getMarker(){
|
||||
return mMarker;
|
||||
}
|
||||
|
||||
public BoundingBox getBoundingBox(){
|
||||
if (getSize()==0)
|
||||
return null;
|
||||
GeoPoint p = getItem(0).getPosition();
|
||||
BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude());
|
||||
for (int i=1; i<getSize(); i++) {
|
||||
p = getItem(i).getPosition();
|
||||
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
|
||||
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
|
||||
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
|
||||
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
|
||||
bb.set(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
return bb;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.intro
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun AnalyticsIntro() {
|
||||
// no-op for fdroid
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
class FdroidMapViewProvider : MapViewProvider {
|
||||
@Composable
|
||||
override fun MapView(
|
||||
modifier: Modifier,
|
||||
viewModel: Any,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int?,
|
||||
nodeTracks: List<Any>?,
|
||||
tracerouteOverlay: Any?,
|
||||
tracerouteNodePositions: Map<Int, Any>,
|
||||
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
|
||||
) {
|
||||
val mapViewModel: MapViewModel = hiltViewModel()
|
||||
org.meshtastic.app.map.MapView(
|
||||
modifier = modifier,
|
||||
mapViewModel = mapViewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
focusedNodeNum = focusedNodeNum,
|
||||
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
|
||||
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
|
||||
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
|
||||
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
|
||||
80
app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
Normal file
80
app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
private const val DEGREES_IN_CIRCLE = 360.0
|
||||
private const val METERS_PER_DEGREE_LATITUDE = 111320.0
|
||||
private const val ZOOM_ADJUSTMENT_FACTOR = 0.8
|
||||
|
||||
/**
|
||||
* Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
|
||||
*
|
||||
* @return The zoom level as a Double value.
|
||||
*/
|
||||
fun BoundingBox.requiredZoomLevel(): Double {
|
||||
val topLeft = GeoPoint(this.latNorth, this.lonWest)
|
||||
val bottomRight = GeoPoint(this.latSouth, this.lonEast)
|
||||
val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
|
||||
val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
|
||||
val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE))
|
||||
val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE))
|
||||
return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
|
||||
*
|
||||
* @return A new [BoundingBox] with added [zoomFactor]. Example:
|
||||
* ```
|
||||
* // Setting the zoom level directly using setZoom()
|
||||
* map.setZoom(14.0)
|
||||
* val boundingBoxZoom14 = map.boundingBox
|
||||
*
|
||||
* // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
|
||||
* val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
|
||||
* ```
|
||||
*/
|
||||
fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
|
||||
val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
|
||||
val latDiff = latNorth - latSouth
|
||||
val lonDiff = lonEast - lonWest
|
||||
|
||||
val newLatDiff = latDiff / (2.0.pow(zoomFactor))
|
||||
val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
|
||||
|
||||
return BoundingBox(
|
||||
center.latitude + newLatDiff / 2,
|
||||
center.longitude + newLonDiff / 2,
|
||||
center.latitude - newLatDiff / 2,
|
||||
center.longitude - newLonDiff / 2,
|
||||
)
|
||||
}
|
||||
|
||||
// Converts SP to pixels.
|
||||
fun Context.spToPx(sp: Float): Int =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt()
|
||||
|
||||
// Converts DP to pixels.
|
||||
fun Context.dpToPx(dp: Float): Int =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
|
||||
1186
app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
Normal file
1186
app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.DashPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.meshtastic.app.R
|
||||
import org.meshtastic.proto.Position
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.CopyrightOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.ScaleBarOverlay
|
||||
import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList
|
||||
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
|
||||
|
||||
/** Adds copyright to map depending on what source is showing */
|
||||
fun MapView.addCopyright() {
|
||||
if (overlays.none { it is CopyrightOverlay }) {
|
||||
val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return
|
||||
val copyrightOverlay = CopyrightOverlay(context)
|
||||
copyrightOverlay.setCopyrightNotice(copyrightNotice)
|
||||
overlays.add(copyrightOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LatLong Grid line overlay
|
||||
*
|
||||
* @param enabled: turn on/off gridlines
|
||||
*/
|
||||
fun MapView.createLatLongGrid(enabled: Boolean) {
|
||||
val latLongGridOverlay = LatLonGridlineOverlay2()
|
||||
latLongGridOverlay.isEnabled = enabled
|
||||
if (latLongGridOverlay.isEnabled) {
|
||||
val textPaint =
|
||||
Paint().apply {
|
||||
textSize = 40f
|
||||
color = Color.GRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
latLongGridOverlay.textPaint = textPaint
|
||||
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
|
||||
latLongGridOverlay.setLineWidth(3.0f)
|
||||
latLongGridOverlay.setLineColor(Color.GRAY)
|
||||
overlays.add(latLongGridOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.addScaleBarOverlay(density: Density) {
|
||||
if (overlays.none { it is ScaleBarOverlay }) {
|
||||
val scaleBarOverlay =
|
||||
ScaleBarOverlay(this).apply {
|
||||
setAlignBottom(true)
|
||||
with(density) {
|
||||
setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt())
|
||||
setTextSize(12.sp.toPx())
|
||||
}
|
||||
textPaint.apply {
|
||||
isAntiAlias = true
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
}
|
||||
overlays.add(scaleBarOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: () -> Unit): Polyline {
|
||||
val polyline =
|
||||
Polyline(this).apply {
|
||||
val borderPaint =
|
||||
Paint().apply {
|
||||
color = Color.BLACK
|
||||
isAntiAlias = true
|
||||
strokeWidth = with(density) { 10.dp.toPx() }
|
||||
style = Paint.Style.STROKE
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
|
||||
}
|
||||
outlinePaintLists.add(MonochromaticPaintList(borderPaint))
|
||||
val fillPaint =
|
||||
Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
strokeWidth = with(density) { 6.dp.toPx() }
|
||||
style = Paint.Style.FILL_AND_STROKE
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
|
||||
}
|
||||
outlinePaintLists.add(MonochromaticPaintList(fillPaint))
|
||||
setPoints(geoPoints)
|
||||
setOnClickListener { _, _, _ ->
|
||||
onClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
overlays.add(polyline)
|
||||
|
||||
return polyline
|
||||
}
|
||||
|
||||
fun MapView.addPositionMarkers(positions: List<Position>, onClick: () -> Unit): List<Marker> {
|
||||
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
|
||||
val markers =
|
||||
positions.map {
|
||||
Marker(this).apply {
|
||||
icon = navIcon
|
||||
rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7)
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
overlays.addAll(markers)
|
||||
|
||||
return markers
|
||||
}
|
||||
69
app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
Normal file
69
app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.toRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.navigation.MapRoutes
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@HiltViewModel
|
||||
class MapViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
mapPrefs: MapPrefs,
|
||||
packetRepository: PacketRepository,
|
||||
override val nodeRepository: NodeRepository,
|
||||
radioController: RadioController,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
buildConfigProvider: BuildConfigProvider,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
|
||||
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
|
||||
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
|
||||
|
||||
var mapStyleId: Int
|
||||
get() = mapPrefs.mapStyle.value
|
||||
set(value) {
|
||||
mapPrefs.setMapStyle(value)
|
||||
}
|
||||
|
||||
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
|
||||
|
||||
val config
|
||||
get() = localConfig.value
|
||||
|
||||
val applicationId = buildConfigProvider.applicationId
|
||||
|
||||
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
private fun PowerManager.WakeLock.safeAcquire() {
|
||||
if (!isHeld) {
|
||||
try {
|
||||
acquire()
|
||||
} catch (e: SecurityException) {
|
||||
Logger.e { "WakeLock permission exception: ${e.message}" }
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e { "WakeLock acquire() exception: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PowerManager.WakeLock.safeRelease() {
|
||||
if (isHeld) {
|
||||
try {
|
||||
release()
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e { "WakeLock release() exception: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_ZOOM_LEVEL = 1.5
|
||||
private const val MAX_ZOOM_LEVEL = 20.0
|
||||
private const val DEFAULT_ZOOM_LEVEL = 15.0
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun rememberMapViewWithLifecycle(
|
||||
applicationId: String,
|
||||
box: BoundingBox,
|
||||
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
|
||||
): MapView {
|
||||
val zoom =
|
||||
if (box.requiredZoomLevel().isFinite()) {
|
||||
(box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL)
|
||||
} else {
|
||||
DEFAULT_ZOOM_LEVEL
|
||||
}
|
||||
val center = GeoPoint(box.centerLatitude, box.centerLongitude)
|
||||
return rememberMapViewWithLifecycle(
|
||||
applicationId = applicationId,
|
||||
zoomLevel = zoom,
|
||||
mapCenter = center,
|
||||
tileSource = tileSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun rememberMapViewWithLifecycle(
|
||||
applicationId: String,
|
||||
zoomLevel: Double = MIN_ZOOM_LEVEL,
|
||||
mapCenter: GeoPoint = GeoPoint(0.0, 0.0),
|
||||
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
|
||||
): MapView {
|
||||
var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) }
|
||||
var savedCenter by
|
||||
rememberSaveable(
|
||||
stateSaver =
|
||||
Saver(
|
||||
save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) },
|
||||
restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(mapCenter)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val mapView = remember {
|
||||
MapView(context).apply {
|
||||
clipToOutline = true
|
||||
|
||||
// Required to get online tiles
|
||||
Configuration.getInstance().userAgentValue = applicationId
|
||||
setTileSource(tileSource)
|
||||
isVerticalMapRepetitionEnabled = false // disables map repetition
|
||||
setMultiTouchControls(true)
|
||||
val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map
|
||||
setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0)
|
||||
// scales the map tiles to the display density of the screen
|
||||
isTilesScaledToDpi = true
|
||||
// sets the minimum zoom level (the furthest out you can zoom)
|
||||
minZoomLevel = MIN_ZOOM_LEVEL
|
||||
maxZoomLevel = MAX_ZOOM_LEVEL
|
||||
// Disables default +/- button for zooming
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT)
|
||||
|
||||
controller.setZoom(savedZoom)
|
||||
controller.setCenter(savedCenter)
|
||||
}
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
|
||||
|
||||
wakeLock.safeAcquire()
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
wakeLock.safeRelease()
|
||||
mapView.onPause()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
wakeLock.safeAcquire()
|
||||
mapView.onResume()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
savedCenter = mapView.projection.currentCenter
|
||||
savedZoom = mapView.zoomLevelDouble
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
wakeLock.safeRelease()
|
||||
}
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
111
app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
Normal file
111
app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.database.Cursor
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
|
||||
import org.osmdroid.tileprovider.modules.SqlTileWriter
|
||||
|
||||
/**
|
||||
* Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need
|
||||
* to put these with the osmdroid-android library, thus they were put here as more of an example.
|
||||
*
|
||||
* created on 12/21/2016.
|
||||
*
|
||||
* @author Alex O'Ree
|
||||
* @since 5.6.2
|
||||
*/
|
||||
class SqlTileWriterExt : SqlTileWriter() {
|
||||
fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery(
|
||||
"select " +
|
||||
DatabaseFileArchive.COLUMN_KEY +
|
||||
"," +
|
||||
COLUMN_EXPIRES +
|
||||
"," +
|
||||
DatabaseFileArchive.COLUMN_PROVIDER +
|
||||
" from " +
|
||||
DatabaseFileArchive.TABLE +
|
||||
" limit ? offset ?",
|
||||
arrayOf(rows.toString() + "", offset.toString() + ""),
|
||||
)
|
||||
|
||||
/**
|
||||
* gets all the tiles sources that we have tiles for in the cache database and their counts
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
val sources: List<SourceCount>
|
||||
get() {
|
||||
val db = db
|
||||
val ret: MutableList<SourceCount> = ArrayList()
|
||||
if (db == null) {
|
||||
return ret
|
||||
}
|
||||
var cur: Cursor? = null
|
||||
try {
|
||||
cur =
|
||||
db.rawQuery(
|
||||
"select " +
|
||||
DatabaseFileArchive.COLUMN_PROVIDER +
|
||||
",count(*) " +
|
||||
",min(length(" +
|
||||
DatabaseFileArchive.COLUMN_TILE +
|
||||
")) " +
|
||||
",max(length(" +
|
||||
DatabaseFileArchive.COLUMN_TILE +
|
||||
")) " +
|
||||
",sum(length(" +
|
||||
DatabaseFileArchive.COLUMN_TILE +
|
||||
")) " +
|
||||
"from " +
|
||||
DatabaseFileArchive.TABLE +
|
||||
" " +
|
||||
"group by " +
|
||||
DatabaseFileArchive.COLUMN_PROVIDER,
|
||||
null,
|
||||
)
|
||||
while (cur.moveToNext()) {
|
||||
val c = SourceCount()
|
||||
c.source = cur.getString(0)
|
||||
c.rowCount = cur.getLong(1)
|
||||
c.sizeMin = cur.getLong(2)
|
||||
c.sizeMax = cur.getLong(3)
|
||||
c.sizeTotal = cur.getLong(4)
|
||||
c.sizeAvg = c.sizeTotal / c.rowCount
|
||||
ret.add(c)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
catchException(e)
|
||||
} finally {
|
||||
cur?.close()
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
val rowCountExpired: Long
|
||||
get() = getRowCount("$COLUMN_EXPIRES<?", arrayOf(nowMillis.toString()))
|
||||
|
||||
class SourceCount {
|
||||
var rowCount: Long = 0
|
||||
var source: String? = null
|
||||
var sizeTotal: Long = 0
|
||||
var sizeMin: Long = 0
|
||||
var sizeMax: Long = 0
|
||||
var sizeAvg: Long = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.map_select_download_region
|
||||
import org.meshtastic.core.resources.map_start_download
|
||||
import org.meshtastic.core.resources.map_tile_download_estimate
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CacheLayout(
|
||||
cacheEstimate: String,
|
||||
onExecuteJob: () -> Unit,
|
||||
onCancelDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.map_select_download_region),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
|
||||
) {
|
||||
Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) {
|
||||
Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) {
|
||||
Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CacheLayoutPreview() {
|
||||
CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.map_download_region
|
||||
|
||||
@Composable
|
||||
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter =
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
|
||||
),
|
||||
exit =
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
|
||||
),
|
||||
) {
|
||||
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Download,
|
||||
contentDescription = stringResource(Res.string.map_download_region),
|
||||
modifier = Modifier.scale(1.25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Preview(showBackground = true)
|
||||
// @Composable
|
||||
// private fun DownloadButtonPreview() {
|
||||
// DownloadButton(true, onClick = {})
|
||||
// }
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.common.util.systemTimeZone
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.date
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.description
|
||||
import org.meshtastic.core.resources.expires
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.send
|
||||
import org.meshtastic.core.resources.time
|
||||
import org.meshtastic.core.resources.waypoint_edit
|
||||
import org.meshtastic.core.resources.waypoint_new
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val emoji = if ((waypointInput.icon ?: 0) == 0) 128205 else waypointInput.icon!!
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
// Get current context for dialogs
|
||||
val context = LocalContext.current
|
||||
val tz = systemTimeZone
|
||||
|
||||
// Determine locale-specific date format
|
||||
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
|
||||
// Check if 24-hour format is preferred
|
||||
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
|
||||
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
|
||||
|
||||
val currentInstant =
|
||||
remember(waypointInput.expire) {
|
||||
val expire = waypointInput.expire ?: 0
|
||||
if (expire != 0 && expire != Int.MAX_VALUE) {
|
||||
Instant.fromEpochSeconds(expire.toLong())
|
||||
} else {
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
|
||||
// State to hold selected date and time
|
||||
var selectedDate by
|
||||
remember(currentInstant) {
|
||||
mutableStateOf(
|
||||
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
var selectedTime by
|
||||
remember(currentInstant) {
|
||||
mutableStateOf(
|
||||
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.name),
|
||||
value = waypointInput.name ?: "",
|
||||
maxSize = 29,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {}),
|
||||
onValueChanged = { waypointInput = waypointInput.copy(name = it) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(emoji)),
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
|
||||
.padding(4.dp),
|
||||
fontSize = 24.sp,
|
||||
color = Color.Unspecified.copy(alpha = 1f),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.description),
|
||||
value = waypointInput.description ?: "",
|
||||
maxSize = 99,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {}),
|
||||
onValueChanged = { waypointInput = waypointInput.copy(description = it) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.locked))
|
||||
Text(stringResource(Res.string.locked))
|
||||
Switch(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
|
||||
checked = (waypointInput.locked_to ?: 0) != 0,
|
||||
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
|
||||
)
|
||||
}
|
||||
|
||||
val ldt = currentInstant.toLocalDateTime(tz)
|
||||
val datePickerDialog =
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
val newLdt =
|
||||
LocalDateTime(
|
||||
year = selectedYear,
|
||||
month = Month(selectedMonth + 1),
|
||||
day = selectedDay,
|
||||
hour = ldt.hour,
|
||||
minute = ldt.minute,
|
||||
second = ldt.second,
|
||||
nanosecond = ldt.nanosecond,
|
||||
)
|
||||
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.year,
|
||||
ldt.monthNumber - 1,
|
||||
ldt.day,
|
||||
)
|
||||
|
||||
val timePickerDialog =
|
||||
android.app.TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
val newLdt =
|
||||
LocalDateTime(
|
||||
year = ldt.year,
|
||||
month = ldt.month,
|
||||
day = ldt.day,
|
||||
hour = selectedHour,
|
||||
minute = selectedMinute,
|
||||
second = ldt.second,
|
||||
nanosecond = ldt.nanosecond,
|
||||
)
|
||||
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.hour,
|
||||
ldt.minute,
|
||||
is24Hour,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
imageVector = Icons.Rounded.CalendarMonth,
|
||||
contentDescription = stringResource(Res.string.expires),
|
||||
)
|
||||
Text(stringResource(Res.string.expires))
|
||||
Switch(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
|
||||
checked = waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0,
|
||||
onCheckedChange = { isChecked ->
|
||||
if (isChecked) {
|
||||
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
|
||||
} else {
|
||||
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedDate,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedTime,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
if (waypoint.id != 0) {
|
||||
Button(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
enabled = !(waypointInput.name.isNullOrEmpty()),
|
||||
) {
|
||||
Text(stringResource(Res.string.delete))
|
||||
}
|
||||
}
|
||||
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy(icon = it.codePointAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
private fun EditWaypointFormPreview() {
|
||||
AppTheme {
|
||||
EditWaypointDialog(
|
||||
waypoint =
|
||||
Waypoint(
|
||||
id = 123,
|
||||
name = "Test 123",
|
||||
description = "This is only a test",
|
||||
icon = 128169,
|
||||
expire = (nowSeconds.toInt() + 8 * 3600),
|
||||
),
|
||||
onSendClicked = {},
|
||||
onDeleteClicked = {},
|
||||
onDismissRequest = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.map_style_selection
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun MapButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: StringResource,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
MapButton(
|
||||
icon = icon,
|
||||
contentDescription = stringResource(contentDescription),
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
|
||||
FloatingActionButton(onClick = onClick, modifier = modifier) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MapButtonPreview() {
|
||||
AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection) }
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
val OPENWEATHER_RADAR =
|
||||
OnlineTileSourceAuth(
|
||||
"Open Weather Map",
|
||||
1,
|
||||
22,
|
||||
256,
|
||||
".png",
|
||||
arrayOf("https://tile.openweathermap.org/map/"),
|
||||
"Openweathermap",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK or
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
"precipitation",
|
||||
"",
|
||||
)
|
||||
private val ESRI_IMAGERY =
|
||||
object :
|
||||
OnlineTileSourceBase(
|
||||
"ESRI World Overview",
|
||||
1,
|
||||
20,
|
||||
256,
|
||||
".jpg",
|
||||
arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"),
|
||||
"Esri, Maxar, Earthstar Geographics, and the GIS User Community",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK or
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
|
||||
(
|
||||
MapTileIndex.getZoom(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getY(pMapTileIndex) +
|
||||
"/" +
|
||||
MapTileIndex.getX(pMapTileIndex) +
|
||||
mImageFilenameEnding
|
||||
)
|
||||
}
|
||||
|
||||
private val ESRI_WORLD_TOPO =
|
||||
object :
|
||||
OnlineTileSourceBase(
|
||||
"ESRI World TOPO",
|
||||
1,
|
||||
20,
|
||||
256,
|
||||
".jpg",
|
||||
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"),
|
||||
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK or
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
|
||||
(
|
||||
MapTileIndex.getZoom(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getY(pMapTileIndex) +
|
||||
"/" +
|
||||
MapTileIndex.getX(pMapTileIndex) +
|
||||
mImageFilenameEnding
|
||||
)
|
||||
}
|
||||
private val USGS_HYDRO_CACHE =
|
||||
object :
|
||||
OnlineTileSourceBase(
|
||||
"USGS Hydro Cache",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
|
||||
(
|
||||
MapTileIndex.getZoom(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getY(pMapTileIndex) +
|
||||
"/" +
|
||||
MapTileIndex.getX(pMapTileIndex) +
|
||||
mImageFilenameEnding
|
||||
)
|
||||
}
|
||||
private val USGS_SHADED_RELIEF =
|
||||
object :
|
||||
OnlineTileSourceBase(
|
||||
"USGS Shaded Relief Only",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf(
|
||||
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/",
|
||||
),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
|
||||
(
|
||||
MapTileIndex.getZoom(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getY(pMapTileIndex) +
|
||||
"/" +
|
||||
MapTileIndex.getX(pMapTileIndex) +
|
||||
mImageFilenameEnding
|
||||
)
|
||||
}
|
||||
|
||||
/** WMS TILE SERVER More research is required to get this to function correctly with overlays */
|
||||
val NOAA_RADAR_WMS =
|
||||
NOAAWmsTileSource(
|
||||
"Recent Weather Radar",
|
||||
arrayOf(
|
||||
"https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" +
|
||||
"radar_meteo_imagery_nexrad_time/MapServer/WmsServer?",
|
||||
),
|
||||
"1",
|
||||
"1.1.0",
|
||||
"",
|
||||
"EPSG%3A3857",
|
||||
"",
|
||||
"image/png",
|
||||
)
|
||||
|
||||
/** =============================================================================================== */
|
||||
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
|
||||
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
|
||||
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
|
||||
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
|
||||
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
|
||||
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
|
||||
|
||||
/** Source for each available [ITileSource] and their display names. */
|
||||
val mTileSources: Map<ITileSource, String> =
|
||||
mapOf(
|
||||
MAPNIK to "OpenStreetMap",
|
||||
USGS_TOPO to "USGS TOPO",
|
||||
OPEN_TOPO to "Open TOPO",
|
||||
ESRI_WORLD_TOPO to "ESRI World TOPO",
|
||||
USGS_SAT to "USGS Satellite",
|
||||
ESRI_IMAGERY to "ESRI World Overview",
|
||||
)
|
||||
|
||||
fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
|
||||
|
||||
fun getTileSource(aName: String): ITileSource {
|
||||
for (tileSource: ITileSource in mTileSources.keys) {
|
||||
if (tileSource.name().equals(aName)) {
|
||||
return tileSource
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("No such tile source: $aName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.MotionEvent
|
||||
import org.meshtastic.app.map.dpToPx
|
||||
import org.meshtastic.app.map.spToPx
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
|
||||
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
|
||||
|
||||
companion object {
|
||||
private const val LABEL_CORNER_RADIUS_DP = 4f
|
||||
private const val LABEL_Y_OFFSET_DP = 34f
|
||||
private const val FONT_SIZE_SP = 14f
|
||||
private const val EMOJI_FONT_SIZE_SP = 20f
|
||||
}
|
||||
|
||||
private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 }
|
||||
|
||||
private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 }
|
||||
|
||||
private var nodeColor: Int = Color.GRAY
|
||||
|
||||
fun setNodeColors(colors: Pair<Int, Int>) {
|
||||
nodeColor = colors.second
|
||||
}
|
||||
|
||||
private var precisionBits: Int? = null
|
||||
|
||||
fun setPrecisionBits(bits: Int) {
|
||||
precisionBits = bits
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getPrecisionMeters(): Double? = when (precisionBits) {
|
||||
10 -> 23345.484932
|
||||
11 -> 11672.7369
|
||||
12 -> 5836.36288
|
||||
13 -> 2918.175876
|
||||
14 -> 1459.0823719999053
|
||||
15 -> 729.53562
|
||||
16 -> 364.7622
|
||||
17 -> 182.375556
|
||||
18 -> 91.182212
|
||||
19 -> 45.58554
|
||||
else -> null
|
||||
}
|
||||
|
||||
private var onLongClickListener: (() -> Boolean)? = null
|
||||
|
||||
fun setOnLongClickListener(listener: () -> Boolean) {
|
||||
onLongClickListener = listener
|
||||
}
|
||||
|
||||
private val mLabel = label
|
||||
private val mEmoji = emoji
|
||||
private val textPaint =
|
||||
Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
|
||||
color = Color.DKGRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val emojiPaint =
|
||||
Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply { color = Color.WHITE }
|
||||
|
||||
private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF {
|
||||
val fontMetrics = textPaint.fontMetrics
|
||||
val halfTextLength = textPaint.measureText(text) / 2 + 3
|
||||
return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom))
|
||||
}
|
||||
|
||||
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
|
||||
val touched = hitTest(event, mapView)
|
||||
if (touched && this.id != null) {
|
||||
return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView)
|
||||
}
|
||||
return super.onLongPress(event, mapView)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
|
||||
super.draw(c, osmv, false)
|
||||
val p = mPositionPixels
|
||||
val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat()))
|
||||
bgRect.inset(-8F, -2F)
|
||||
|
||||
if (mLabel.isNotEmpty()) {
|
||||
c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint)
|
||||
c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint)
|
||||
}
|
||||
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
|
||||
|
||||
getPrecisionMeters()?.let { radius ->
|
||||
val polygon =
|
||||
Polygon(osmv).apply {
|
||||
points = Polygon.pointsAsCircle(position, radius)
|
||||
fillPaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 48
|
||||
}
|
||||
outlinePaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 64
|
||||
}
|
||||
}
|
||||
polygon.draw(c, osmv, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.osmdroid.api.IMapView
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sinh
|
||||
|
||||
open class NOAAWmsTileSource(
|
||||
aName: String,
|
||||
aBaseUrl: Array<String>,
|
||||
layername: String,
|
||||
version: String,
|
||||
time: String?,
|
||||
srs: String,
|
||||
style: String?,
|
||||
format: String,
|
||||
) : OnlineTileSourceBase(
|
||||
aName,
|
||||
0,
|
||||
5,
|
||||
256,
|
||||
"png",
|
||||
aBaseUrl,
|
||||
"",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_BULK or
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
|
||||
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
|
||||
),
|
||||
) {
|
||||
|
||||
// array indexes for array to hold bounding boxes.
|
||||
private val minX = 0
|
||||
private val maxX = 1
|
||||
private val minY = 2
|
||||
private val maxY = 3
|
||||
|
||||
// Web Mercator n/w corner of the map.
|
||||
private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244)
|
||||
|
||||
// array indexes for that data
|
||||
private val origX = 0
|
||||
private val origY = 1 // "
|
||||
|
||||
// Size of square world map in meters, using WebMerc projection.
|
||||
private val mapSize = 20037508.34789244 * 2
|
||||
private var layer = ""
|
||||
private var version = "1.1.0"
|
||||
private var srs = "EPSG%3A3857" // used by geo server
|
||||
private var format = ""
|
||||
private var time = ""
|
||||
private var style: String? = null
|
||||
private var forceHttps = false
|
||||
private var forceHttp = false
|
||||
|
||||
init {
|
||||
Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" }
|
||||
layer = layername
|
||||
this.version = version
|
||||
this.srs = srs
|
||||
this.style = style
|
||||
this.format = format
|
||||
if (time != null) this.time = time
|
||||
}
|
||||
|
||||
// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
|
||||
// var srs: String? = "EPSG:900913"
|
||||
// if (layer.srs.isNotEmpty()) {
|
||||
// srs = layer.srs[0]
|
||||
// }
|
||||
// return if (layer.styles.isEmpty()) {
|
||||
// WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, null, layer.pixelSize
|
||||
// )
|
||||
// } else WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
|
||||
// )
|
||||
// }
|
||||
|
||||
private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180
|
||||
|
||||
private fun tile2lat(y: Int, z: Int): Double {
|
||||
val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble())
|
||||
return Math.toDegrees(atan(sinh(n)))
|
||||
}
|
||||
|
||||
// Return a web Mercator bounding box given tile x/y indexes and a zoom
|
||||
// level.
|
||||
private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
|
||||
val tileSize = mapSize / 2.0.pow(zoom.toDouble())
|
||||
val minx = tileOrigin[origX] + x * tileSize
|
||||
val maxx = tileOrigin[origX] + (x + 1) * tileSize
|
||||
val miny = tileOrigin[origY] - (y + 1) * tileSize
|
||||
val maxy = tileOrigin[origY] - y * tileSize
|
||||
val bbox = DoubleArray(4)
|
||||
bbox[minX] = minx
|
||||
bbox[minY] = miny
|
||||
bbox[maxX] = maxx
|
||||
bbox[maxY] = maxy
|
||||
return bbox
|
||||
}
|
||||
|
||||
fun isForceHttps(): Boolean = forceHttps
|
||||
|
||||
fun setForceHttps(forceHttps: Boolean) {
|
||||
this.forceHttps = forceHttps
|
||||
}
|
||||
|
||||
fun isForceHttp(): Boolean = forceHttp
|
||||
|
||||
fun setForceHttp(forceHttp: Boolean) {
|
||||
this.forceHttp = forceHttp
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String? {
|
||||
var baseUrl = baseUrl
|
||||
if (forceHttps) baseUrl = baseUrl.replace("http://", "https://")
|
||||
if (forceHttp) baseUrl = baseUrl.replace("https://", "http://")
|
||||
val sb = StringBuilder(baseUrl)
|
||||
if (!baseUrl.endsWith("&")) sb.append("service=WMS")
|
||||
sb.append("&request=GetMap")
|
||||
sb.append("&version=").append(version)
|
||||
sb.append("&layers=").append(layer)
|
||||
if (style != null) sb.append("&styles=").append(style)
|
||||
sb.append("&format=").append(format)
|
||||
sb.append("&transparent=true")
|
||||
sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels)
|
||||
sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels)
|
||||
sb.append("&srs=").append(srs)
|
||||
sb.append("&size=").append(getSize())
|
||||
sb.append("&bbox=")
|
||||
val bbox =
|
||||
getBoundingBox(
|
||||
MapTileIndex.getX(pMapTileIndex),
|
||||
MapTileIndex.getY(pMapTileIndex),
|
||||
MapTileIndex.getZoom(pMapTileIndex),
|
||||
)
|
||||
sb.append(bbox[minX]).append(",")
|
||||
sb.append(bbox[minY]).append(",")
|
||||
sb.append(bbox[maxX]).append(",")
|
||||
sb.append(bbox[maxY])
|
||||
Logger.withTag(IMapView.LOGTAG).i { sb.toString() }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getSize(): String {
|
||||
val height = Resources.getSystem().displayMetrics.heightPixels
|
||||
val width = Resources.getSystem().displayMetrics.widthPixels
|
||||
return "$width,$height"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
open class OnlineTileSourceAuth(
|
||||
name: String,
|
||||
zoomLevel: Int,
|
||||
zoomMaxLevel: Int,
|
||||
tileSizePixels: Int,
|
||||
imageFileNameEnding: String,
|
||||
baseUrl: Array<String>,
|
||||
pCopyright: String,
|
||||
tileSourcePolicy: TileSourcePolicy,
|
||||
layerName: String?,
|
||||
apiKey: String,
|
||||
) : OnlineTileSourceBase(
|
||||
name,
|
||||
zoomLevel,
|
||||
zoomMaxLevel,
|
||||
tileSizePixels,
|
||||
imageFileNameEnding,
|
||||
baseUrl,
|
||||
pCopyright,
|
||||
tileSourcePolicy,
|
||||
) {
|
||||
private var layerName = ""
|
||||
private var apiKey = ""
|
||||
|
||||
init {
|
||||
if (layerName != null) {
|
||||
this.layerName = layerName
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" +
|
||||
(
|
||||
MapTileIndex.getZoom(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getX(pMapTileIndex).toString() +
|
||||
"/" +
|
||||
MapTileIndex.getY(pMapTileIndex).toString()
|
||||
) +
|
||||
mImageFilenameEnding +
|
||||
"?appId=$apiKey"
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.node
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.app.map.addCopyright
|
||||
import org.meshtastic.app.map.addPolyline
|
||||
import org.meshtastic.app.map.addPositionMarkers
|
||||
import org.meshtastic.app.map.addScaleBarOverlay
|
||||
import org.meshtastic.app.map.model.CustomTileSource
|
||||
import org.meshtastic.app.map.rememberMapViewWithLifecycle
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
private const val DEG_D = 1e-7
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val density = LocalDensity.current
|
||||
val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
|
||||
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
|
||||
val mapView =
|
||||
rememberMapViewWithLifecycle(
|
||||
applicationId = nodeMapViewModel.applicationId,
|
||||
box = cameraView,
|
||||
tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
|
||||
)
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.clear()
|
||||
map.addCopyright()
|
||||
map.addScaleBarOverlay(density)
|
||||
|
||||
map.addPolyline(density, geoPoints) {}
|
||||
map.addPositionMarkers(positionLogs) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
14
app/src/fdroid/res/drawable/ic_location_on.xml
Normal file
14
app/src/fdroid/res/drawable/ic_location_on.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#3388ff"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeColor="#ffffffff"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
|
||||
</vector>
|
||||
11
app/src/fdroid/res/drawable/ic_map_location_dot.xml
Normal file
11
app/src/fdroid/res/drawable/ic_map_location_dot.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#3388ff"
|
||||
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
|
||||
android:strokeWidth="2.0"
|
||||
android:strokeColor="#ffffffff" />
|
||||
</vector>
|
||||
11
app/src/fdroid/res/drawable/ic_map_navigation.xml
Normal file
11
app/src/fdroid/res/drawable/ic_map_navigation.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="36dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#3388ff"
|
||||
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeColor="#ffffffff" />
|
||||
</vector>
|
||||
27
app/src/google/AndroidManifest.xml
Normal file
27
app/src/google/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${MAPS_API_KEY}" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.intro
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.analytics_notice
|
||||
import org.meshtastic.core.resources.analytics_platforms
|
||||
import org.meshtastic.core.resources.datadog_link
|
||||
import org.meshtastic.core.resources.firebase_link
|
||||
import org.meshtastic.core.resources.for_more_information_see_our_privacy_policy
|
||||
import org.meshtastic.core.resources.privacy_url
|
||||
import org.meshtastic.core.ui.component.AutoLinkText
|
||||
|
||||
@Composable
|
||||
fun AnalyticsIntro(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(Res.string.analytics_notice),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(Res.string.analytics_platforms),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
AutoLinkText(
|
||||
text = stringResource(Res.string.firebase_link),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AutoLinkText(
|
||||
text = stringResource(Res.string.datadog_link),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(Res.string.for_more_information_see_our_privacy_policy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AutoLinkText(
|
||||
text = stringResource(Res.string.privacy_url),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AnalyticsIntroPreview() {
|
||||
AnalyticsIntro()
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import org.meshtastic.core.ui.util.MapViewProvider
|
||||
|
||||
class GoogleMapViewProvider : MapViewProvider {
|
||||
@Composable
|
||||
override fun MapView(
|
||||
modifier: Modifier,
|
||||
viewModel: Any,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int?,
|
||||
nodeTracks: List<Any>?,
|
||||
tracerouteOverlay: Any?,
|
||||
tracerouteNodePositions: Map<Int, Any>,
|
||||
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
|
||||
) {
|
||||
val mapViewModel: MapViewModel = hiltViewModel()
|
||||
org.meshtastic.app.map.MapView(
|
||||
modifier = modifier,
|
||||
mapViewModel = mapViewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
focusedNodeNum = focusedNodeNum,
|
||||
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
|
||||
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
|
||||
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
|
||||
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
139
app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
Normal file
139
app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.android.gms.common.api.ResolvableApiException
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.LocationSettingsRequest
|
||||
import com.google.android.gms.location.Priority
|
||||
|
||||
private const val INTERVAL_MILLIS = 10000L
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var localHasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
|
||||
val requestLocationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
localHasPermission = isGranted
|
||||
// Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
|
||||
// onPermissionResult
|
||||
// if permission is granted. If not granted, immediately report false.
|
||||
if (!isGranted) {
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
val locationSettingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
Logger.d { "Location settings changed by user." }
|
||||
// User has enabled location services or improved accuracy.
|
||||
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
|
||||
} else {
|
||||
Logger.d { "Location settings change cancelled by user." }
|
||||
// User chose not to change settings. The permission itself is still granted,
|
||||
// but the experience might be degraded. For the purpose of enabling map features,
|
||||
// we consider this as success if the core permission is there.
|
||||
// If stricter handling is needed (e.g., block feature if settings not optimal),
|
||||
// this logic might change.
|
||||
onPermissionResult(localHasPermission)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial permission check
|
||||
when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
if (!localHasPermission) {
|
||||
localHasPermission = true
|
||||
}
|
||||
// If permission is already granted, proceed to check location settings.
|
||||
// The LaunchedEffect(localHasPermission) will handle this.
|
||||
// No need to call onPermissionResult(true) here yet, let settings check complete.
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Request permission if not granted. The launcher's callback will update localHasPermission.
|
||||
requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(localHasPermission) {
|
||||
// Handles logic after permission status is known/updated
|
||||
if (localHasPermission) {
|
||||
// Permission is granted, now check location settings
|
||||
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
|
||||
|
||||
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
|
||||
|
||||
val client = LocationServices.getSettingsClient(context)
|
||||
val task = client.checkLocationSettings(builder.build())
|
||||
|
||||
task.addOnSuccessListener {
|
||||
Logger.d { "Location settings are satisfied." }
|
||||
onPermissionResult(true) // Permission granted and settings are good
|
||||
}
|
||||
|
||||
task.addOnFailureListener { exception ->
|
||||
if (exception is ResolvableApiException) {
|
||||
try {
|
||||
val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
|
||||
locationSettingsLauncher.launch(intentSenderRequest)
|
||||
// Result of this launch will be handled by locationSettingsLauncher's callback
|
||||
} catch (sendEx: ActivityNotFoundException) {
|
||||
Logger.d { "Error launching location settings resolution ${sendEx.message}." }
|
||||
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
|
||||
}
|
||||
} else {
|
||||
Logger.d { "Location settings are not satisfiable.${exception.message}" }
|
||||
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If permission is not granted, report false.
|
||||
// This case is primarily handled by the requestLocationPermissionLauncher's callback
|
||||
// if the initial state was denied, or if user denies it.
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.google.android.gms.maps.model.Tile
|
||||
import com.google.android.gms.maps.model.TileProvider
|
||||
import java.io.File
|
||||
|
||||
class MBTilesProvider(private val file: File) :
|
||||
TileProvider,
|
||||
AutoCloseable {
|
||||
private var database: SQLiteDatabase? = null
|
||||
|
||||
init {
|
||||
openDatabase()
|
||||
}
|
||||
|
||||
private fun openDatabase() {
|
||||
if (database == null && file.exists()) {
|
||||
database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
|
||||
val db = database ?: return null
|
||||
|
||||
var tile: Tile? = null
|
||||
// Convert Google Maps y coordinate to standard TMS y coordinate
|
||||
val tmsY = (1 shl zoom) - 1 - y
|
||||
|
||||
val cursor =
|
||||
db.rawQuery(
|
||||
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
|
||||
arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
|
||||
)
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
val tileData = cursor.getBlob(0)
|
||||
tile = Tile(256, 256, tileData)
|
||||
}
|
||||
cursor.close()
|
||||
|
||||
return tile ?: TileProvider.NO_TILE
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
database?.close()
|
||||
database = null
|
||||
}
|
||||
}
|
||||
901
app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Normal file
901
app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.rounded.TripOrigin
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.maps.CameraUpdateFactory
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.BitmapDescriptorFactory
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.JointType
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.maps.android.SphericalUtil
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapEffect
|
||||
import com.google.maps.android.compose.MapProperties
|
||||
import com.google.maps.android.compose.MapType
|
||||
import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
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.rememberUpdatedMarkerState
|
||||
import com.google.maps.android.compose.widgets.ScaleBar
|
||||
import com.google.maps.android.data.geojson.GeoJsonLayer
|
||||
import com.google.maps.android.data.kml.KmlLayer
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.json.JSONObject
|
||||
import org.meshtastic.app.map.component.ClusterItemsListDialog
|
||||
import org.meshtastic.app.map.component.CustomMapLayersSheet
|
||||
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
|
||||
import org.meshtastic.app.map.component.EditWaypointDialog
|
||||
import org.meshtastic.app.map.component.MapControlsOverlay
|
||||
import org.meshtastic.app.map.component.NodeClusterMarkers
|
||||
import org.meshtastic.app.map.component.WaypointMarkers
|
||||
import org.meshtastic.app.map.model.NodeClusterItem
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.mpsToKmph
|
||||
import org.meshtastic.core.model.util.mpsToMph
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.alt
|
||||
import org.meshtastic.core.resources.heading
|
||||
import org.meshtastic.core.resources.latitude
|
||||
import org.meshtastic.core.resources.longitude
|
||||
import org.meshtastic.core.resources.position
|
||||
import org.meshtastic.core.resources.sats
|
||||
import org.meshtastic.core.resources.speed
|
||||
import org.meshtastic.core.resources.timestamp
|
||||
import org.meshtastic.core.resources.track_point
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.formatAgo
|
||||
import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.map.tracerouteNodeSelection
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
|
||||
private const val DEG_D = 1e-7
|
||||
private const val HEADING_DEG = 1e-5
|
||||
private const val TRACEROUTE_OFFSET_METERS = 100.0
|
||||
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(
|
||||
MapsComposeExperimentalApi::class,
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterial3ExpressiveApi::class,
|
||||
ExperimentalPermissionsApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun MapView(
|
||||
modifier: Modifier = Modifier,
|
||||
mapViewModel: MapViewModel = hiltViewModel(),
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int? = null,
|
||||
nodeTracks: List<Position>? = null,
|
||||
tracerouteOverlay: TracerouteOverlay? = null,
|
||||
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
|
||||
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
|
||||
// Location permissions state
|
||||
val locationPermissionsState =
|
||||
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
|
||||
|
||||
// Location tracking state
|
||||
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
|
||||
var followPhoneBearing by remember { mutableStateOf(false) }
|
||||
|
||||
// Effect to toggle location tracking after permission is granted
|
||||
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
|
||||
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
|
||||
isLocationTrackingEnabled = true
|
||||
triggerLocationToggleAfterPermission = false
|
||||
}
|
||||
}
|
||||
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
val fileName = uri.getFileName(context)
|
||||
mapViewModel.addMapLayer(uri, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
|
||||
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val cameraPositionState = mapViewModel.cameraPositionState
|
||||
|
||||
// Save camera position when it stops moving
|
||||
LaunchedEffect(cameraPositionState.isMoving) {
|
||||
if (!cameraPositionState.isMoving) {
|
||||
mapViewModel.saveCameraPosition(cameraPositionState.position)
|
||||
}
|
||||
}
|
||||
|
||||
// Location tracking functionality
|
||||
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
|
||||
val locationCallback = remember {
|
||||
object : LocationCallback() {
|
||||
override fun onLocationResult(locationResult: LocationResult) {
|
||||
if (isLocationTrackingEnabled) {
|
||||
locationResult.lastLocation?.let { location ->
|
||||
val latLng = LatLng(location.latitude, location.longitude)
|
||||
val cameraUpdate =
|
||||
if (followPhoneBearing) {
|
||||
val bearing =
|
||||
if (location.hasBearing()) {
|
||||
location.bearing
|
||||
} else {
|
||||
cameraPositionState.position.bearing
|
||||
}
|
||||
CameraUpdateFactory.newCameraPosition(
|
||||
CameraPosition.Builder()
|
||||
.target(latLng)
|
||||
.zoom(cameraPositionState.position.zoom)
|
||||
.bearing(bearing)
|
||||
.build(),
|
||||
)
|
||||
} else {
|
||||
CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.d { "Error animating camera to location: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start/stop location tracking based on state
|
||||
LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
|
||||
if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
|
||||
val locationRequest =
|
||||
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
|
||||
.setMinUpdateIntervalMillis(2000L)
|
||||
.build()
|
||||
|
||||
try {
|
||||
@Suppress("MissingPermission")
|
||||
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
|
||||
Logger.d { "Started location tracking" }
|
||||
} catch (e: SecurityException) {
|
||||
Logger.d { "Location permission not available: ${e.message}" }
|
||||
isLocationTrackingEnabled = false
|
||||
}
|
||||
} else {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
Logger.d { "Stopped location tracking" }
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
|
||||
|
||||
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
|
||||
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
|
||||
|
||||
val tracerouteSelection =
|
||||
remember(tracerouteOverlay, tracerouteNodePositions, allNodes) {
|
||||
mapViewModel.tracerouteNodeSelection(
|
||||
tracerouteOverlay = tracerouteOverlay,
|
||||
tracerouteNodePositions = tracerouteNodePositions,
|
||||
nodes = allNodes,
|
||||
)
|
||||
}
|
||||
|
||||
val filteredNodes =
|
||||
allNodes
|
||||
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
|
||||
.filter { node ->
|
||||
mapFilterState.lastHeardFilter.seconds == 0L ||
|
||||
(nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
|
||||
node.num == ourNodeInfo?.num
|
||||
}
|
||||
|
||||
val displayNodes =
|
||||
if (tracerouteOverlay != null) {
|
||||
tracerouteSelection.nodesForMarkers
|
||||
} else {
|
||||
filteredNodes
|
||||
}
|
||||
LaunchedEffect(tracerouteOverlay, displayNodes) {
|
||||
if (tracerouteOverlay != null) {
|
||||
onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
|
||||
}
|
||||
}
|
||||
|
||||
val myNodeNum = mapViewModel.myNodeNum
|
||||
val nodeClusterItems =
|
||||
displayNodes.map { node ->
|
||||
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
|
||||
NodeClusterItem(
|
||||
node = node,
|
||||
nodePosition = latLng,
|
||||
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
|
||||
nodeSnippet = "${node.user.long_name}",
|
||||
myNodeNum = myNodeNum,
|
||||
)
|
||||
}
|
||||
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
|
||||
val dark =
|
||||
when (theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> true
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val mapColorScheme =
|
||||
when (dark) {
|
||||
true -> ComposeMapColorScheme.DARK
|
||||
else -> ComposeMapColorScheme.LIGHT
|
||||
}
|
||||
val tracerouteForwardPoints =
|
||||
remember(tracerouteOverlay, displayNodes) {
|
||||
val nodeLookup = displayNodes.associateBy { it.num }
|
||||
tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
|
||||
}
|
||||
val tracerouteReturnPoints =
|
||||
remember(tracerouteOverlay, displayNodes) {
|
||||
val nodeLookup = displayNodes.associateBy { it.num }
|
||||
tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
|
||||
}
|
||||
val tracerouteHeadingReferencePoints =
|
||||
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
when {
|
||||
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
|
||||
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
val tracerouteForwardOffsetPoints =
|
||||
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteForwardPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = 1.0,
|
||||
)
|
||||
}
|
||||
val tracerouteReturnOffsetPoints =
|
||||
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteReturnPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = -1.0,
|
||||
)
|
||||
}
|
||||
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
|
||||
|
||||
var showLayersBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val onAddLayerClicked = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
val mimeTypes =
|
||||
arrayOf(
|
||||
"application/vnd.google-earth.kml+xml",
|
||||
"application/vnd.google-earth.kmz",
|
||||
"application/vnd.geo+json",
|
||||
"application/geo+json",
|
||||
"application/json",
|
||||
)
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
}
|
||||
filePickerLauncher.launch(intent)
|
||||
}
|
||||
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
|
||||
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
|
||||
|
||||
val effectiveGoogleMapType =
|
||||
if (currentCustomTileProviderUrl != null) {
|
||||
MapType.NONE
|
||||
} else {
|
||||
selectedGoogleMapType
|
||||
}
|
||||
|
||||
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
|
||||
|
||||
LaunchedEffect(isLocationTrackingEnabled) {
|
||||
val activity = context as? Activity ?: return@LaunchedEffect
|
||||
val window = activity.window
|
||||
|
||||
if (isLocationTrackingEnabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
|
||||
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
|
||||
if (allPoints.isNotEmpty()) {
|
||||
val cameraUpdate =
|
||||
if (allPoints.size == 1) {
|
||||
CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f))
|
||||
} else {
|
||||
val bounds = LatLngBounds.builder()
|
||||
allPoints.forEach { bounds.include(it) }
|
||||
CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
|
||||
}
|
||||
try {
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
hasCenteredTraceroute = true
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.d { "Error centering traceroute overlay: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
GoogleMap(
|
||||
mapColorScheme = mapColorScheme,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraPositionState = cameraPositionState,
|
||||
uiSettings =
|
||||
MapUiSettings(
|
||||
zoomControlsEnabled = true,
|
||||
mapToolbarEnabled = true,
|
||||
compassEnabled = false,
|
||||
myLocationButtonEnabled = false,
|
||||
rotationGesturesEnabled = true,
|
||||
scrollGesturesEnabled = true,
|
||||
tiltGesturesEnabled = true,
|
||||
zoomGesturesEnabled = true,
|
||||
),
|
||||
properties =
|
||||
MapProperties(
|
||||
mapType = effectiveGoogleMapType,
|
||||
isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
|
||||
),
|
||||
onMapLongClick = { latLng ->
|
||||
if (isConnected) {
|
||||
val newWaypoint =
|
||||
Waypoint(
|
||||
latitude_i = (latLng.latitude / DEG_D).toInt(),
|
||||
longitude_i = (latLng.longitude / DEG_D).toInt(),
|
||||
)
|
||||
editingWaypoint = newWaypoint
|
||||
}
|
||||
},
|
||||
) {
|
||||
key(currentCustomTileProviderUrl) {
|
||||
currentCustomTileProviderUrl?.let { url ->
|
||||
val config =
|
||||
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
|
||||
it.urlTemplate == url || it.localUri == url
|
||||
}
|
||||
mapViewModel.getTileProvider(config)?.let { tileProvider ->
|
||||
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tracerouteForwardPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteForwardOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.OutgoingRoute,
|
||||
width = 9f,
|
||||
zIndex = 3.0f,
|
||||
)
|
||||
}
|
||||
if (tracerouteReturnPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteReturnOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.ReturnRoute,
|
||||
width = 7f,
|
||||
zIndex = 2.5f,
|
||||
)
|
||||
}
|
||||
|
||||
if (nodeTracks != null && focusedNodeNum != null) {
|
||||
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
|
||||
val timeFilteredPositions =
|
||||
nodeTracks.filter {
|
||||
lastHeardTrackFilter == LastHeardFilter.Any ||
|
||||
it.time > nowSeconds - lastHeardTrackFilter.seconds
|
||||
}
|
||||
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
|
||||
allNodes
|
||||
.find { it.num == focusedNodeNum }
|
||||
?.let { focusedNode ->
|
||||
sortedPositions.forEachIndexed { index, position ->
|
||||
key(position.time) {
|
||||
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
|
||||
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
|
||||
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
|
||||
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
|
||||
val activeNodeZIndex = if (isHighPriority) 5f else 4f
|
||||
|
||||
if (index == sortedPositions.lastIndex) {
|
||||
MarkerComposable(
|
||||
state = markerState,
|
||||
zIndex = activeNodeZIndex,
|
||||
alpha = if (isHighPriority) 1.0f else 0.9f,
|
||||
) {
|
||||
NodeChip(node = focusedNode)
|
||||
}
|
||||
} else {
|
||||
MarkerInfoWindowComposable(
|
||||
state = markerState,
|
||||
title = stringResource(Res.string.position),
|
||||
snippet = formatAgo(position.time),
|
||||
zIndex = 1f + alpha,
|
||||
infoContent = {
|
||||
PositionInfoWindowContent(position = position, displayUnits = displayUnits)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin,
|
||||
contentDescription = stringResource(Res.string.track_point),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedPositions.size > 1) {
|
||||
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
|
||||
segments.forEachIndexed { index, segmentPoints ->
|
||||
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
|
||||
Polyline(
|
||||
points = segmentPoints.map { it.toLatLng() },
|
||||
jointType = JointType.ROUND,
|
||||
color = Color(focusedNode.colors.second).copy(alpha = alpha),
|
||||
width = 8f,
|
||||
zIndex = 0.6f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NodeClusterMarkers(
|
||||
nodeClusterItems = nodeClusterItems,
|
||||
mapFilterState = mapFilterState,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
onClusterClick = { cluster ->
|
||||
val items = cluster.items.toList()
|
||||
val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
|
||||
|
||||
if (allSameLocation) {
|
||||
showClusterItemsDialog = items
|
||||
} else {
|
||||
val bounds = LatLngBounds.builder()
|
||||
cluster.items.forEach { bounds.include(it.position) }
|
||||
coroutineScope.launch {
|
||||
cameraPositionState.animate(
|
||||
CameraUpdateFactory.newCameraPosition(
|
||||
CameraPosition.Builder()
|
||||
.target(bounds.build().center)
|
||||
.zoom(cameraPositionState.position.zoom + 1)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
Logger.d { "Cluster clicked! $cluster" }
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
WaypointMarkers(
|
||||
displayableWaypoints = displayableWaypoints,
|
||||
mapFilterState = mapFilterState,
|
||||
myNodeNum = mapViewModel.myNodeNum ?: 0,
|
||||
isConnected = isConnected,
|
||||
unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
|
||||
onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
|
||||
selectedWaypointId = selectedWaypointId,
|
||||
)
|
||||
|
||||
mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
|
||||
}
|
||||
|
||||
ScaleBar(
|
||||
cameraPositionState = cameraPositionState,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp),
|
||||
)
|
||||
editingWaypoint?.let { waypointToEdit ->
|
||||
EditWaypointDialog(
|
||||
waypoint = waypointToEdit,
|
||||
onSendClicked = { updatedWp ->
|
||||
var finalWp = updatedWp
|
||||
if (updatedWp.id == 0) {
|
||||
finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
|
||||
}
|
||||
if ((updatedWp.icon ?: 0) == 0) {
|
||||
finalWp = finalWp.copy(icon = 0x1F4CD)
|
||||
}
|
||||
|
||||
mapViewModel.sendWaypoint(finalWp)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDeleteClicked = { wpToDelete ->
|
||||
if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
|
||||
val deleteMarkerWp = wpToDelete.copy(expire = 1)
|
||||
mapViewModel.sendWaypoint(deleteMarkerWp)
|
||||
}
|
||||
mapViewModel.deleteWaypoint(wpToDelete.id)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDismissRequest = { editingWaypoint = null },
|
||||
)
|
||||
}
|
||||
|
||||
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
|
||||
val showRefresh = visibleNetworkLayers.isNotEmpty()
|
||||
val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
|
||||
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
mapFilterMenuExpanded = mapFilterMenuExpanded,
|
||||
onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
|
||||
onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
|
||||
mapViewModel = mapViewModel,
|
||||
mapTypeMenuExpanded = mapTypeMenuExpanded,
|
||||
onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
|
||||
onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
|
||||
onManageLayersClicked = { showLayersBottomSheet = true },
|
||||
onManageCustomTileProvidersClicked = {
|
||||
mapTypeMenuExpanded = false
|
||||
showCustomTileManagerSheet = true
|
||||
},
|
||||
isNodeMap = focusedNodeNum != null,
|
||||
isLocationTrackingEnabled = isLocationTrackingEnabled,
|
||||
onToggleLocationTracking = {
|
||||
if (locationPermissionsState.allPermissionsGranted) {
|
||||
isLocationTrackingEnabled = !isLocationTrackingEnabled
|
||||
if (!isLocationTrackingEnabled) {
|
||||
followPhoneBearing = false
|
||||
}
|
||||
} else {
|
||||
triggerLocationToggleAfterPermission = true
|
||||
locationPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
},
|
||||
bearing = cameraPositionState.position.bearing,
|
||||
onCompassClick = {
|
||||
if (isLocationTrackingEnabled) {
|
||||
followPhoneBearing = !followPhoneBearing
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val currentPosition = cameraPositionState.position
|
||||
val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
|
||||
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
|
||||
Logger.d { "Oriented map to north" }
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.d { "Error orienting map to north: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
followPhoneBearing = followPhoneBearing,
|
||||
showRefresh = showRefresh,
|
||||
isRefreshing = isRefreshingLayers,
|
||||
onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
|
||||
)
|
||||
}
|
||||
if (showLayersBottomSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
|
||||
CustomMapLayersSheet(
|
||||
mapLayers = mapLayers,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
onRemoveLayer = onRemoveLayer,
|
||||
onAddLayerClicked = onAddLayerClicked,
|
||||
onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
|
||||
onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
showClusterItemsDialog?.let {
|
||||
ClusterItemsListDialog(
|
||||
items = it,
|
||||
onDismiss = { showClusterItemsDialog = null },
|
||||
onItemClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
showClusterItemsDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showCustomTileManagerSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
|
||||
CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
|
||||
val context = LocalContext.current
|
||||
var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
|
||||
|
||||
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
|
||||
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
|
||||
val layer =
|
||||
try {
|
||||
when (layerItem.layerType) {
|
||||
LayerType.KML -> KmlLayer(map, inputStream, context)
|
||||
LayerType.GEOJSON ->
|
||||
GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
|
||||
null
|
||||
}
|
||||
|
||||
layer?.let {
|
||||
if (layerItem.isVisible) {
|
||||
it.addLayerToMap()
|
||||
}
|
||||
currentLayer = it
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(layerItem.id) {
|
||||
onDispose {
|
||||
currentLayer?.removeLayerFromMap()
|
||||
currentLayer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle visibility changes without reloading the whole layer if possible,
|
||||
// though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
|
||||
LaunchedEffect(layerItem.isVisible) {
|
||||
val layer = currentLayer ?: return@LaunchedEffect
|
||||
if (layerItem.isVisible) {
|
||||
if (!layer.isLayerOnMap) layer.addLayerToMap()
|
||||
} else {
|
||||
if (layer.isLayerOnMap) layer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
|
||||
String(Character.toChars(unicodeCodePoint))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
|
||||
"\uD83D\uDCCD"
|
||||
}
|
||||
|
||||
internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
|
||||
val unicodeEmoji = convertIntToEmoji(icon)
|
||||
val paint =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 64f
|
||||
color = android.graphics.Color.BLACK
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val baseline = -paint.ascent()
|
||||
val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
|
||||
val height = (baseline + paint.descent() + 0.5f).toInt()
|
||||
val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(image)
|
||||
canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
|
||||
|
||||
return BitmapDescriptorFactory.fromBitmap(image)
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun Uri.getFileName(context: android.content.Context): String {
|
||||
var name = this.lastPathSegment ?: "layer_$nowMillis"
|
||||
if (this.scheme == "content") {
|
||||
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex != -1) {
|
||||
name = cursor.getString(displayNameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
|
||||
@Composable
|
||||
fun PositionRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(value, style = MaterialTheme.typography.labelMediumEmphasized)
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.latitude),
|
||||
value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.longitude),
|
||||
value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "")
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.alt),
|
||||
value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.heading),
|
||||
value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
|
||||
val speedInMps = position.ground_speed ?: 0
|
||||
val mpsText = "%d m/s".format(speedInMps)
|
||||
val speedText =
|
||||
if (speedInMps > 10) {
|
||||
when (displayUnits) {
|
||||
DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
|
||||
DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
|
||||
else -> mpsText
|
||||
}
|
||||
} else {
|
||||
mpsText
|
||||
}
|
||||
return speedText
|
||||
}
|
||||
|
||||
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
|
||||
|
||||
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
|
||||
|
||||
private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
|
||||
|
||||
private fun offsetPolyline(
|
||||
points: List<LatLng>,
|
||||
offsetMeters: Double,
|
||||
headingReferencePoints: List<LatLng> = points,
|
||||
sideMultiplier: Double = 1.0,
|
||||
): List<LatLng> {
|
||||
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
|
||||
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
|
||||
|
||||
val headings =
|
||||
headingPoints.mapIndexed { index, _ ->
|
||||
when (index) {
|
||||
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
|
||||
headingPoints.lastIndex ->
|
||||
SphericalUtil.computeHeading(
|
||||
headingPoints[headingPoints.lastIndex - 1],
|
||||
headingPoints[headingPoints.lastIndex],
|
||||
)
|
||||
|
||||
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return points.mapIndexed { index, point ->
|
||||
val heading = headings[index.coerceIn(0, headings.lastIndex)]
|
||||
val perpendicularHeading = heading + (90.0 * sideMultiplier)
|
||||
SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
|
||||
}
|
||||
}
|
||||
666
app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
Normal file
666
app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import co.touchlab.kermit.Logger
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
||||
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
|
||||
import org.meshtastic.app.map.repository.CustomTileProviderRepository
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.navigation.MapRoutes
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.Config
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private const val TILE_SIZE = 256
|
||||
|
||||
@Serializable
|
||||
data class MapCameraPosition(
|
||||
val targetLat: Double,
|
||||
val targetLng: Double,
|
||||
val zoom: Float,
|
||||
val tilt: Float,
|
||||
val bearing: Float,
|
||||
)
|
||||
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
@HiltViewModel
|
||||
class MapViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
mapPrefs: MapPrefs,
|
||||
private val googleMapsPrefs: GoogleMapsPrefs,
|
||||
nodeRepository: NodeRepository,
|
||||
packetRepository: PacketRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
radioController: RadioController,
|
||||
private val customTileProviderRepository: CustomTileProviderRepository,
|
||||
uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
|
||||
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute<MapRoutes.Map>().waypointId)
|
||||
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
|
||||
|
||||
private val targetLatLng =
|
||||
googleMapsPrefs.cameraTargetLat.value
|
||||
.takeIf { it != 0.0 }
|
||||
?.let { lat -> googleMapsPrefs.cameraTargetLng.value.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.value,
|
||||
googleMapsPrefs.cameraTilt.value,
|
||||
googleMapsPrefs.cameraBearing.value,
|
||||
),
|
||||
)
|
||||
|
||||
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
|
||||
|
||||
private val _errorFlow = MutableSharedFlow<String>()
|
||||
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
|
||||
|
||||
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
|
||||
customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
|
||||
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
|
||||
|
||||
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
|
||||
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
|
||||
|
||||
val displayUnits =
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.mapNotNull { it.config?.display?.units }
|
||||
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
|
||||
|
||||
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
|
||||
viewModelScope.launch {
|
||||
if (
|
||||
name.isBlank() ||
|
||||
(urlTemplate.isBlank() && localUri == null) ||
|
||||
(localUri == null && !isValidTileUrlTemplate(urlTemplate))
|
||||
) {
|
||||
_errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.")
|
||||
return@launch
|
||||
}
|
||||
if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
|
||||
_errorFlow.emit("Custom tile provider with name '$name' already exists.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
var finalLocalUri = localUri
|
||||
if (localUri != null) {
|
||||
try {
|
||||
val uri = Uri.parse(localUri)
|
||||
val extension = "mbtiles"
|
||||
val finalFileName = "mbtiles_${Uuid.random()}.$extension"
|
||||
val copiedUri = copyFileToInternalStorage(uri, finalFileName)
|
||||
if (copiedUri != null) {
|
||||
finalLocalUri = copiedUri.toString()
|
||||
} else {
|
||||
_errorFlow.emit("Failed to copy MBTiles file to internal storage.")
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error processing local URI" }
|
||||
_errorFlow.emit("Error processing local URI for MBTiles.")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
|
||||
customTileProviderRepository.addCustomTileProvider(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
|
||||
viewModelScope.launch {
|
||||
if (
|
||||
configToUpdate.name.isBlank() ||
|
||||
(configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
|
||||
(configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
|
||||
) {
|
||||
_errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
|
||||
return@launch
|
||||
}
|
||||
val existingConfigs = customTileProviderConfigs.value
|
||||
if (
|
||||
existingConfigs.any {
|
||||
it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
|
||||
}
|
||||
) {
|
||||
_errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
customTileProviderRepository.updateCustomTileProvider(configToUpdate)
|
||||
|
||||
val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
|
||||
if (
|
||||
_selectedCustomTileProviderUrl.value != null &&
|
||||
originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
|
||||
) {
|
||||
// No change needed if URL didn't change, or handle if it did
|
||||
} else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
|
||||
val currentlySelectedConfig =
|
||||
customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
|
||||
if (currentlySelectedConfig?.id == configToUpdate.id) {
|
||||
_selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustomTileProvider(configId: String) {
|
||||
viewModelScope.launch {
|
||||
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
|
||||
customTileProviderRepository.deleteCustomTileProvider(configId)
|
||||
|
||||
if (configToRemove != null) {
|
||||
if (
|
||||
_selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
|
||||
_selectedCustomTileProviderUrl.value == configToRemove.localUri
|
||||
) {
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
// Also clear from prefs
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
}
|
||||
|
||||
if (configToRemove.localUri != null) {
|
||||
val uri = Uri.parse(configToRemove.localUri)
|
||||
deleteFileToInternalStorage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
|
||||
if (config != null) {
|
||||
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
|
||||
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
return
|
||||
}
|
||||
// Use localUri if present, otherwise urlTemplate
|
||||
val selectedUrl = config.localUri ?: config.urlTemplate
|
||||
_selectedCustomTileProviderUrl.value = selectedUrl
|
||||
_selectedGoogleMapType.value = MapType.NONE
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
|
||||
googleMapsPrefs.setSelectedGoogleMapType(null)
|
||||
} else {
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
_selectedGoogleMapType.value = MapType.NORMAL
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedGoogleMapType(mapType: MapType) {
|
||||
_selectedGoogleMapType.value = mapType
|
||||
_selectedCustomTileProviderUrl.value = null // Clear custom selection
|
||||
googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
}
|
||||
|
||||
private var currentTileProvider: TileProvider? = null
|
||||
|
||||
fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
|
||||
if (config == null) {
|
||||
(currentTileProvider as? MBTilesProvider)?.close()
|
||||
currentTileProvider = null
|
||||
return null
|
||||
}
|
||||
|
||||
val selectedUrl = config.localUri ?: config.urlTemplate
|
||||
if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
|
||||
return currentTileProvider
|
||||
}
|
||||
|
||||
// Close previous if it was a local provider
|
||||
(currentTileProvider as? MBTilesProvider)?.close()
|
||||
|
||||
val newProvider =
|
||||
if (config.isLocal) {
|
||||
val uri = Uri.parse(config.localUri)
|
||||
val file =
|
||||
try {
|
||||
uri.toFile()
|
||||
} catch (e: Exception) {
|
||||
File(uri.path ?: "")
|
||||
}
|
||||
if (file.exists()) {
|
||||
MBTilesProvider(file)
|
||||
} else {
|
||||
Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
val urlString = config.urlTemplate
|
||||
if (!isValidTileUrlTemplate(urlString)) {
|
||||
Logger.withTag("MapViewModel")
|
||||
.e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
|
||||
null
|
||||
} else {
|
||||
object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
|
||||
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
|
||||
val subdomains = listOf("a", "b", "c")
|
||||
val subdomain = subdomains[(x + y) % subdomains.size]
|
||||
val formattedUrl =
|
||||
urlString
|
||||
.replace("{s}", subdomain, ignoreCase = true)
|
||||
.replace("{z}", zoom.toString(), ignoreCase = true)
|
||||
.replace("{x}", x.toString(), ignoreCase = true)
|
||||
.replace("{y}", y.toString(), ignoreCase = true)
|
||||
return try {
|
||||
URL(formattedUrl)
|
||||
} catch (e: MalformedURLException) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentTileProvider = newProvider
|
||||
return newProvider
|
||||
}
|
||||
|
||||
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
|
||||
urlTemplate.contains("{x}", ignoreCase = true) &&
|
||||
urlTemplate.contains("{y}", ignoreCase = true)
|
||||
|
||||
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
|
||||
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
customTileProviderRepository.getCustomTileProviders().first()
|
||||
loadPersistedMapType()
|
||||
}
|
||||
loadPersistedLayers()
|
||||
|
||||
selectedWaypointId.value?.let { wpId ->
|
||||
viewModelScope.launch {
|
||||
val wpMap = waypoints.first { it.containsKey(wpId) }
|
||||
wpMap[wpId]?.let { packet ->
|
||||
val waypoint = packet.waypoint!!
|
||||
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
|
||||
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCameraPosition(cameraPosition: CameraPosition) {
|
||||
viewModelScope.launch {
|
||||
googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
|
||||
googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
|
||||
googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
|
||||
googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
|
||||
googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPersistedMapType() {
|
||||
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
|
||||
if (savedCustomUrl != null) {
|
||||
// Check if this custom provider still exists
|
||||
if (
|
||||
customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } &&
|
||||
isValidTileUrlTemplate(savedCustomUrl)
|
||||
) {
|
||||
_selectedCustomTileProviderUrl.value = savedCustomUrl
|
||||
_selectedGoogleMapType.value =
|
||||
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
|
||||
} else {
|
||||
// The saved custom URL is no longer valid or doesn't exist, remove preference
|
||||
googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
// Fallback to default Google Map type
|
||||
_selectedGoogleMapType.value = MapType.NORMAL
|
||||
}
|
||||
} else {
|
||||
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
|
||||
try {
|
||||
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
|
||||
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
|
||||
googleMapsPrefs.setSelectedGoogleMapType(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPersistedLayers() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val layersDir = File(application.filesDir, "map_layers")
|
||||
if (layersDir.exists() && layersDir.isDirectory) {
|
||||
val persistedLayerFiles = layersDir.listFiles()
|
||||
|
||||
if (persistedLayerFiles != null) {
|
||||
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
||||
val loadedItems =
|
||||
persistedLayerFiles.mapNotNull { file ->
|
||||
if (file.isFile) {
|
||||
val layerType =
|
||||
when (file.extension.lowercase()) {
|
||||
"kml",
|
||||
"kmz",
|
||||
-> LayerType.KML
|
||||
"geojson",
|
||||
"json",
|
||||
-> LayerType.GEOJSON
|
||||
else -> null
|
||||
}
|
||||
|
||||
layerType?.let {
|
||||
val uri = Uri.fromFile(file)
|
||||
MapLayerItem(
|
||||
name = file.nameWithoutExtension,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = it,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val networkItems =
|
||||
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
||||
try {
|
||||
val parts = networkString.split("|:|")
|
||||
if (parts.size == 3) {
|
||||
val id = parts[0]
|
||||
val name = parts[1]
|
||||
val uri = Uri.parse(parts[2])
|
||||
MapLayerItem(
|
||||
id = id,
|
||||
name = name,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = LayerType.KML,
|
||||
isNetwork = true,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
_mapLayers.value = loadedItems + networkItems
|
||||
if (_mapLayers.value.isNotEmpty()) {
|
||||
Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" }
|
||||
_mapLayers.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addMapLayer(uri: Uri, fileName: String?) {
|
||||
viewModelScope.launch {
|
||||
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
|
||||
|
||||
val extension =
|
||||
fileName?.substringAfterLast('.', "")?.lowercase()
|
||||
?: application.contentResolver.getType(uri)?.split('/')?.last()
|
||||
|
||||
val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
|
||||
val geoJsonExtensions = listOf("geojson", "json")
|
||||
|
||||
val layerType =
|
||||
when (extension) {
|
||||
in kmlExtensions -> LayerType.KML
|
||||
in geoJsonExtensions -> LayerType.GEOJSON
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (layerType == null) {
|
||||
Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val finalFileName =
|
||||
if (fileName != null) {
|
||||
"$layerName.$extension"
|
||||
} else {
|
||||
"layer_${Uuid.random()}.$extension"
|
||||
}
|
||||
|
||||
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
|
||||
|
||||
if (localFileUri != null) {
|
||||
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
|
||||
_mapLayers.value = _mapLayers.value + newItem
|
||||
} else {
|
||||
Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addNetworkMapLayer(name: String, url: String) {
|
||||
viewModelScope.launch {
|
||||
if (name.isBlank() || url.isBlank()) {
|
||||
_errorFlow.emit("Invalid name or URL for network layer.")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "http" && uri.scheme != "https") {
|
||||
_errorFlow.emit("URL must be http or https.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val path = uri.path?.lowercase() ?: ""
|
||||
val layerType =
|
||||
when {
|
||||
path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
|
||||
else -> LayerType.KML // Default to KML
|
||||
}
|
||||
|
||||
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
|
||||
_mapLayers.value = _mapLayers.value + newItem
|
||||
|
||||
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
|
||||
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
|
||||
} catch (e: Exception) {
|
||||
_errorFlow.emit("Invalid URL.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = application.contentResolver.openInputStream(uri)
|
||||
val directory = File(application.filesDir, "map_layers")
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
val outputFile = File(directory, fileName)
|
||||
val outputStream = FileOutputStream(outputFile)
|
||||
|
||||
inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
|
||||
Uri.fromFile(outputFile)
|
||||
} catch (e: IOException) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLayerVisibility(layerId: String) {
|
||||
var toggledLayer: MapLayerItem? = null
|
||||
val updatedLayers =
|
||||
_mapLayers.value.map {
|
||||
if (it.id == layerId) {
|
||||
toggledLayer = it.copy(isVisible = !it.isVisible)
|
||||
toggledLayer
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
_mapLayers.value = updatedLayers
|
||||
|
||||
toggledLayer?.let {
|
||||
if (it.isVisible) {
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
|
||||
} else {
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMapLayer(layerId: String) {
|
||||
viewModelScope.launch {
|
||||
val layerToRemove = _mapLayers.value.find { it.id == layerId }
|
||||
layerToRemove?.uri?.let { uri ->
|
||||
if (layerToRemove.isNetwork) {
|
||||
googleMapsPrefs.setNetworkMapLayers(
|
||||
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
|
||||
)
|
||||
} else {
|
||||
deleteFileToInternalStorage(uri)
|
||||
}
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
|
||||
}
|
||||
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMapLayer(layerId: String) {
|
||||
viewModelScope.launch {
|
||||
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
|
||||
// By resetting the layer data in the UI (implied by just refreshing),
|
||||
// we trigger a reload in the Composable.
|
||||
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAllVisibleNetworkLayers() {
|
||||
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
|
||||
}
|
||||
|
||||
private suspend fun deleteFileToInternalStorage(uri: Uri) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = uri.toFile()
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Recycle")
|
||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
||||
val uriToLoad = layerItem.uri ?: return null
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
|
||||
val url = java.net.URL(uriToLoad.toString())
|
||||
java.io.BufferedInputStream(url.openStream())
|
||||
} else {
|
||||
application.contentResolver.openInputStream(uriToLoad)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
(currentTileProvider as? MBTilesProvider)?.close()
|
||||
}
|
||||
|
||||
override fun getUser(userId: String?) =
|
||||
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
|
||||
}
|
||||
|
||||
enum class LayerType {
|
||||
KML,
|
||||
GEOJSON,
|
||||
}
|
||||
|
||||
data class MapLayerItem(
|
||||
val id: String = Uuid.random().toString(),
|
||||
val name: String,
|
||||
val uri: Uri? = null,
|
||||
val isVisible: Boolean = true,
|
||||
val layerType: LayerType,
|
||||
val isNetwork: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.model.NodeClusterItem
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.nodes_at_this_location
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
|
||||
@Composable
|
||||
fun ClusterItemsListDialog(
|
||||
items: List<NodeClusterItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (NodeClusterItem) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(Res.string.nodes_at_this_location)) },
|
||||
text = {
|
||||
// Use a LazyColumn for potentially long lists of items
|
||||
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(items, key = { it.node.num }) { item ->
|
||||
ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
leadingContent = { NodeChip(node = item.node) },
|
||||
headlineContent = { Text(item.nodeTitle) },
|
||||
supportingContent = {
|
||||
if (item.nodeSnippet.isNotBlank()) {
|
||||
Text(item.nodeSnippet)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapLayerItem
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_layer
|
||||
import org.meshtastic.core.resources.add_network_layer
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.hide_layer
|
||||
import org.meshtastic.core.resources.manage_map_layers
|
||||
import org.meshtastic.core.resources.map_layer_formats
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.network_layer_url_hint
|
||||
import org.meshtastic.core.resources.no_map_layers_loaded
|
||||
import org.meshtastic.core.resources.refresh
|
||||
import org.meshtastic.core.resources.remove_layer
|
||||
import org.meshtastic.core.resources.save
|
||||
import org.meshtastic.core.resources.show_layer
|
||||
import org.meshtastic.core.resources.url
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun CustomMapLayersSheet(
|
||||
mapLayers: List<MapLayerItem>,
|
||||
onToggleVisibility: (String) -> Unit,
|
||||
onRemoveLayer: (String) -> Unit,
|
||||
onAddLayerClicked: () -> Unit,
|
||||
onRefreshLayer: (String) -> Unit,
|
||||
onAddNetworkLayer: (String, String) -> Unit,
|
||||
) {
|
||||
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = stringResource(Res.string.manage_map_layers),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
|
||||
text = stringResource(Res.string.map_layer_formats),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (mapLayers.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = stringResource(Res.string.no_map_layers_loaded),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(mapLayers, key = { it.id }) { layer ->
|
||||
ListItem(
|
||||
headlineContent = { Text(layer.name) },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (layer.isNetwork) {
|
||||
if (layer.isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).padding(4.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { onRefreshLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(Res.string.refresh),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onToggleVisibility(layer.id) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (layer.isVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (layer.isVisible) {
|
||||
Res.string.hide_layer
|
||||
} else {
|
||||
Res.string.show_layer
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onRemoveLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(Res.string.remove_layer),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
|
||||
Text(stringResource(Res.string.add_layer))
|
||||
}
|
||||
Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
|
||||
Text(stringResource(Res.string.add_network_layer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddNetworkLayerDialog) {
|
||||
AddNetworkLayerDialog(
|
||||
onDismiss = { showAddNetworkLayerDialog = false },
|
||||
onConfirm = { name, url ->
|
||||
onAddNetworkLayer(name, url)
|
||||
showAddNetworkLayerDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(Res.string.add_network_layer),
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(Res.string.name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onConfirm = { onConfirm(name, url) },
|
||||
confirmTextRes = Res.string.save,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_custom_tile_source
|
||||
import org.meshtastic.core.resources.add_local_mbtiles_file
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.delete_custom_tile_source
|
||||
import org.meshtastic.core.resources.edit_custom_tile_source
|
||||
import org.meshtastic.core.resources.local_mbtiles_file
|
||||
import org.meshtastic.core.resources.manage_custom_tile_sources
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.name_cannot_be_empty
|
||||
import org.meshtastic.core.resources.no_custom_tile_sources_found
|
||||
import org.meshtastic.core.resources.provider_name_exists
|
||||
import org.meshtastic.core.resources.save
|
||||
import org.meshtastic.core.resources.url_cannot_be_empty
|
||||
import org.meshtastic.core.resources.url_must_contain_placeholders
|
||||
import org.meshtastic.core.resources.url_template
|
||||
import org.meshtastic.core.resources.url_template_hint
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val mbtilesPickerLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
val fileName = uri.getFileName(context)
|
||||
val baseName = fileName.substringBeforeLast('.')
|
||||
mapViewModel.addCustomTileProvider(
|
||||
name = baseName,
|
||||
urlTemplate = "", // Empty for local
|
||||
localUri = uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
|
||||
|
||||
if (showEditDialog) {
|
||||
AddEditCustomTileProviderDialog(
|
||||
config = editingConfig,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { name, url ->
|
||||
if (editingConfig == null) { // Adding new
|
||||
mapViewModel.addCustomTileProvider(name, url)
|
||||
} else { // Editing existing
|
||||
mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
|
||||
}
|
||||
showEditDialog = false
|
||||
},
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.manage_custom_tile_sources),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (customTileProviders.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.no_custom_tile_sources_found),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(customTileProviders, key = { it.id }) { config ->
|
||||
ListItem(
|
||||
headlineContent = { Text(config.name) },
|
||||
supportingContent = {
|
||||
if (config.isLocal) {
|
||||
Text(
|
||||
stringResource(Res.string.local_mbtiles_file),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
editingConfig = config
|
||||
showEditDialog = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(Res.string.edit_custom_tile_source),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(Res.string.delete_custom_tile_source),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
editingConfig = null
|
||||
showEditDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.add_custom_tile_source))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
}
|
||||
mbtilesPickerLauncher.launch(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.add_local_mbtiles_file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun AddEditCustomTileProviderDialog(
|
||||
config: CustomTileProviderConfig?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String) -> Unit,
|
||||
mapViewModel: MapViewModel,
|
||||
) {
|
||||
var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
|
||||
var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
var urlError by remember { mutableStateOf<String?>(null) }
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
|
||||
val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
|
||||
val providerNameExistsError = stringResource(Res.string.provider_name_exists)
|
||||
val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
|
||||
val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
|
||||
|
||||
fun validateAndSave() {
|
||||
val currentNameError =
|
||||
validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
|
||||
val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
|
||||
|
||||
nameError = currentNameError
|
||||
urlError = currentUrlError
|
||||
|
||||
if (currentNameError == null && currentUrlError == null) {
|
||||
onSave(name, url)
|
||||
}
|
||||
}
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title =
|
||||
if (config == null) {
|
||||
stringResource(Res.string.add_custom_tile_source)
|
||||
} else {
|
||||
stringResource(Res.string.edit_custom_tile_source)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
nameError = null
|
||||
},
|
||||
label = { Text(stringResource(Res.string.name)) },
|
||||
isError = nameError != null,
|
||||
supportingText = { nameError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
urlError = null
|
||||
},
|
||||
label = { Text(stringResource(Res.string.url_template)) },
|
||||
isError = urlError != null,
|
||||
supportingText = {
|
||||
if (urlError != null) {
|
||||
Text(urlError!!)
|
||||
} else {
|
||||
Text(stringResource(Res.string.url_template_hint))
|
||||
}
|
||||
},
|
||||
singleLine = false,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
},
|
||||
onConfirm = { validateAndSave() },
|
||||
confirmTextRes = Res.string.save,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateName(
|
||||
name: String,
|
||||
providers: List<CustomTileProviderConfig>,
|
||||
currentId: String?,
|
||||
emptyNameError: String,
|
||||
nameExistsError: String,
|
||||
): String? = if (name.isBlank()) {
|
||||
emptyNameError
|
||||
} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
|
||||
nameExistsError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
|
||||
if (url.isBlank()) {
|
||||
emptyUrlError
|
||||
} else if (
|
||||
!url.contains("{z}", ignoreCase = true) ||
|
||||
!url.contains("{x}", ignoreCase = true) ||
|
||||
!url.contains("{y}", ignoreCase = true)
|
||||
) {
|
||||
mustContainPlaceholdersError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun android.net.Uri.getFileName(context: android.content.Context): String {
|
||||
var name = this.lastPathSegment ?: "mbtiles_file"
|
||||
if (this.scheme == "content") {
|
||||
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex != -1) {
|
||||
name = cursor.getString(displayNameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CalendarMonth
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.systemTimeZone
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.date
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.description
|
||||
import org.meshtastic.core.resources.expires
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.send
|
||||
import org.meshtastic.core.resources.time
|
||||
import org.meshtastic.core.resources.waypoint_edit
|
||||
import org.meshtastic.core.resources.waypoint_new
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
|
||||
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
|
||||
val currentEmojiCodepoint = if ((waypointInput.icon ?: 0) == 0) defaultEmoji else waypointInput.icon!!
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val tz = systemTimeZone
|
||||
|
||||
// Initialize date and time states from waypointInput.expire
|
||||
var selectedDateString by remember { mutableStateOf("") }
|
||||
var selectedTimeString by remember { mutableStateOf("") }
|
||||
var isExpiryEnabled by remember {
|
||||
mutableStateOf((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
|
||||
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
|
||||
dateFormat.timeZone = java.util.TimeZone.getDefault()
|
||||
timeFormat.timeZone = java.util.TimeZone.getDefault()
|
||||
|
||||
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
|
||||
val expireValue = waypointInput.expire ?: 0
|
||||
if (isExpiryEnabled) {
|
||||
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
|
||||
val instant = Instant.fromEpochSeconds(expireValue.toLong())
|
||||
val date = java.util.Date(instant.toEpochMilliseconds())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
selectedTimeString = timeFormat.format(date)
|
||||
} else { // If enabled but not set, default to 8 hours from now
|
||||
val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
val date = java.util.Date(futureInstant.toEpochMilliseconds())
|
||||
selectedDateString = dateFormat.format(date)
|
||||
selectedTimeString = timeFormat.format(date)
|
||||
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
|
||||
}
|
||||
} else {
|
||||
selectedDateString = ""
|
||||
selectedTimeString = ""
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = waypointInput.name ?: "",
|
||||
onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) },
|
||||
label = { Text(stringResource(Res.string.name)) },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(currentEmojiCodepoint)),
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
.padding(6.dp),
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = waypointInput.description ?: "",
|
||||
onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) },
|
||||
label = { Text(stringResource(Res.string.description)) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 3,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Rounded.Lock,
|
||||
contentDescription = stringResource(Res.string.locked),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.locked))
|
||||
}
|
||||
Switch(
|
||||
checked = (waypointInput.locked_to ?: 0) != 0,
|
||||
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Rounded.CalendarMonth,
|
||||
contentDescription = stringResource(Res.string.expires),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.expires))
|
||||
}
|
||||
Switch(
|
||||
checked = isExpiryEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
isExpiryEnabled = checked
|
||||
if (checked) {
|
||||
val expireValue = waypointInput.expire ?: 0
|
||||
// Default to 8 hours from now if not already set
|
||||
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
|
||||
val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
|
||||
}
|
||||
} else {
|
||||
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpiryEnabled) {
|
||||
val currentInstant =
|
||||
(waypointInput.expire ?: 0).let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
val ldt = currentInstant.toLocalDateTime(tz)
|
||||
|
||||
val datePickerDialog =
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
val currentLdt =
|
||||
(waypointInput.expire ?: 0)
|
||||
.let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
.toLocalDateTime(tz)
|
||||
|
||||
val newLdt =
|
||||
LocalDate(
|
||||
year = selectedYear,
|
||||
month = Month(selectedMonth + 1),
|
||||
day = selectedDay,
|
||||
)
|
||||
.atTime(
|
||||
hour = currentLdt.hour,
|
||||
minute = currentLdt.minute,
|
||||
second = currentLdt.second,
|
||||
nanosecond = currentLdt.nanosecond,
|
||||
)
|
||||
waypointInput =
|
||||
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.year,
|
||||
ldt.month.number - 1,
|
||||
ldt.day,
|
||||
)
|
||||
|
||||
val timePickerDialog =
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
val currentLdt =
|
||||
(waypointInput.expire ?: 0)
|
||||
.let {
|
||||
if (it != 0 && it != Int.MAX_VALUE) {
|
||||
Instant.fromEpochSeconds(it.toLong())
|
||||
} else {
|
||||
kotlinx.datetime.Clock.System.now() + 8.hours
|
||||
}
|
||||
}
|
||||
.toLocalDateTime(tz)
|
||||
|
||||
val newLdt =
|
||||
LocalDate(
|
||||
year = currentLdt.year,
|
||||
month = currentLdt.month,
|
||||
day = currentLdt.day,
|
||||
)
|
||||
.atTime(
|
||||
hour = selectedHour,
|
||||
minute = selectedMinute,
|
||||
second = currentLdt.second,
|
||||
nanosecond = currentLdt.nanosecond,
|
||||
)
|
||||
waypointInput =
|
||||
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
|
||||
},
|
||||
ldt.hour,
|
||||
ldt.minute,
|
||||
android.text.format.DateFormat.is24HourFormat(context),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedDateString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedTimeString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (waypoint.id != 0) {
|
||||
TextButton(
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
|
||||
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSendClicked(waypointInput) },
|
||||
enabled = (waypointInput.name ?: "").isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(Res.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null, // Using custom buttons in confirmButton Row
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun MapButton(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector,
|
||||
iconTint: Color? = null,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FilledIconButton(onClick = onClick, modifier = modifier) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Navigation
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.Map
|
||||
import androidx.compose.material.icons.outlined.MyLocation
|
||||
import androidx.compose.material.icons.outlined.Navigation
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material.icons.rounded.LocationDisabled
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.manage_map_layers
|
||||
import org.meshtastic.core.resources.map_filter
|
||||
import org.meshtastic.core.resources.map_tile_source
|
||||
import org.meshtastic.core.resources.orient_north
|
||||
import org.meshtastic.core.resources.refresh
|
||||
import org.meshtastic.core.resources.toggle_my_position
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MapControlsOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
mapFilterMenuExpanded: Boolean,
|
||||
onMapFilterMenuDismissRequest: () -> Unit,
|
||||
onToggleMapFilterMenu: () -> Unit,
|
||||
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
|
||||
mapTypeMenuExpanded: Boolean,
|
||||
onMapTypeMenuDismissRequest: () -> Unit,
|
||||
onToggleMapTypeMenu: () -> Unit,
|
||||
onManageLayersClicked: () -> Unit,
|
||||
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
|
||||
isNodeMap: Boolean,
|
||||
// Location tracking parameters
|
||||
isLocationTrackingEnabled: Boolean = false,
|
||||
onToggleLocationTracking: () -> Unit = {},
|
||||
bearing: Float = 0f,
|
||||
onCompassClick: () -> Unit = {},
|
||||
followPhoneBearing: Boolean,
|
||||
showRefresh: Boolean = false,
|
||||
isRefreshing: Boolean = false,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
HorizontalFloatingToolbar(
|
||||
modifier = modifier,
|
||||
expanded = true,
|
||||
leadingContent = {},
|
||||
trailingContent = {},
|
||||
content = {
|
||||
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
|
||||
if (isNodeMap) {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Tune,
|
||||
contentDescription = stringResource(Res.string.map_filter),
|
||||
onClick = onToggleMapFilterMenu,
|
||||
)
|
||||
NodeMapFilterDropdown(
|
||||
expanded = mapFilterMenuExpanded,
|
||||
onDismissRequest = onMapFilterMenuDismissRequest,
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
} else {
|
||||
Box {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Tune,
|
||||
contentDescription = stringResource(Res.string.map_filter),
|
||||
onClick = onToggleMapFilterMenu,
|
||||
)
|
||||
MapFilterDropdown(
|
||||
expanded = mapFilterMenuExpanded,
|
||||
onDismissRequest = onMapFilterMenuDismissRequest,
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Map,
|
||||
contentDescription = stringResource(Res.string.map_tile_source),
|
||||
onClick = onToggleMapTypeMenu,
|
||||
)
|
||||
MapTypeDropdown(
|
||||
expanded = mapTypeMenuExpanded,
|
||||
onDismissRequest = onMapTypeMenuDismissRequest,
|
||||
mapViewModel = mapViewModel, // Pass mapViewModel
|
||||
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
|
||||
)
|
||||
}
|
||||
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Layers,
|
||||
contentDescription = stringResource(Res.string.manage_map_layers),
|
||||
onClick = onManageLayersClicked,
|
||||
)
|
||||
|
||||
if (showRefresh) {
|
||||
if (isRefreshing) {
|
||||
Box(modifier = Modifier.padding(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
} else {
|
||||
MapButton(
|
||||
icon = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(Res.string.refresh),
|
||||
onClick = onRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location tracking button
|
||||
MapButton(
|
||||
icon =
|
||||
if (isLocationTrackingEnabled) {
|
||||
Icons.Rounded.LocationDisabled
|
||||
} else {
|
||||
Icons.Outlined.MyLocation
|
||||
},
|
||||
contentDescription = stringResource(Res.string.toggle_my_position),
|
||||
onClick = onToggleLocationTracking,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
|
||||
val icon = if (isFollowing) Icons.Filled.Navigation else Icons.Outlined.Navigation
|
||||
|
||||
MapButton(
|
||||
modifier = Modifier.rotate(-bearing),
|
||||
icon = icon,
|
||||
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
|
||||
contentDescription = stringResource(Res.string.orient_north),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.last_heard_filter_label
|
||||
import org.meshtastic.core.resources.only_favorites
|
||||
import org.meshtastic.core.resources.show_precision_circle
|
||||
import org.meshtastic.core.resources.show_waypoints
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.only_favorites)) },
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(Res.string.only_favorites))
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_waypoints)) },
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Filled.Place, contentDescription = stringResource(Res.string.show_waypoints))
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_precision_circle)) },
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
|
||||
contentDescription = stringResource(Res.string.show_precision_circle),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val filterOptions = LastHeardFilter.entries
|
||||
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
|
||||
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
|
||||
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
Res.string.last_heard_filter_label,
|
||||
stringResource(mapFilterState.lastHeardFilter.label),
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Slider(
|
||||
value = sliderPosition,
|
||||
onValueChange = { sliderPosition = it },
|
||||
onValueChangeFinished = {
|
||||
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
|
||||
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
|
||||
},
|
||||
valueRange = 0f..(filterOptions.size - 1).toFloat(),
|
||||
steps = filterOptions.size - 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val filterOptions = LastHeardFilter.entries
|
||||
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter)
|
||||
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
|
||||
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
Res.string.last_heard_filter_label,
|
||||
stringResource(mapFilterState.lastHeardTrackFilter.label),
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Slider(
|
||||
value = sliderPosition,
|
||||
onValueChange = { sliderPosition = it },
|
||||
onValueChangeFinished = {
|
||||
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
|
||||
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
|
||||
},
|
||||
valueRange = 0f..(filterOptions.size - 1).toFloat(),
|
||||
steps = filterOptions.size - 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.maps.android.compose.MapType
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.manage_custom_tile_sources
|
||||
import org.meshtastic.core.resources.map_type_hybrid
|
||||
import org.meshtastic.core.resources.map_type_normal
|
||||
import org.meshtastic.core.resources.map_type_satellite
|
||||
import org.meshtastic.core.resources.map_type_terrain
|
||||
import org.meshtastic.core.resources.selected_map_type
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MapTypeDropdown(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
mapViewModel: MapViewModel,
|
||||
onManageCustomTileProvidersClicked: () -> Unit,
|
||||
) {
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
|
||||
val googleMapTypes =
|
||||
listOf(
|
||||
stringResource(Res.string.map_type_normal) to MapType.NORMAL,
|
||||
stringResource(Res.string.map_type_satellite) to MapType.SATELLITE,
|
||||
stringResource(Res.string.map_type_terrain) to MapType.TERRAIN,
|
||||
stringResource(Res.string.map_type_hybrid) to MapType.HYBRID,
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
googleMapTypes.forEach { (name, type) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
mapViewModel.setSelectedGoogleMapType(type)
|
||||
onDismissRequest() // Close menu
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = stringResource(Res.string.selected_map_type)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (customTileProviders.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
customTileProviders.forEach { config ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(config.name) },
|
||||
onClick = {
|
||||
mapViewModel.selectCustomTileProvider(config)
|
||||
onDismissRequest() // Close menu
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == config.urlTemplate) {
|
||||
{
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(Res.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
|
||||
onClick = {
|
||||
onManageCustomTileProvidersClicked()
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
|
||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.clustering.view.DefaultClusterRenderer
|
||||
import com.google.maps.android.compose.Circle
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.clustering.Clustering
|
||||
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
|
||||
import org.meshtastic.app.map.model.NodeClusterItem
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
@Suppress("NestedBlockDepth")
|
||||
@Composable
|
||||
fun NodeClusterMarkers(
|
||||
nodeClusterItems: List<NodeClusterItem>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
|
||||
|
||||
// Workaround for https://github.com/googlemaps/android-maps-compose/issues/858
|
||||
// The maps clustering library creates an internal ComposeView to snapshot markers.
|
||||
// If that view is not attached to the hierarchy (which it often isn't during rendering),
|
||||
// it fails to find the Lifecycle and SavedState owners. We propagate them to the root view
|
||||
// so the internal snapshot view can find them when walking up the tree.
|
||||
LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) {
|
||||
val root = view.rootView
|
||||
if (root.findViewTreeLifecycleOwner() == null) {
|
||||
root.setViewTreeLifecycleOwner(lifecycleOwner)
|
||||
}
|
||||
if (root.findViewTreeSavedStateRegistryOwner() == null) {
|
||||
root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
|
||||
}
|
||||
}
|
||||
|
||||
Clustering(
|
||||
items = nodeClusterItems,
|
||||
onClusterClick = onClusterClick,
|
||||
onClusterItemInfoWindowClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
false
|
||||
},
|
||||
clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
|
||||
onClusterManager = { clusterManager ->
|
||||
(clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
|
||||
},
|
||||
clusterItemDecoration = { clusterItem ->
|
||||
if (mapFilterState.showPrecisionCircle) {
|
||||
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
|
||||
if (precisionMeters > 0) {
|
||||
Circle(
|
||||
center = clusterItem.position,
|
||||
radius = precisionMeters,
|
||||
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
|
||||
strokeColor = Color(clusterItem.node.colors.second),
|
||||
strokeWidth = 2f,
|
||||
zIndex = 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
|
||||
ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
|
||||
@Composable
|
||||
fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) {
|
||||
val animatedProgress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(node) {
|
||||
if ((nowSeconds - node.lastHeard) <= 5) {
|
||||
launch {
|
||||
animatedProgress.snapTo(0f)
|
||||
animatedProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier.drawWithContent {
|
||||
drawContent()
|
||||
if (animatedProgress.value > 0 && animatedProgress.value < 1f) {
|
||||
val alpha = (1f - animatedProgress.value) * 0.3f
|
||||
drawRoundRect(
|
||||
size = size,
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
color = Color.White.copy(alpha = alpha),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
NodeChip(node = node)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.Marker
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.locked
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
private const val DEG_D = 1e-7
|
||||
|
||||
@Composable
|
||||
fun WaypointMarkers(
|
||||
displayableWaypoints: List<Waypoint>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
myNodeNum: Int,
|
||||
isConnected: Boolean,
|
||||
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
|
||||
onEditWaypointRequest: (Waypoint) -> Unit,
|
||||
selectedWaypointId: Int? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
if (mapFilterState.showWaypoints) {
|
||||
displayableWaypoints.forEach { waypoint ->
|
||||
val markerState =
|
||||
rememberUpdatedMarkerState(
|
||||
position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D),
|
||||
)
|
||||
|
||||
LaunchedEffect(selectedWaypointId) {
|
||||
if (selectedWaypointId == waypoint.id) {
|
||||
markerState.showInfoWindow()
|
||||
}
|
||||
}
|
||||
|
||||
Marker(
|
||||
state = markerState,
|
||||
icon =
|
||||
if ((waypoint.icon ?: 0) == 0) {
|
||||
unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
|
||||
} else {
|
||||
unicodeEmojiToBitmapProvider(waypoint.icon!!)
|
||||
},
|
||||
title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '),
|
||||
snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '),
|
||||
visible = true,
|
||||
onInfoWindowClick = {
|
||||
if ((waypoint.locked_to ?: 0) == 0 || waypoint.locked_to == myNodeNum || !isConnected) {
|
||||
onEditWaypointRequest(waypoint)
|
||||
} else {
|
||||
scope.launch { context.showToast(Res.string.locked) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Serializable
|
||||
data class CustomTileProviderConfig(
|
||||
val id: String = Uuid.random().toString(),
|
||||
val name: String,
|
||||
val urlTemplate: String,
|
||||
val localUri: String? = null,
|
||||
) {
|
||||
val isLocal: Boolean
|
||||
get() = localUri != null
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
fun getTileSource(index: Int) {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.model
|
||||
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import org.meshtastic.core.model.Node
|
||||
|
||||
data class NodeClusterItem(
|
||||
val node: Node,
|
||||
val nodePosition: LatLng,
|
||||
val nodeTitle: String,
|
||||
val nodeSnippet: String,
|
||||
val myNodeNum: Int? = null,
|
||||
) : ClusterItem {
|
||||
override fun getPosition(): LatLng = nodePosition
|
||||
|
||||
override fun getTitle(): String = nodeTitle
|
||||
|
||||
override fun getSnippet(): String = nodeSnippet
|
||||
|
||||
override fun getZIndex(): Float = when {
|
||||
node.num == myNodeNum -> 5.0f // My node is always highest
|
||||
node.isFavorite -> 5.0f // Favorites are equally high priority
|
||||
else -> 4.0f
|
||||
}
|
||||
|
||||
fun getPrecisionMeters(): Double? {
|
||||
val precisionMap =
|
||||
mapOf(
|
||||
10 to 23345.484932,
|
||||
11 to 11672.7369,
|
||||
12 to 5836.36288,
|
||||
13 to 2918.175876,
|
||||
14 to 1459.0823719999053,
|
||||
15 to 729.53562,
|
||||
16 to 364.7622,
|
||||
17 to 182.375556,
|
||||
18 to 91.182212,
|
||||
19 to 45.58554,
|
||||
)
|
||||
return precisionMap[this.node.position.precision_bits ?: 0]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.node
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.app.map.MapView
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
|
||||
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
val destNum = node?.num
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = node?.user?.long_name ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.prefs.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.SharedPreferencesMigration
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
|
||||
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefsImpl
|
||||
import org.meshtastic.app.map.repository.CustomTileProviderRepository
|
||||
import org.meshtastic.app.map.repository.CustomTileProviderRepositoryImpl
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class GoogleMapsDataStore
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
interface GoogleMapsModule {
|
||||
|
||||
@Binds fun bindGoogleMapsPrefs(googleMapsPrefsImpl: GoogleMapsPrefsImpl): GoogleMapsPrefs
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindCustomTileProviderRepository(impl: CustomTileProviderRepositoryImpl): CustomTileProviderRepository
|
||||
|
||||
companion object {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@GoogleMapsDataStore
|
||||
fun provideGoogleMapsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
|
||||
scope = scope,
|
||||
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.prefs.map
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.google.maps.android.compose.MapType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.app.map.prefs.di.GoogleMapsDataStore
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
|
||||
interface GoogleMapsPrefs {
|
||||
val selectedGoogleMapType: StateFlow<String?>
|
||||
|
||||
fun setSelectedGoogleMapType(value: String?)
|
||||
|
||||
val selectedCustomTileUrl: StateFlow<String?>
|
||||
|
||||
fun setSelectedCustomTileUrl(value: String?)
|
||||
|
||||
val hiddenLayerUrls: StateFlow<Set<String>>
|
||||
|
||||
fun setHiddenLayerUrls(value: Set<String>)
|
||||
|
||||
val cameraTargetLat: StateFlow<Double>
|
||||
|
||||
fun setCameraTargetLat(value: Double)
|
||||
|
||||
val cameraTargetLng: StateFlow<Double>
|
||||
|
||||
fun setCameraTargetLng(value: Double)
|
||||
|
||||
val cameraZoom: StateFlow<Float>
|
||||
|
||||
fun setCameraZoom(value: Float)
|
||||
|
||||
val cameraTilt: StateFlow<Float>
|
||||
|
||||
fun setCameraTilt(value: Float)
|
||||
|
||||
val cameraBearing: StateFlow<Float>
|
||||
|
||||
fun setCameraBearing(value: Float)
|
||||
|
||||
val networkMapLayers: StateFlow<Set<String>>
|
||||
|
||||
fun setNetworkMapLayers(value: Set<String>)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class GoogleMapsPrefsImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@GoogleMapsDataStore private val dataStore: DataStore<Preferences>,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : GoogleMapsPrefs {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
override val selectedGoogleMapType: StateFlow<String?> =
|
||||
dataStore.data
|
||||
.map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
|
||||
.stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
|
||||
|
||||
override fun setSelectedGoogleMapType(value: String?) {
|
||||
scope.launch {
|
||||
dataStore.edit { prefs ->
|
||||
if (value == null) {
|
||||
prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
|
||||
} else {
|
||||
prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val selectedCustomTileUrl: StateFlow<String?> =
|
||||
dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
override fun setSelectedCustomTileUrl(value: String?) {
|
||||
scope.launch {
|
||||
dataStore.edit { prefs ->
|
||||
if (value == null) {
|
||||
prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
|
||||
} else {
|
||||
prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val hiddenLayerUrls: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setHiddenLayerUrls(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraTargetLat: StateFlow<Double> =
|
||||
dataStore.data.map { it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
|
||||
|
||||
override fun setCameraTargetLat(value: Double) {
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraTargetLng: StateFlow<Double> =
|
||||
dataStore.data.map { it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
|
||||
|
||||
override fun setCameraTargetLng(value: Double) {
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraZoom: StateFlow<Float> =
|
||||
dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
|
||||
|
||||
override fun setCameraZoom(value: Float) {
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraTilt: StateFlow<Float> =
|
||||
dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
|
||||
|
||||
override fun setCameraTilt(value: Float) {
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraBearing: StateFlow<Float> =
|
||||
dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
|
||||
|
||||
override fun setCameraBearing(value: Float) {
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
|
||||
}
|
||||
|
||||
override val networkMapLayers: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setNetworkMapLayers(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
|
||||
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
|
||||
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
|
||||
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
|
||||
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
|
||||
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
|
||||
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
|
||||
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
|
||||
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.meshtastic.app.map.model.CustomTileProviderConfig
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MapTileProviderPrefs
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
interface CustomTileProviderRepository {
|
||||
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
|
||||
|
||||
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
|
||||
|
||||
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
|
||||
|
||||
suspend fun deleteCustomTileProvider(configId: String)
|
||||
|
||||
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class CustomTileProviderRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val json: Json,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val mapTileProviderPrefs: MapTileProviderPrefs,
|
||||
) : CustomTileProviderRepository {
|
||||
|
||||
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
|
||||
|
||||
init {
|
||||
loadDataFromPrefs()
|
||||
}
|
||||
|
||||
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
|
||||
customTileProvidersStateFlow.asStateFlow()
|
||||
|
||||
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value + config
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun deleteCustomTileProvider(configId: String) {
|
||||
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
|
||||
customTileProvidersStateFlow.value.find { it.id == configId }
|
||||
|
||||
private fun loadDataFromPrefs() {
|
||||
val jsonString = mapTileProviderPrefs.customTileProviders.value
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Logger.e(e) { "Error deserializing tile providers" }
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
} else {
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
val jsonString = json.encodeToString(providers)
|
||||
mapTileProviderPrefs.setCustomTileProviders(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Logger.e(e) { "Error serializing tile providers" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import co.touchlab.kermit.Logger
|
||||
|
|
@ -47,14 +48,23 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
|
||||
import org.meshtastic.app.intro.AnalyticsIntro
|
||||
import org.meshtastic.app.intro.AndroidIntroViewModel
|
||||
import org.meshtastic.app.map.getMapViewProvider
|
||||
import org.meshtastic.app.model.UIViewModel
|
||||
import org.meshtastic.app.ui.MainScreen
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
|
||||
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
|
||||
import org.meshtastic.core.ui.util.LocalMapViewProvider
|
||||
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.intro.AppIntroductionScreen
|
||||
import javax.inject.Inject
|
||||
|
|
@ -108,7 +118,13 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
|
||||
@Suppress("SpreadOperator")
|
||||
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
|
||||
CompositionLocalProvider(
|
||||
*(LocalEnvironmentOwner provides androidEnvironment),
|
||||
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
|
||||
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
|
||||
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
|
||||
LocalMapViewProvider provides getMapViewProvider(),
|
||||
) {
|
||||
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
|
||||
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -119,7 +135,8 @@ class MainActivity : ComponentActivity() {
|
|||
if (appIntroCompleted) {
|
||||
MainScreen(uIViewModel = model)
|
||||
} else {
|
||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
|
||||
val introViewModel = hiltViewModel<AndroidIntroViewModel>()
|
||||
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
app/src/main/kotlin/org/meshtastic/app/di/BleModule.kt
Normal file
75
app/src/main/kotlin/org/meshtastic/app/di/BleModule.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.native
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
|
||||
import org.meshtastic.core.ble.AndroidBleConnectionFactory
|
||||
import org.meshtastic.core.ble.AndroidBleScanner
|
||||
import org.meshtastic.core.ble.AndroidBluetoothRepository
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class BleModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindBleScanner(impl: AndroidBleScanner): BleScanner
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindBluetoothRepository(impl: AndroidBluetoothRepository): BluetoothRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindBleConnectionFactory(impl: AndroidBleConnectionFactory): BleConnectionFactory
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
|
||||
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
|
||||
CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
|
||||
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
|
||||
|
||||
@Provides
|
||||
fun provideBleConnection(factory: BleConnectionFactory, coroutineScope: CoroutineScope): BleConnection =
|
||||
factory.create(coroutineScope, "BLE")
|
||||
}
|
||||
}
|
||||
38
app/src/main/kotlin/org/meshtastic/app/di/ServiceModule.kt
Normal file
38
app/src/main/kotlin/org/meshtastic/app/di/ServiceModule.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.service.AndroidRadioControllerImpl
|
||||
import org.meshtastic.core.service.AndroidServiceRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ServiceModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindServiceRepository(impl: AndroidServiceRepository): ServiceRepository
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ constructor(
|
|||
val matchingNode =
|
||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||
db.values.find { node ->
|
||||
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
|
||||
val suffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
|
||||
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.intro
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.meshtastic.feature.intro.IntroViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Android-specific Hilt wrapper for IntroViewModel. */
|
||||
@HiltViewModel class AndroidIntroViewModel @Inject constructor() : IntroViewModel()
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.feature.map.SharedMapViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AndroidSharedMapViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
mapPrefs: MapPrefs,
|
||||
nodeRepository: NodeRepository,
|
||||
packetRepository: PacketRepository,
|
||||
radioController: RadioController,
|
||||
) : SharedMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.node
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.navigation.toRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.ui.util.toPosition
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NodeMapViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
nodeRepository: NodeRepository,
|
||||
meshLogRepository: MeshLogRepository,
|
||||
buildConfigProvider: BuildConfigProvider,
|
||||
private val mapPrefs: MapPrefs,
|
||||
) : ViewModel() {
|
||||
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
|
||||
|
||||
val node =
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { it[destNum] }
|
||||
.distinctUntilChanged()
|
||||
.stateInWhileSubscribed(initialValue = null)
|
||||
|
||||
val applicationId = buildConfigProvider.applicationId
|
||||
|
||||
private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
|
||||
|
||||
val positionLogs: StateFlow<List<Position>> =
|
||||
ourNodeNumFlow
|
||||
.map { if (destNum == it) MeshLog.NODE_NUM_LOCAL else destNum!! }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { logId ->
|
||||
meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets ->
|
||||
packets
|
||||
.mapNotNull { it.toPosition() }
|
||||
.asFlow()
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.time == new.time ||
|
||||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val mapStyleId: Int
|
||||
get() = mapPrefs.mapStyle.value
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AndroidContactsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
nodeRepository: NodeRepository,
|
||||
packetRepository: PacketRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
) : ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.meshtastic.core.data.repository.QuickChatActionRepository
|
||||
import org.meshtastic.core.repository.CustomEmojiPrefs
|
||||
import org.meshtastic.core.repository.HomoglyphPrefs
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import org.meshtastic.feature.messaging.MessageViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@HiltViewModel
|
||||
class AndroidMessageViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
quickChatActionRepository: QuickChatActionRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
packetRepository: PacketRepository,
|
||||
uiPrefs: UiPrefs,
|
||||
customEmojiPrefs: CustomEmojiPrefs,
|
||||
homoglyphEncodingPrefs: HomoglyphPrefs,
|
||||
meshServiceNotifications: MeshServiceNotifications,
|
||||
sendMessageUseCase: SendMessageUseCase,
|
||||
) : MessageViewModel(
|
||||
savedStateHandle,
|
||||
nodeRepository,
|
||||
radioConfigRepository,
|
||||
quickChatActionRepository,
|
||||
serviceRepository,
|
||||
packetRepository,
|
||||
uiPrefs,
|
||||
customEmojiPrefs,
|
||||
homoglyphEncodingPrefs,
|
||||
meshServiceNotifications,
|
||||
sendMessageUseCase,
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.meshtastic.core.data.repository.QuickChatActionRepository
|
||||
import org.meshtastic.feature.messaging.QuickChatViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AndroidQuickChatViewModel @Inject constructor(quickChatActionRepository: QuickChatActionRepository) :
|
||||
QuickChatViewModel(quickChatActionRepository)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.app.messaging.domain.worker.WorkManagerMessageQueue
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class MessagingModule {
|
||||
|
||||
@Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging.domain.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
|
||||
@HiltWorker
|
||||
class SendMessageWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val radioController: RadioController,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
|
||||
override suspend fun doWork(): Result {
|
||||
val packetId = inputData.getInt(KEY_PACKET_ID, 0)
|
||||
if (packetId == 0) return Result.failure()
|
||||
|
||||
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
|
||||
if (radioController.connectionState.value != ConnectionState.Connected) {
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
val packetData =
|
||||
packetRepository.getPacketByPacketId(packetId)
|
||||
?: return Result.failure() // Packet no longer exists in DB? Do not retry.
|
||||
|
||||
return try {
|
||||
radioController.sendMessage(packetData)
|
||||
packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
packetRepository.updateMessageStatus(packetData, MessageStatus.QUEUED)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_PACKET_ID = "packet_id"
|
||||
const val WORK_NAME_PREFIX = "send_message_"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging.domain.worker
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.meshtastic.core.repository.MessageQueue
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
|
||||
@Singleton
|
||||
class WorkManagerMessageQueue @Inject constructor(private val workManager: WorkManager) : MessageQueue {
|
||||
|
||||
override suspend fun enqueue(packetId: Int) {
|
||||
val workRequest =
|
||||
OneTimeWorkRequestBuilder<SendMessageWorker>()
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
"${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,7 @@ package org.meshtastic.app.model
|
|||
|
||||
import android.hardware.usb.UsbManager
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.BondState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.Node
|
||||
|
|
@ -50,15 +49,14 @@ sealed class DeviceListEntry(
|
|||
override fun toString(): String =
|
||||
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
|
||||
data class Ble(val device: BleDevice, override val node: Node? = null) :
|
||||
DeviceListEntry(
|
||||
name = peripheral.name ?: "unnamed-${peripheral.address}",
|
||||
fullAddress = "x${peripheral.address}",
|
||||
bonded = peripheral.bondState.value == BondState.BONDED,
|
||||
name = device.name ?: "unnamed-${device.address}",
|
||||
fullAddress = "x${device.address}",
|
||||
bonded = device.isBonded,
|
||||
node = node,
|
||||
) {
|
||||
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
|
||||
override fun copy(node: Node?): Ble = copy(device = device, node = node)
|
||||
}
|
||||
|
||||
data class Usb(
|
||||
|
|
@ -95,4 +93,4 @@ private val bleNameRegex = Regex(BLE_NAME_PATTERN)
|
|||
*
|
||||
* @return The short name (e.g., 1234) or null.
|
||||
*/
|
||||
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
|
||||
fun BleDevice.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import androidx.navigation.navDeepLink
|
|||
import androidx.navigation.navigation
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.app.messaging.AndroidContactsViewModel
|
||||
import org.meshtastic.app.messaging.AndroidMessageViewModel
|
||||
import org.meshtastic.app.messaging.AndroidQuickChatViewModel
|
||||
import org.meshtastic.app.model.UIViewModel
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
|
|
@ -43,9 +46,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
|
||||
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
contactsViewModel = contactsViewModel,
|
||||
messageViewModel = messageViewModel,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
|
|
@ -67,9 +74,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
|
||||
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
contactsViewModel = contactsViewModel,
|
||||
messageViewModel = messageViewModel,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
|
|
@ -90,7 +101,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
),
|
||||
) { backStackEntry ->
|
||||
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
|
||||
val viewModel = hiltViewModel<AndroidContactsViewModel>()
|
||||
ShareScreen(
|
||||
viewModel = viewModel,
|
||||
onConfirm = {
|
||||
navController.navigate(ContactsRoutes.Messages(it, message)) {
|
||||
popUpTo<ContactsRoutes.Share> { inclusive = true }
|
||||
|
|
@ -102,6 +115,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
composable<ContactsRoutes.QuickChat>(
|
||||
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
|
||||
) {
|
||||
QuickChatScreen(onNavigateUp = navController::navigateUp)
|
||||
val viewModel = hiltViewModel<AndroidQuickChatViewModel>()
|
||||
QuickChatScreen(viewModel = viewModel, onNavigateUp = navController::navigateUp)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.app.navigation
|
||||
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navDeepLink
|
||||
import org.meshtastic.app.map.AndroidSharedMapViewModel
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.navigation.MapRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
|
|
@ -27,7 +29,9 @@ import org.meshtastic.feature.map.MapScreen
|
|||
|
||||
fun NavGraphBuilder.mapGraph(navController: NavHostController) {
|
||||
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
|
||||
val viewModel = hiltViewModel<AndroidSharedMapViewModel>()
|
||||
MapScreen(
|
||||
viewModel = viewModel,
|
||||
onClickNodeChip = {
|
||||
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
|
||||
launchSingleTop = true
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ import androidx.navigation.navDeepLink
|
|||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.app.map.node.NodeMapScreen
|
||||
import org.meshtastic.app.map.node.NodeMapViewModel
|
||||
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
|
|
@ -57,8 +59,6 @@ import org.meshtastic.core.resources.power
|
|||
import org.meshtastic.core.resources.signal
|
||||
import org.meshtastic.core.resources.traceroute
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.feature.map.node.NodeMapScreen
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||
|
|
|
|||
|
|
@ -33,12 +33,15 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import org.meshtastic.core.ble.AndroidBleDevice
|
||||
import org.meshtastic.core.ble.AndroidBleService
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
@ -62,7 +65,9 @@ private val SCAN_TIMEOUT = 5.seconds
|
|||
* - Routing raw byte packets between the radio and [RadioInterfaceService].
|
||||
*
|
||||
* @param serviceScope The coroutine scope to use for launching coroutines.
|
||||
* @param centralManager The central manager provided by Nordic BLE Library.
|
||||
* @param scanner The BLE scanner.
|
||||
* @param bluetoothRepository The Bluetooth repository.
|
||||
* @param connectionFactory The BLE connection factory.
|
||||
* @param service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
|
|
@ -71,7 +76,9 @@ class NordicBleInterface
|
|||
@AssistedInject
|
||||
constructor(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val centralManager: CentralManager,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val service: RadioInterfaceService,
|
||||
@Assisted val address: String,
|
||||
) : IRadioInterface {
|
||||
|
|
@ -91,7 +98,7 @@ constructor(
|
|||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
|
||||
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
private var connectionStartTime: Long = 0
|
||||
|
|
@ -106,21 +113,19 @@ constructor(
|
|||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findPeripheral(): Peripheral {
|
||||
centralManager
|
||||
.getBondedPeripherals()
|
||||
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findDevice(): BleDevice {
|
||||
bluetoothRepository.state.value.bondedDevices
|
||||
.firstOrNull { it.address == address }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
val scanner = BleScanner(centralManager)
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
|
||||
if (p != null) return p
|
||||
val d = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
|
||||
if (d != null) return d
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
|
|
@ -138,7 +143,7 @@ constructor(
|
|||
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is ConnectionState.Disconnected) {
|
||||
if (state is BleConnectionState.Disconnected) {
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,9 +153,9 @@ constructor(
|
|||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
val p = findPeripheral()
|
||||
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
|
||||
if (state !is ConnectionState.Connected) {
|
||||
val device = findDevice()
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +163,7 @@ constructor(
|
|||
discoverServicesAndSetupCharacteristics()
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -166,8 +171,9 @@ constructor(
|
|||
|
||||
private suspend fun onConnected() {
|
||||
try {
|
||||
bleConnection.peripheralFlow.first()?.let { p ->
|
||||
val rssi = retryBleOperation(tag = address) { p.readRssi() }
|
||||
bleConnection.deviceFlow.first()?.let { device ->
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
val rssi = retryBleOperation(tag = address) { androidDevice.peripheral.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -175,7 +181,7 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected(state: ConnectionState.Disconnected) {
|
||||
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
|
||||
radioService = null
|
||||
|
||||
val uptime =
|
||||
|
|
@ -185,26 +191,22 @@ constructor(
|
|||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected - Reason: ${state.reason}, " +
|
||||
"[$address] BLE disconnected, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
val (isPermanent, msg) =
|
||||
when (val reason = state.reason) {
|
||||
is ConnectionState.Disconnected.Reason.InsufficientAuthentication ->
|
||||
Pair(true, "Insufficient authentication: please unpair and repair the device")
|
||||
is ConnectionState.Disconnected.Reason.RequiredServiceNotFound ->
|
||||
Pair(false, "Required characteristic missing")
|
||||
else -> Pair(false, reason.toString())
|
||||
}
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
|
||||
// Note: Disconnected state in commonMain doesn't currently carry a reason.
|
||||
// We might want to add that later if needed.
|
||||
service.onDisconnect(false, errorMessage = "Disconnected")
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
|
||||
val radioService = MeshtasticRadioServiceImpl(service)
|
||||
val androidService = (service as AndroidBleService).service
|
||||
val radioService = MeshtasticRadioServiceImpl(androidService)
|
||||
|
||||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
|
|
@ -235,7 +237,7 @@ constructor(
|
|||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@NordicBleInterface.service.onConnect()
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ import androidx.work.ExistingWorkPolicy
|
|||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.meshtastic.app.messaging.domain.worker.SendMessageWorker
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
|
|||
|
|
@ -117,15 +117,15 @@ constructor(
|
|||
|
||||
/** Initiates the bonding process and connects to the device upon success. */
|
||||
private fun requestBonding(entry: DeviceListEntry.Ble) {
|
||||
Logger.i { "Starting bonding for ${entry.peripheral.address.anonymize}" }
|
||||
Logger.i { "Starting bonding for ${entry.device.address.anonymize}" }
|
||||
viewModelScope.launch {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
bluetoothRepository.bond(entry.peripheral)
|
||||
Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." }
|
||||
bluetoothRepository.bond(entry.device)
|
||||
Logger.i { "Bonding complete for ${entry.device.address.anonymize}, selecting device..." }
|
||||
changeDeviceAddress(entry.fullAddress)
|
||||
} catch (ex: SecurityException) {
|
||||
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
|
||||
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" }
|
||||
serviceRepository.setErrorMessage(
|
||||
text = "Bonding failed: ${ex.message} Permissions not granted",
|
||||
severity = Severity.Warn,
|
||||
|
|
@ -135,9 +135,9 @@ constructor(
|
|||
val message = ex.message ?: ""
|
||||
if (message.contains("Received bond state changed 11")) {
|
||||
// This is a known issue where bonding is still in progress, ignore as error
|
||||
Logger.d { "Bonding still in progress for ${entry.peripheral.address.anonymize}" }
|
||||
Logger.d { "Bonding still in progress for ${entry.device.address.anonymize}" }
|
||||
} else {
|
||||
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
|
||||
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize}" }
|
||||
serviceRepository.setErrorMessage(text = "Bonding failed: ${ex.message}", severity = Severity.Warn)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.model.DeviceListEntry
|
||||
import org.meshtastic.app.ui.connections.ScannerViewModel
|
||||
import org.meshtastic.core.ble.AndroidBleDevice
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
|
|
@ -73,12 +74,14 @@ fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanMod
|
|||
|
||||
ScannerView(
|
||||
state = filterState,
|
||||
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
|
||||
onScanResultSelected = { result ->
|
||||
scanModel.onSelected(DeviceListEntry.Ble(AndroidBleDevice(result.peripheral)))
|
||||
},
|
||||
deviceItem = { result ->
|
||||
val device =
|
||||
remember(result.peripheral.address, bleDevices) {
|
||||
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
|
||||
?: DeviceListEntry.Ble(result.peripheral)
|
||||
?: DeviceListEntry.Ble(AndroidBleDevice(result.peripheral))
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ import co.touchlab.kermit.Logger
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.ui.view.RssiIcon
|
||||
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.model.DeviceListEntry
|
||||
import org.meshtastic.core.model.Node
|
||||
|
|
@ -75,23 +73,14 @@ fun CurrentlyConnectedInfo(
|
|||
var rssi by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(bleDevice) {
|
||||
if (bleDevice != null) {
|
||||
while (bleDevice.peripheral.isConnected) {
|
||||
while (bleDevice.device.isConnected) {
|
||||
try {
|
||||
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.peripheral.readRssi() }
|
||||
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
|
||||
delay(RSSI_DELAY.seconds)
|
||||
} catch (e: PeripheralNotConnectedException) {
|
||||
Logger.w(e) { "Failed to read RSSI ${e.message}" }
|
||||
break
|
||||
} catch (e: OperationFailedException) {
|
||||
} catch (e: Exception) {
|
||||
// RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise
|
||||
Logger.w(e) { "Failed to read RSSI ${e.message}" }
|
||||
break
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "Failed to read RSSI ${e.message}" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Unexpected error reading RSSI: ${e.message}" }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.messaging.domain.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SendMessageWorkerTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var packetRepository: PacketRepository
|
||||
private lateinit var radioController: RadioController
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
packetRepository = mockk(relaxed = true)
|
||||
radioController = mockk(relaxed = true)
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns success when packet is sent successfully`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
|
||||
coEvery { radioController.sendMessage(any()) } just Runs
|
||||
coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { radioController.sendMessage(dataPacket) }
|
||||
coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns retry when radio is disconnected`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
|
||||
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
coVerify(exactly = 0) { radioController.sendMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns failure when packet is missing`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 999
|
||||
coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
|
||||
.setWorkerFactory(
|
||||
object : androidx.work.WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? =
|
||||
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
// Act
|
||||
val result = worker.doWork()
|
||||
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.failure(), result)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,10 +141,25 @@ class NordicBleInterfaceRetryTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
@ -246,10 +261,25 @@ class NordicBleInterfaceRetryTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -151,10 +151,25 @@ class NordicBleInterfaceTest {
|
|||
|
||||
// Create the interface
|
||||
println("Creating NordicBleInterface")
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
@ -284,10 +299,25 @@ class NordicBleInterfaceTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
@ -377,10 +407,25 @@ class NordicBleInterfaceTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
@ -467,10 +512,25 @@ class NordicBleInterfaceTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
@ -553,10 +613,25 @@ class NordicBleInterfaceTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = uniqueAddress,
|
||||
)
|
||||
|
|
@ -644,10 +719,25 @@ class NordicBleInterfaceTest {
|
|||
centralManager.simulatePeripherals(listOf(peripheralSpec))
|
||||
advanceUntilIdle()
|
||||
|
||||
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
|
||||
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
|
||||
io.mockk.every { state } returns
|
||||
kotlinx.coroutines.flow.MutableStateFlow(
|
||||
org.meshtastic.core.ble.BluetoothState(
|
||||
hasPermissions = true,
|
||||
enabled = true,
|
||||
bondedDevices = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
|
||||
|
||||
val nordicInterface =
|
||||
NordicBleInterface(
|
||||
serviceScope = this,
|
||||
centralManager = centralManager,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue