diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 83ca286ee..a7446ae31 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -500,6 +500,13 @@ android:stopWithTask="false" /> + + { + 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(); + } + } +} diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingDialog.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingDialog.java new file mode 100644 index 000000000..379ef57c3 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingDialog.java @@ -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))); + } +} diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java new file mode 100644 index 000000000..978543cbc --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java @@ -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); +} diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingNotification.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingNotification.java new file mode 100644 index 000000000..75ce13290 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingNotification.java @@ -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); + } +} diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java new file mode 100644 index 000000000..6757724b9 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java @@ -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(); + } +} diff --git a/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java b/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java index 86e8a2125..d4d8dcfb5 100644 --- a/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java +++ b/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java @@ -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) diff --git a/android/app/src/main/java/app/organicmaps/widget/menu/NavMenu.java b/android/app/src/main/java/app/organicmaps/widget/menu/NavMenu.java index b503cf812..f9b2ab6a3 100644 --- a/android/app/src/main/java/app/organicmaps/widget/menu/NavMenu.java +++ b/android/app/src/main/java/app/organicmaps/widget/menu/NavMenu.java @@ -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(); diff --git a/android/app/src/main/res/drawable/ic_location_sharing.xml b/android/app/src/main/res/drawable/ic_location_sharing.xml new file mode 100644 index 000000000..71ae817cd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_location_sharing.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_location_sharing.xml b/android/app/src/main/res/layout/dialog_location_sharing.xml new file mode 100644 index 000000000..a7a8cfcb8 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_location_sharing.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + +