Initial implementation of location sharing

Signed-off-by: zyphlar <zyphlar@gmail.com>
This commit is contained in:
zyphlar
2025-10-20 04:32:10 -07:00
parent 2601ec854a
commit d430a2202e
32 changed files with 4178 additions and 0 deletions

View File

@@ -500,6 +500,13 @@
android:stopWithTask="false"
/>
<service android:name=".location.LocationSharingService"
android:foregroundServiceType="location"
android:exported="false"
android:enabled="true"
android:stopWithTask="false"
/>
<service
android:name=".downloader.DownloaderService"
android:foregroundServiceType="dataSync"

View File

@@ -0,0 +1,200 @@
package app.organicmaps.api;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.organicmaps.sdk.util.log.Logger;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* HTTP API client for location sharing server.
* Sends encrypted location updates to the server.
*/
public class LocationSharingApiClient
{
private static final String TAG = LocationSharingApiClient.class.getSimpleName();
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 10000;
private final String mServerBaseUrl;
private final String mSessionId;
private final Executor mExecutor;
public interface Callback
{
void onSuccess();
void onFailure(@NonNull String error);
}
public LocationSharingApiClient(@NonNull String serverBaseUrl, @NonNull String sessionId)
{
mServerBaseUrl = serverBaseUrl.endsWith("/") ? serverBaseUrl : serverBaseUrl + "/";
mSessionId = sessionId;
mExecutor = Executors.newSingleThreadExecutor();
}
/**
* Create a new session on the server.
* @param callback Result callback
*/
public void createSession(@Nullable Callback callback)
{
mExecutor.execute(() -> {
try
{
String url = mServerBaseUrl + "api/v1/session";
String requestBody = "{\"sessionId\":\"" + mSessionId + "\"}";
int responseCode = postJson(url, requestBody);
if (responseCode >= 200 && responseCode < 300)
{
Logger.d(TAG, "Session created successfully: " + mSessionId);
if (callback != null)
callback.onSuccess();
}
else
{
String error = "Server returned error: " + responseCode;
Logger.w(TAG, error);
if (callback != null)
callback.onFailure(error);
}
}
catch (IOException e)
{
Logger.e(TAG, "Failed to create session", e);
if (callback != null)
callback.onFailure(e.getMessage());
}
});
}
/**
* Update location on the server with encrypted payload.
* @param encryptedPayloadJson Encrypted payload JSON (from native code)
* @param callback Result callback
*/
public void updateLocation(@NonNull String encryptedPayloadJson, @Nullable Callback callback)
{
mExecutor.execute(() -> {
try
{
String url = mServerBaseUrl + "api/v1/location/" + mSessionId;
int responseCode = postJson(url, encryptedPayloadJson);
if (responseCode >= 200 && responseCode < 300)
{
Logger.d(TAG, "Location updated successfully");
if (callback != null)
callback.onSuccess();
}
else
{
String error = "Server returned error: " + responseCode;
Logger.w(TAG, error);
if (callback != null)
callback.onFailure(error);
}
}
catch (IOException e)
{
Logger.e(TAG, "Failed to update location", e);
if (callback != null)
callback.onFailure(e.getMessage());
}
});
}
/**
* End the session on the server.
*/
public void endSession()
{
mExecutor.execute(() -> {
try
{
String url = mServerBaseUrl + "api/v1/session/" + mSessionId;
deleteRequest(url);
Logger.d(TAG, "Session ended: " + mSessionId);
}
catch (IOException e)
{
Logger.e(TAG, "Failed to end session", e);
}
});
}
/**
* Send a POST request with JSON body.
* @param urlString URL to send request to
* @param jsonBody JSON request body
* @return HTTP response code
* @throws IOException on network error
*/
private int postJson(@NonNull String urlString, @NonNull String jsonBody) throws IOException
{
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try
{
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
connection.setRequestProperty("Accept", "application/json");
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setDoOutput(true);
// Write body
byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(bodyBytes.length);
try (OutputStream os = connection.getOutputStream())
{
os.write(bodyBytes);
os.flush();
}
return connection.getResponseCode();
}
finally
{
connection.disconnect();
}
}
/**
* Send a DELETE request.
* @param urlString URL to send request to
* @return HTTP response code
* @throws IOException on network error
*/
private int deleteRequest(@NonNull String urlString) throws IOException
{
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try
{
connection.setRequestMethod("DELETE");
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
return connection.getResponseCode();
}
finally
{
connection.disconnect();
}
}
}

View File

@@ -0,0 +1,208 @@
package app.organicmaps.location;
import android.app.Dialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import app.organicmaps.R;
import app.organicmaps.util.SharingUtils;
/**
* Dialog for starting/stopping live location sharing and managing the share URL.
*/
public class LocationSharingDialog extends DialogFragment
{
private static final String TAG = LocationSharingDialog.class.getSimpleName();
@Nullable
private TextView mStatusText;
@Nullable
private TextView mShareUrlText;
@Nullable
private Button mStartStopButton;
@Nullable
private Button mCopyButton;
@Nullable
private Button mShareButton;
private LocationSharingManager mManager;
public static void show(@NonNull FragmentManager fragmentManager)
{
LocationSharingDialog dialog = new LocationSharingDialog();
dialog.show(fragmentManager, TAG);
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState)
{
mManager = LocationSharingManager.getInstance();
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_location_sharing, null);
initViews(view);
updateUI();
builder.setView(view);
builder.setTitle(R.string.location_sharing_title);
builder.setNegativeButton(R.string.close, (dialog, which) -> dismiss());
return builder.create();
}
private void initViews(@NonNull View root)
{
mStatusText = root.findViewById(R.id.status_text);
mShareUrlText = root.findViewById(R.id.share_url_text);
mStartStopButton = root.findViewById(R.id.start_stop_button);
mCopyButton = root.findViewById(R.id.copy_button);
mShareButton = root.findViewById(R.id.share_button);
if (mStartStopButton != null)
{
mStartStopButton.setOnClickListener(v -> {
if (mManager.isSharing())
stopSharing();
else
startSharing();
});
}
if (mCopyButton != null)
{
mCopyButton.setOnClickListener(v -> copyUrl());
}
if (mShareButton != null)
{
mShareButton.setOnClickListener(v -> shareUrl());
}
}
private void updateUI()
{
boolean isSharing = mManager.isSharing();
if (mStatusText != null)
{
mStatusText.setText(isSharing
? R.string.location_sharing_status_active
: R.string.location_sharing_status_inactive);
}
if (mShareUrlText != null)
{
String url = mManager.getShareUrl();
if (url != null && isSharing)
{
mShareUrlText.setText(url);
mShareUrlText.setVisibility(View.VISIBLE);
}
else
{
mShareUrlText.setVisibility(View.GONE);
}
}
if (mStartStopButton != null)
{
mStartStopButton.setText(isSharing
? R.string.location_sharing_stop
: R.string.location_sharing_start);
}
// Show/hide copy and share buttons
int visibility = isSharing ? View.VISIBLE : View.GONE;
if (mCopyButton != null)
mCopyButton.setVisibility(visibility);
if (mShareButton != null)
mShareButton.setVisibility(visibility);
}
private void startSharing()
{
String shareUrl = mManager.startSharing();
if (shareUrl != null)
{
Toast.makeText(requireContext(),
R.string.location_sharing_started,
Toast.LENGTH_SHORT).show();
updateUI();
// Auto-copy URL to clipboard
copyUrlToClipboard(shareUrl);
}
else
{
Toast.makeText(requireContext(),
R.string.location_sharing_failed_to_start,
Toast.LENGTH_LONG).show();
}
}
private void stopSharing()
{
mManager.stopSharing();
Toast.makeText(requireContext(),
R.string.location_sharing_stopped,
Toast.LENGTH_SHORT).show();
updateUI();
}
private void copyUrl()
{
String url = mManager.getShareUrl();
if (url != null)
{
copyUrlToClipboard(url);
}
}
private void copyUrlToClipboard(@NonNull String url)
{
ClipboardManager clipboard = (ClipboardManager)
requireContext().getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard != null)
{
ClipData clip = ClipData.newPlainText("Location Share URL", url);
clipboard.setPrimaryClip(clip);
Toast.makeText(requireContext(),
R.string.location_sharing_url_copied,
Toast.LENGTH_SHORT).show();
}
}
private void shareUrl()
{
String url = mManager.getShareUrl();
if (url == null)
return;
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.location_sharing_share_message, url));
startActivity(Intent.createChooser(shareIntent, getString(R.string.location_sharing_share_url)));
}
}

View File

@@ -0,0 +1,212 @@
package app.organicmaps.location;
import android.content.Context;
import android.content.Intent;
import android.os.BatteryManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.organicmaps.MwmApplication;
import app.organicmaps.sdk.routing.RoutingController;
import app.organicmaps.sdk.util.log.Logger;
/**
* Singleton manager for live location sharing functionality.
* Coordinates between LocationHelper, RoutingController, and LocationSharingService.
*/
public class LocationSharingManager
{
private static final String TAG = LocationSharingManager.class.getSimpleName();
private static LocationSharingManager sInstance;
@Nullable
private String mSessionId;
@Nullable
private String mEncryptionKey;
@Nullable
private String mShareUrl;
private boolean mIsSharing = false;
private final Context mContext;
// Configuration
private int mUpdateIntervalSeconds = 20;
private String mServerBaseUrl = "https://live.organicmaps.app"; // TODO: Configure
private LocationSharingManager()
{
mContext = MwmApplication.sInstance;
}
@NonNull
public static synchronized LocationSharingManager getInstance()
{
if (sInstance == null)
sInstance = new LocationSharingManager();
return sInstance;
}
/**
* Start live location sharing.
* @return Share URL that can be sent to others
*/
@Nullable
public String startSharing()
{
if (mIsSharing)
{
Logger.w(TAG, "Location sharing already active");
return mShareUrl;
}
// Generate session credentials via native code
String[] credentials = nativeGenerateSessionCredentials();
if (credentials == null || credentials.length != 2)
{
Logger.e(TAG, "Failed to generate session credentials");
return null;
}
mSessionId = credentials[0];
mEncryptionKey = credentials[1];
// Generate share URL
mShareUrl = nativeGenerateShareUrl(mSessionId, mEncryptionKey, mServerBaseUrl);
if (mShareUrl == null)
{
Logger.e(TAG, "Failed to generate share URL");
return null;
}
mIsSharing = true;
// Start foreground service
Intent intent = new Intent(mContext, LocationSharingService.class);
intent.putExtra(LocationSharingService.EXTRA_SESSION_ID, mSessionId);
intent.putExtra(LocationSharingService.EXTRA_ENCRYPTION_KEY, mEncryptionKey);
intent.putExtra(LocationSharingService.EXTRA_SERVER_URL, mServerBaseUrl);
intent.putExtra(LocationSharingService.EXTRA_UPDATE_INTERVAL, mUpdateIntervalSeconds);
mContext.startForegroundService(intent);
Logger.i(TAG, "Location sharing started, session ID: " + mSessionId);
return mShareUrl;
}
/**
* Stop live location sharing.
*/
public void stopSharing()
{
if (!mIsSharing)
{
Logger.w(TAG, "Location sharing not active");
return;
}
// Stop foreground service
Intent intent = new Intent(mContext, LocationSharingService.class);
mContext.stopService(intent);
mIsSharing = false;
mSessionId = null;
mEncryptionKey = null;
mShareUrl = null;
Logger.i(TAG, "Location sharing stopped");
}
public boolean isSharing()
{
return mIsSharing;
}
@Nullable
public String getShareUrl()
{
return mShareUrl;
}
@Nullable
public String getSessionId()
{
return mSessionId;
}
public void setUpdateIntervalSeconds(int seconds)
{
if (seconds < 5 || seconds > 60)
{
Logger.w(TAG, "Invalid update interval: " + seconds + ", using default");
return;
}
mUpdateIntervalSeconds = seconds;
}
public int getUpdateIntervalSeconds()
{
return mUpdateIntervalSeconds;
}
public void setServerBaseUrl(@NonNull String url)
{
mServerBaseUrl = url;
}
@NonNull
public String getServerBaseUrl()
{
return mServerBaseUrl;
}
/**
* Get current battery level (0-100).
*/
public int getBatteryLevel()
{
BatteryManager bm = (BatteryManager) mContext.getSystemService(Context.BATTERY_SERVICE);
if (bm == null)
return 100;
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
}
/**
* Check if currently navigating with an active route.
*/
public boolean isNavigating()
{
return RoutingController.get().isNavigating();
}
// Native methods (implemented in JNI)
/**
* Generate new session credentials (ID and encryption key).
* @return Array of [sessionId, encryptionKey]
*/
@Nullable
private static native String[] nativeGenerateSessionCredentials();
/**
* Generate shareable URL from credentials.
* @param sessionId Session ID (UUID)
* @param encryptionKey Base64-encoded encryption key
* @param serverBaseUrl Server base URL
* @return Share URL
*/
@Nullable
private static native String nativeGenerateShareUrl(String sessionId, String encryptionKey, String serverBaseUrl);
/**
* Encrypt location payload.
* @param encryptionKey Base64-encoded encryption key
* @param payloadJson JSON payload to encrypt
* @return Encrypted payload JSON (with iv, ciphertext, authTag) or null on failure
*/
@Nullable
public static native String nativeEncryptPayload(String encryptionKey, String payloadJson);
}

View File

@@ -0,0 +1,215 @@
package app.organicmaps.location;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.location.Location;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import app.organicmaps.MwmActivity;
import app.organicmaps.R;
import app.organicmaps.sdk.routing.RoutingInfo;
import java.util.Locale;
/**
* Helper for creating and updating location sharing notifications.
*/
public class LocationSharingNotification
{
public static final String CHANNEL_ID = "LOCATION_SHARING";
private static final String CHANNEL_NAME = "Live Location Sharing";
private final Context mContext;
private final NotificationManagerCompat mNotificationManager;
public LocationSharingNotification(@NonNull Context context)
{
mContext = context;
mNotificationManager = NotificationManagerCompat.from(context);
createNotificationChannel();
}
private void createNotificationChannel()
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
return;
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW); // Low importance = no sound/vibration
channel.setDescription("Notifications for active live location sharing");
channel.setShowBadge(false);
channel.enableLights(false);
channel.enableVibration(false);
NotificationManager nm = mContext.getSystemService(NotificationManager.class);
if (nm != null)
nm.createNotificationChannel(channel);
}
/**
* Build notification for location sharing service.
* @param stopIntent PendingIntent to stop sharing
* @return Notification object
*/
@NonNull
public Notification buildNotification(@NonNull PendingIntent stopIntent)
{
return buildNotification(stopIntent, null, null);
}
/**
* Build notification with current location and routing info.
* @param stopIntent PendingIntent to stop sharing
* @param location Current location (optional)
* @param routingInfo Navigation info (optional)
* @return Notification object
*/
@NonNull
public Notification buildNotification(
@NonNull PendingIntent stopIntent,
@Nullable Location location,
@Nullable RoutingInfo routingInfo)
{
Intent notificationIntent = new Intent(mContext, MwmActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
mContext,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_location_sharing)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setShowWhen(false)
.setAutoCancel(false);
// Title
builder.setContentTitle(mContext.getString(R.string.location_sharing_active));
// Content text
String contentText = buildContentText(location, routingInfo);
builder.setContentText(contentText);
// Big text style for more details
if (routingInfo != null)
{
NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle()
.bigText(contentText)
.setSummaryText(mContext.getString(R.string.location_sharing_tap_to_view));
builder.setStyle(bigTextStyle);
}
// Stop action button
builder.addAction(
R.drawable.ic_close,
mContext.getString(R.string.location_sharing_stop),
stopIntent);
// Set foreground service type for Android 10+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE);
}
return builder.build();
}
@NonNull
private String buildContentText(@Nullable Location location, @Nullable RoutingInfo routingInfo)
{
StringBuilder text = new StringBuilder();
// If navigating, show ETA and distance
if (routingInfo != null && routingInfo.distToTarget != null)
{
if (routingInfo.totalTimeInSeconds > 0)
{
String eta = formatTime(routingInfo.totalTimeInSeconds);
text.append(mContext.getString(R.string.location_sharing_eta, eta));
}
if (routingInfo.distToTarget != null && routingInfo.distToTarget.isValid())
{
if (text.length() > 0)
text.append("");
text.append(routingInfo.distToTarget.toString(mContext));
text.append(" ").append(mContext.getString(R.string.location_sharing_remaining));
}
}
else
{
// Standalone mode - show accuracy if available
if (location != null)
{
text.append(mContext.getString(R.string.location_sharing_accuracy,
formatAccuracy(location.getAccuracy())));
}
else
{
text.append(mContext.getString(R.string.location_sharing_waiting_for_location));
}
}
return text.toString();
}
@NonNull
private String formatTime(int seconds)
{
if (seconds < 60)
return String.format(Locale.US, "%ds", seconds);
int minutes = seconds / 60;
if (minutes < 60)
return String.format(Locale.US, "%d min", minutes);
int hours = minutes / 60;
int remainingMinutes = minutes % 60;
return String.format(Locale.US, "%dh %dm", hours, remainingMinutes);
}
@NonNull
private String formatAccuracy(float accuracyMeters)
{
if (accuracyMeters < 10)
return mContext.getString(R.string.location_sharing_accuracy_high);
else if (accuracyMeters < 50)
return mContext.getString(R.string.location_sharing_accuracy_medium);
else
return mContext.getString(R.string.location_sharing_accuracy_low);
}
/**
* Update existing notification.
* @param notificationId Notification ID
* @param notification Updated notification
*/
public void updateNotification(int notificationId, @NonNull Notification notification)
{
mNotificationManager.notify(notificationId, notification);
}
/**
* Cancel notification.
* @param notificationId Notification ID
*/
public void cancelNotification(int notificationId)
{
mNotificationManager.cancel(notificationId);
}
}

View File

@@ -0,0 +1,334 @@
package app.organicmaps.location;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.location.Location;
import android.os.BatteryManager;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import app.organicmaps.MwmActivity;
import app.organicmaps.MwmApplication;
import app.organicmaps.R;
import app.organicmaps.api.LocationSharingApiClient;
import app.organicmaps.sdk.location.LocationHelper;
import app.organicmaps.sdk.location.LocationListener;
import app.organicmaps.sdk.routing.RoutingController;
import app.organicmaps.sdk.routing.RoutingInfo;
import app.organicmaps.sdk.util.log.Logger;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Locale;
/**
* Foreground service for live GPS location sharing.
* Monitors location updates and posts encrypted data to server at regular intervals.
*/
public class LocationSharingService extends Service implements LocationListener
{
private static final String TAG = LocationSharingService.class.getSimpleName();
private static final int NOTIFICATION_ID = 0x1002; // Unique ID for location sharing
// Intent extras
public static final String EXTRA_SESSION_ID = "session_id";
public static final String EXTRA_ENCRYPTION_KEY = "encryption_key";
public static final String EXTRA_SERVER_URL = "server_url";
public static final String EXTRA_UPDATE_INTERVAL = "update_interval";
// Action for notification stop button
private static final String ACTION_STOP = "app.organicmaps.ACTION_STOP_LOCATION_SHARING";
@Nullable
private String mSessionId;
@Nullable
private String mEncryptionKey;
@Nullable
private String mServerUrl;
private int mUpdateIntervalSeconds = 20;
@Nullable
private Location mLastLocation;
private long mLastUpdateTimestamp = 0;
private final Handler mHandler = new Handler(Looper.getMainLooper());
private final Runnable mUpdateTask = this::processLocationUpdate;
@Nullable
private LocationSharingApiClient mApiClient;
@Nullable
private LocationSharingNotification mNotificationHelper;
@Override
public void onCreate()
{
super.onCreate();
Logger.i(TAG, "Service created");
mNotificationHelper = new LocationSharingNotification(this);
}
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId)
{
if (intent == null)
{
Logger.w(TAG, "Null intent, stopping service");
stopSelf();
return START_NOT_STICKY;
}
// Handle stop action from notification
if (ACTION_STOP.equals(intent.getAction()))
{
Logger.i(TAG, "Stop action received from notification");
LocationSharingManager.getInstance().stopSharing();
stopSelf();
return START_NOT_STICKY;
}
// Extract session info
mSessionId = intent.getStringExtra(EXTRA_SESSION_ID);
mEncryptionKey = intent.getStringExtra(EXTRA_ENCRYPTION_KEY);
mServerUrl = intent.getStringExtra(EXTRA_SERVER_URL);
mUpdateIntervalSeconds = intent.getIntExtra(EXTRA_UPDATE_INTERVAL, 20);
if (mSessionId == null || mEncryptionKey == null || mServerUrl == null)
{
Logger.e(TAG, "Missing session info, stopping service");
stopSelf();
return START_NOT_STICKY;
}
// Initialize API client
mApiClient = new LocationSharingApiClient(mServerUrl, mSessionId);
// Start foreground with notification
Notification notification = mNotificationHelper != null
? mNotificationHelper.buildNotification(getStopIntent())
: buildFallbackNotification();
startForeground(NOTIFICATION_ID, notification);
// Register for location updates
LocationHelper locationHelper = MwmApplication.sInstance.getLocationHelper();
locationHelper.addListener(this);
Logger.i(TAG, "Service started for session: " + mSessionId);
return START_STICKY;
}
@Override
public void onDestroy()
{
Logger.i(TAG, "Service destroyed");
// Unregister location listener
LocationHelper locationHelper = MwmApplication.sInstance.getLocationHelper();
locationHelper.removeListener(this);
// Cancel pending updates
mHandler.removeCallbacks(mUpdateTask);
// Send session end to server (optional)
if (mApiClient != null && mSessionId != null)
mApiClient.endSession();
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent)
{
return null; // Not a bound service
}
// LocationHelper.LocationListener implementation
@Override
public void onLocationUpdated(@NonNull Location location)
{
mLastLocation = location;
// Update notification with location info
if (mNotificationHelper != null)
{
Notification notification = mNotificationHelper.buildNotification(
getStopIntent(),
location,
getNavigationInfo());
mNotificationHelper.updateNotification(NOTIFICATION_ID, notification);
}
// Schedule update if needed
scheduleUpdate();
}
// Private methods
private void scheduleUpdate()
{
long now = System.currentTimeMillis();
long timeSinceLastUpdate = (now - mLastUpdateTimestamp) / 1000; // Convert to seconds
if (timeSinceLastUpdate >= mUpdateIntervalSeconds)
{
// Remove any pending updates
mHandler.removeCallbacks(mUpdateTask);
// Execute immediately
mHandler.post(mUpdateTask);
}
}
private void processLocationUpdate()
{
if (mLastLocation == null || mEncryptionKey == null || mApiClient == null)
return;
// Check battery level
int batteryLevel = getBatteryLevel();
if (batteryLevel < 10)
{
Logger.w(TAG, "Battery level too low (" + batteryLevel + "%), stopping sharing");
LocationSharingManager.getInstance().stopSharing();
stopSelf();
return;
}
// Build payload JSON
JSONObject payload = buildPayloadJson(mLastLocation, batteryLevel);
if (payload == null)
return;
// Encrypt payload
String encryptedJson = LocationSharingManager.nativeEncryptPayload(mEncryptionKey, payload.toString());
if (encryptedJson == null)
{
Logger.e(TAG, "Failed to encrypt payload");
return;
}
// Send to server
mApiClient.updateLocation(encryptedJson, new LocationSharingApiClient.Callback()
{
@Override
public void onSuccess()
{
Logger.d(TAG, "Location update sent successfully");
mLastUpdateTimestamp = System.currentTimeMillis();
}
@Override
public void onFailure(@NonNull String error)
{
Logger.w(TAG, "Failed to send location update: " + error);
}
});
}
@Nullable
private JSONObject buildPayloadJson(@NonNull Location location, int batteryLevel)
{
try
{
JSONObject json = new JSONObject();
json.put("timestamp", System.currentTimeMillis() / 1000); // Unix timestamp
json.put("lat", location.getLatitude());
json.put("lon", location.getLongitude());
json.put("accuracy", location.getAccuracy());
if (location.hasSpeed())
json.put("speed", location.getSpeed());
if (location.hasBearing())
json.put("bearing", location.getBearing());
// Check if navigating
RoutingInfo routingInfo = getNavigationInfo();
if (routingInfo != null && routingInfo.distToTarget != null)
{
json.put("mode", "navigation");
// Calculate ETA (current time + time remaining)
if (routingInfo.totalTimeInSeconds > 0)
{
long etaTimestamp = (System.currentTimeMillis() / 1000) + routingInfo.totalTimeInSeconds;
json.put("eta", etaTimestamp);
}
// Distance remaining in meters
if (routingInfo.distToTarget != null)
{
json.put("distanceRemaining", routingInfo.distToTarget.mDistance);
}
}
else
{
json.put("mode", "standalone");
}
json.put("batteryLevel", batteryLevel);
return json;
}
catch (JSONException e)
{
Logger.e(TAG, "Failed to build payload JSON", e);
return null;
}
}
@Nullable
private RoutingInfo getNavigationInfo()
{
if (!RoutingController.get().isNavigating())
return null;
return RoutingController.get().getCachedRoutingInfo();
}
private int getBatteryLevel()
{
BatteryManager bm = (BatteryManager) getSystemService(BATTERY_SERVICE);
if (bm == null)
return 100;
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
}
@NonNull
private PendingIntent getStopIntent()
{
Intent stopIntent = new Intent(this, LocationSharingService.class);
stopIntent.setAction(ACTION_STOP);
return PendingIntent.getService(this, 0, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
@NonNull
private Notification buildFallbackNotification()
{
Intent notificationIntent = new Intent(this, MwmActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent,
PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, LocationSharingNotification.CHANNEL_ID)
.setContentTitle(getString(R.string.location_sharing_active))
.setContentText(getString(R.string.location_sharing_notification_text))
.setSmallIcon(R.drawable.ic_location_sharing)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build();
}
}

View File

@@ -26,8 +26,10 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import app.organicmaps.MwmActivity;
import app.organicmaps.MwmApplication;
import app.organicmaps.R;
import app.organicmaps.location.LocationSharingDialog;
import app.organicmaps.sdk.Framework;
import app.organicmaps.sdk.bookmarks.data.DistanceAndAzimut;
import app.organicmaps.sdk.routing.RouteMarkData;
@@ -144,6 +146,9 @@ final class RoutingBottomMenuController implements View.OnClickListener
mActionButton.setOnClickListener(this);
View actionSearchButton = actionFrame.findViewById(R.id.btn__search_point);
actionSearchButton.setOnClickListener(this);
View shareLocationButton = actionFrame.findViewById(R.id.btn__share_location);
if (shareLocationButton != null)
shareLocationButton.setOnClickListener(this);
mActionIcon = mActionButton.findViewById(R.id.iv__icon);
UiUtils.hide(mAltitudeChartFrame, mActionFrame);
mListener = listener;
@@ -472,6 +477,11 @@ final class RoutingBottomMenuController implements View.OnClickListener
final RouteMarkType pointType = (RouteMarkType) mActionMessage.getTag();
mListener.onSearchRoutePoint(pointType);
}
else if (id == R.id.btn__share_location)
{
if (mContext instanceof MwmActivity)
LocationSharingDialog.show(((MwmActivity) mContext).getSupportFragmentManager());
}
else if (id == R.id.btn__manage_route)
mListener.onManageRouteOpen();
else if (id == R.id.btn__save)

View File

@@ -5,6 +5,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import app.organicmaps.R;
import app.organicmaps.location.LocationSharingDialog;
import app.organicmaps.sdk.routing.RoutingInfo;
import app.organicmaps.sdk.sound.TtsPlayer;
import app.organicmaps.sdk.util.DateUtils;
@@ -97,6 +98,8 @@ public class NavMenu
mRouteProgress = bottomFrame.findViewById(R.id.navigation_progress);
// Bottom frame buttons
ShapeableImageView shareLocation = bottomFrame.findViewById(R.id.share_location);
shareLocation.setOnClickListener(v -> onShareLocationClicked());
ShapeableImageView mSettings = bottomFrame.findViewById(R.id.settings);
mSettings.setOnClickListener(v -> onSettingsClicked());
mTts = bottomFrame.findViewById(R.id.tts_volume);
@@ -110,6 +113,11 @@ public class NavMenu
mNavMenuListener.onStopClicked();
}
private void onShareLocationClicked()
{
LocationSharingDialog.show(mActivity.getSupportFragmentManager());
}
private void onSettingsClicked()
{
mNavMenuListener.onSettingsClicked();

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Location pin with share icon -->
<path
android:fillColor="@android:color/white"
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"/>
<!-- Share arrows (smaller, overlaid) -->
<path
android:fillColor="@android:color/white"
android:pathData="M18,16l-3,3v-2H9v-2h6v-2z"
android:fillAlpha="0.8"/>
</vector>

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Status Text -->
<TextView
android:id="@+id/status_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:textColorPrimary"
android:paddingBottom="8dp"
tools:text="Location sharing is not active" />
<!-- Description -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/location_sharing_description"
android:textSize="14sp"
android:textColor="?android:textColorSecondary"
android:paddingBottom="16dp" />
<!-- Share URL (visible only when sharing) -->
<TextView
android:id="@+id/share_url_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?android:textColorPrimary"
android:fontFamily="monospace"
android:background="?android:selectableItemBackground"
android:padding="12dp"
android:textIsSelectable="true"
android:visibility="gone"
tools:visibility="visible"
tools:text="https://live.organicmaps.app/live/abc123def456" />
<!-- Button Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingTop="16dp">
<!-- Copy Button -->
<Button
android:id="@+id/copy_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/location_sharing_copy_url"
android:visibility="gone"
tools:visibility="visible" />
<!-- Share Button -->
<Button
android:id="@+id/share_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/location_sharing_share_url"
android:visibility="gone"
android:layout_marginStart="8dp"
tools:visibility="visible" />
<!-- Start/Stop Button -->
<Button
android:id="@+id/start_stop_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
tools:text="Start Sharing" />
</LinearLayout>
</LinearLayout>

View File

@@ -43,6 +43,17 @@
android:paddingEnd="@dimen/nav_bottom_gap"
tools:background="#300000FF">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/share_location"
android:layout_width="0dp"
android:layout_height="@dimen/nav_icon_size"
android:layout_weight="0.2"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/location_sharing_title"
app:srcCompat="@drawable/ic_location_sharing"
app:tint="?iconTint" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/tts_volume"
android:layout_width="0dp"

View File

@@ -57,4 +57,27 @@
app:srcCompat="@drawable/ic_location_crosshair"
tools:tint="?colorSecondary"/>
</LinearLayout>
<LinearLayout
android:id="@+id/btn__share_location"
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:background="?clickableBackground"
tools:visibility="visible">
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_marginBottom="@dimen/margin_half"
android:layout_marginTop="@dimen/margin_half"
android:background="?dividerHorizontal"/>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/iv__share_location_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/margin_base"
app:srcCompat="@drawable/ic_location_sharing"
app:tint="?colorSecondary"
android:contentDescription="@string/location_sharing_title"/>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Location Sharing -->
<string name="location_sharing_title">Live Location Sharing</string>
<string name="location_sharing_start">Start Sharing</string>
<string name="location_sharing_stop">Stop Sharing</string>
<string name="location_sharing_active">Sharing location</string>
<string name="location_sharing_status_active">Your location is being shared</string>
<string name="location_sharing_status_inactive">Location sharing is not active</string>
<!-- Notification -->
<string name="location_sharing_notification_text">Your live location is being shared</string>
<string name="location_sharing_tap_to_view">Tap to view in app</string>
<string name="location_sharing_eta">ETA: %s</string>
<string name="location_sharing_remaining">remaining</string>
<string name="location_sharing_accuracy">Accuracy: %s</string>
<string name="location_sharing_accuracy_high">high</string>
<string name="location_sharing_accuracy_medium">medium</string>
<string name="location_sharing_accuracy_low">low</string>
<string name="location_sharing_waiting_for_location">Waiting for location…</string>
<!-- Messages -->
<string name="location_sharing_started">Location sharing started</string>
<string name="location_sharing_stopped">Location sharing stopped</string>
<string name="location_sharing_failed_to_start">Failed to start location sharing</string>
<string name="location_sharing_url_copied">Share URL copied to clipboard</string>
<string name="location_sharing_share_message">Follow my live location: %s</string>
<!-- Dialog -->
<string name="location_sharing_description">Share your real-time location with end-to-end encryption. Only people with the link can view your location.</string>
<string name="location_sharing_copy_url">Copy Link</string>
<string name="location_sharing_share_url">Share Link</string>
<!-- Using existing close string from main strings.xml -->
<!-- Settings -->
<string name="pref_location_sharing_category">Live Location Sharing</string>
<string name="pref_location_sharing_update_interval">Update interval</string>
<string name="pref_location_sharing_update_interval_summary">How often to send location updates</string>
<string name="pref_location_sharing_server_url">Server URL</string>
<string name="pref_location_sharing_server_url_summary">Location sharing server endpoint</string>
</resources>