mirror of
https://codeberg.org/comaps/comaps
synced 2026-01-07 21:13:55 +00:00
Initial implementation of location sharing
Signed-off-by: zyphlar <zyphlar@gmail.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
16
android/app/src/main/res/drawable/ic_location_sharing.xml
Normal file
16
android/app/src/main/res/drawable/ic_location_sharing.xml
Normal 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>
|
||||
83
android/app/src/main/res/layout/dialog_location_sharing.xml
Normal file
83
android/app/src/main/res/layout/dialog_location_sharing.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
android/app/src/main/res/values/strings_location_sharing.xml
Normal file
41
android/app/src/main/res/values/strings_location_sharing.xml
Normal 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>
|
||||
Reference in New Issue
Block a user