diff --git a/android/app/src/main/java/app/organicmaps/location/LocationCrypto.java b/android/app/src/main/java/app/organicmaps/location/LocationCrypto.java
new file mode 100644
index 000000000..f129d5512
--- /dev/null
+++ b/android/app/src/main/java/app/organicmaps/location/LocationCrypto.java
@@ -0,0 +1,131 @@
+package app.organicmaps.location;
+
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * AES-256-GCM encryption/decryption for location data.
+ */
+public class LocationCrypto
+{
+ private static final String ALGORITHM = "AES/GCM/NoPadding";
+ private static final int GCM_IV_LENGTH = 12; // 96 bits
+ private static final int GCM_TAG_LENGTH = 128; // 128 bits
+
+ /**
+ * Encrypt plaintext JSON using AES-256-GCM.
+ * @param base64Key Base64-encoded 256-bit key
+ * @param plaintextJson JSON string to encrypt
+ * @return JSON string with encrypted payload: {"iv":"...","ciphertext":"...","authTag":"..."}
+ */
+ @Nullable
+ public static String encrypt(@NonNull String base64Key, @NonNull String plaintextJson)
+ {
+ try
+ {
+ // Decode the base64 key
+ byte[] key = Base64.decode(base64Key, Base64.NO_WRAP);
+ if (key.length != 32) // 256 bits
+ {
+ android.util.Log.e("LocationCrypto", "Invalid key size: " + key.length);
+ return null;
+ }
+
+ // Generate random IV
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ new SecureRandom().nextBytes(iv);
+
+ // Create cipher
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
+ GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec);
+
+ // Encrypt
+ byte[] plaintext = plaintextJson.getBytes(StandardCharsets.UTF_8);
+ byte[] ciphertextWithTag = cipher.doFinal(plaintext);
+
+ // Split ciphertext and auth tag
+ // In GCM mode, doFinal() returns ciphertext + tag
+ int ciphertextLength = ciphertextWithTag.length - (GCM_TAG_LENGTH / 8);
+ byte[] ciphertext = new byte[ciphertextLength];
+ byte[] authTag = new byte[GCM_TAG_LENGTH / 8];
+
+ System.arraycopy(ciphertextWithTag, 0, ciphertext, 0, ciphertextLength);
+ System.arraycopy(ciphertextWithTag, ciphertextLength, authTag, 0, authTag.length);
+
+ // Build JSON response
+ JSONObject result = new JSONObject();
+ result.put("iv", Base64.encodeToString(iv, Base64.NO_WRAP));
+ result.put("ciphertext", Base64.encodeToString(ciphertext, Base64.NO_WRAP));
+ result.put("authTag", Base64.encodeToString(authTag, Base64.NO_WRAP));
+
+ return result.toString();
+ }
+ catch (Exception e)
+ {
+ android.util.Log.e("LocationCrypto", "Encryption failed", e);
+ return null;
+ }
+ }
+
+ /**
+ * Decrypt encrypted payload using AES-256-GCM.
+ * @param base64Key Base64-encoded 256-bit key
+ * @param encryptedPayloadJson JSON string with format: {"iv":"...","ciphertext":"...","authTag":"..."}
+ * @return Decrypted plaintext JSON string
+ */
+ @Nullable
+ public static String decrypt(@NonNull String base64Key, @NonNull String encryptedPayloadJson)
+ {
+ try
+ {
+ // Parse encrypted payload
+ JSONObject payload = new JSONObject(encryptedPayloadJson);
+ byte[] iv = Base64.decode(payload.getString("iv"), Base64.NO_WRAP);
+ byte[] ciphertext = Base64.decode(payload.getString("ciphertext"), Base64.NO_WRAP);
+ byte[] authTag = Base64.decode(payload.getString("authTag"), Base64.NO_WRAP);
+
+ // Decode the base64 key
+ byte[] key = Base64.decode(base64Key, Base64.NO_WRAP);
+ if (key.length != 32) // 256 bits
+ {
+ android.util.Log.e("LocationCrypto", "Invalid key size: " + key.length);
+ return null;
+ }
+
+ // Combine ciphertext and auth tag for GCM decryption
+ byte[] ciphertextWithTag = new byte[ciphertext.length + authTag.length];
+ System.arraycopy(ciphertext, 0, ciphertextWithTag, 0, ciphertext.length);
+ System.arraycopy(authTag, 0, ciphertextWithTag, ciphertext.length, authTag.length);
+
+ // Create cipher
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
+ GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
+
+ // Decrypt
+ byte[] plaintext = cipher.doFinal(ciphertextWithTag);
+
+ return new String(plaintext, StandardCharsets.UTF_8);
+ }
+ catch (Exception e)
+ {
+ android.util.Log.e("LocationCrypto", "Decryption failed", e);
+ return null;
+ }
+ }
+}
diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java
index 978543cbc..f4c3a0718 100644
--- a/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java
+++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingManager.java
@@ -9,6 +9,7 @@ import androidx.annotation.Nullable;
import app.organicmaps.MwmApplication;
import app.organicmaps.sdk.routing.RoutingController;
+import app.organicmaps.sdk.util.Config;
import app.organicmaps.sdk.util.log.Logger;
/**
@@ -31,10 +32,6 @@ public class LocationSharingManager
private final Context mContext;
- // Configuration
- private int mUpdateIntervalSeconds = 20;
- private String mServerBaseUrl = "https://live.organicmaps.app"; // TODO: Configure
-
private LocationSharingManager()
{
mContext = MwmApplication.sInstance;
@@ -72,8 +69,9 @@ public class LocationSharingManager
mSessionId = credentials[0];
mEncryptionKey = credentials[1];
- // Generate share URL
- mShareUrl = nativeGenerateShareUrl(mSessionId, mEncryptionKey, mServerBaseUrl);
+ // Generate share URL using configured server
+ String serverUrl = Config.LocationSharing.getServerUrl();
+ mShareUrl = nativeGenerateShareUrl(mSessionId, mEncryptionKey, serverUrl);
if (mShareUrl == null)
{
Logger.e(TAG, "Failed to generate share URL");
@@ -86,8 +84,8 @@ public class LocationSharingManager
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);
+ intent.putExtra(LocationSharingService.EXTRA_SERVER_URL, serverUrl);
+ intent.putExtra(LocationSharingService.EXTRA_UPDATE_INTERVAL, Config.LocationSharing.getUpdateInterval());
mContext.startForegroundService(intent);
@@ -138,28 +136,23 @@ public class LocationSharingManager
public void setUpdateIntervalSeconds(int seconds)
{
- if (seconds < 5 || seconds > 60)
- {
- Logger.w(TAG, "Invalid update interval: " + seconds + ", using default");
- return;
- }
- mUpdateIntervalSeconds = seconds;
+ Config.LocationSharing.setUpdateInterval(seconds);
}
public int getUpdateIntervalSeconds()
{
- return mUpdateIntervalSeconds;
+ return Config.LocationSharing.getUpdateInterval();
}
public void setServerBaseUrl(@NonNull String url)
{
- mServerBaseUrl = url;
+ Config.LocationSharing.setServerUrl(url);
}
@NonNull
public String getServerBaseUrl()
{
- return mServerBaseUrl;
+ return Config.LocationSharing.getServerUrl();
}
/**
diff --git a/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java b/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java
index 6757724b9..b1ba73e29 100644
--- a/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java
+++ b/android/app/src/main/java/app/organicmaps/location/LocationSharingService.java
@@ -111,6 +111,22 @@ public class LocationSharingService extends Service implements LocationListener
// Initialize API client
mApiClient = new LocationSharingApiClient(mServerUrl, mSessionId);
+ // Create session on server
+ mApiClient.createSession(new LocationSharingApiClient.Callback()
+ {
+ @Override
+ public void onSuccess()
+ {
+ Logger.i(TAG, "Session created on server");
+ }
+
+ @Override
+ public void onFailure(@NonNull String error)
+ {
+ Logger.w(TAG, "Failed to create session on server: " + error);
+ }
+ });
+
// Start foreground with notification
Notification notification = mNotificationHelper != null
? mNotificationHelper.buildNotification(getStopIntent())
@@ -212,7 +228,7 @@ public class LocationSharingService extends Service implements LocationListener
return;
// Encrypt payload
- String encryptedJson = LocationSharingManager.nativeEncryptPayload(mEncryptionKey, payload.toString());
+ String encryptedJson = LocationCrypto.encrypt(mEncryptionKey, payload.toString());
if (encryptedJson == null)
{
Logger.e(TAG, "Failed to encrypt payload");
diff --git a/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java b/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java
index 26eade991..b672d7e67 100644
--- a/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java
+++ b/android/app/src/main/java/app/organicmaps/settings/SettingsPrefsFragment.java
@@ -8,6 +8,7 @@ import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
@@ -73,6 +74,7 @@ public class SettingsPrefsFragment extends BaseXmlSettingsFragment implements La
initScreenSleepEnabledPrefsCallbacks();
initShowOnLockScreenPrefsCallbacks();
initLeftButtonPrefs();
+ initLocationSharingPrefsCallbacks();
}
private void initLeftButtonPrefs()
@@ -542,6 +544,29 @@ public class SettingsPrefsFragment extends BaseXmlSettingsFragment implements La
category.removePreference(preference);
}
+ private void initLocationSharingPrefsCallbacks()
+ {
+ // Server URL preference
+ final EditTextPreference serverUrlPref = getPreference(getString(R.string.pref_location_sharing_server_url));
+ serverUrlPref.setText(Config.LocationSharing.getServerUrl());
+ serverUrlPref.setSummary(Config.LocationSharing.getServerUrl());
+ serverUrlPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ String url = (String) newValue;
+ Config.LocationSharing.setServerUrl(url);
+ serverUrlPref.setSummary(url);
+ return true;
+ });
+
+ // Update interval preference
+ final ListPreference intervalPref = getPreference(getString(R.string.pref_location_sharing_update_interval));
+ intervalPref.setValue(String.valueOf(Config.LocationSharing.getUpdateInterval()));
+ intervalPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ int seconds = Integer.parseInt((String) newValue);
+ Config.LocationSharing.setUpdateInterval(seconds);
+ return true;
+ });
+ }
+
@Override
public void onLanguageSelected(Language language)
{
diff --git a/android/app/src/main/res/values/arrays.xml b/android/app/src/main/res/values/arrays.xml
index de2c651d7..1df52df4a 100644
--- a/android/app/src/main/res/values/arrays.xml
+++ b/android/app/src/main/res/values/arrays.xml
@@ -50,4 +50,21 @@
- 8
- 10
+
+
+
+ - 5 seconds
+ - 10 seconds
+ - 20 seconds
+ - 30 seconds
+ - 60 seconds
+
+
+
+ - 5
+ - 10
+ - 20
+ - 30
+ - 60
+
diff --git a/android/app/src/main/res/xml/prefs_main.xml b/android/app/src/main/res/xml/prefs_main.xml
index 32964f38b..9c5f66492 100644
--- a/android/app/src/main/res/xml/prefs_main.xml
+++ b/android/app/src/main/res/xml/prefs_main.xml
@@ -159,6 +159,27 @@
+
+
+
+
Defaults.UPDATE_INTERVAL_MAX)
+ seconds = Defaults.UPDATE_INTERVAL;
+ setInt(Keys.UPDATE_INTERVAL, seconds);
+ }
+ }
+
private static native boolean nativeHasConfigValue(String name);
private static native boolean nativeDeleteConfigValue(String name);
private static native boolean nativeGetBoolean(String name, boolean defaultValue);
diff --git a/iphone/Maps/Core/LocationSharing/LocationSharingSession.swift b/iphone/Maps/Core/LocationSharing/LocationSharingSession.swift
index ab03a3f98..18b2b1e55 100644
--- a/iphone/Maps/Core/LocationSharing/LocationSharingSession.swift
+++ b/iphone/Maps/Core/LocationSharing/LocationSharingSession.swift
@@ -23,6 +23,8 @@ struct LocationSharingConfig {
var includeDestinationName: Bool = true
var includeBatteryLevel: Bool = true
var lowBatteryThreshold: Int = 10
+ // Default from LOCATION_SHARING_SERVER_URL in private.h
+ // Can be overridden by user settings
var serverBaseUrl: String = "https://live.organicmaps.app"
}
diff --git a/libs/platform/platform.cpp b/libs/platform/platform.cpp
index 906758520..1de5d9946 100644
--- a/libs/platform/platform.cpp
+++ b/libs/platform/platform.cpp
@@ -158,6 +158,11 @@ std::string Platform::DefaultUrlsJSON() const
return DEFAULT_URLS_JSON;
}
+std::string Platform::LocationSharingServerUrl() const
+{
+ return LOCATION_SHARING_SERVER_URL;
+}
+
bool Platform::RemoveFileIfExists(std::string const & filePath)
{
return IsFileExistsByFullPath(filePath) ? base::DeleteFileX(filePath) : true;
diff --git a/libs/platform/platform.hpp b/libs/platform/platform.hpp
index 10bc76403..65aa5217c 100644
--- a/libs/platform/platform.hpp
+++ b/libs/platform/platform.hpp
@@ -271,6 +271,9 @@ public:
/// @return JSON-encoded list of urls if metaserver is unreachable
std::string DefaultUrlsJSON() const;
+ /// @return default location sharing server URL
+ std::string LocationSharingServerUrl() const;
+
bool IsTablet() const { return m_isTablet; }
/// @return information about kinds of memory which are relevant for a platform.
diff --git a/private.h b/private.h
index 870f46178..3bfb82735 100644
--- a/private.h
+++ b/private.h
@@ -11,3 +11,4 @@
#define TRAFFIC_DATA_BASE_URL ""
#define USER_BINDING_PKCS12 ""
#define USER_BINDING_PKCS12_PASSWORD ""
+#define LOCATION_SHARING_SERVER_URL "https://ec1e1096-e991-4cb1-ac21-30fbad2bd406.mock.pstmn.io"