Repository: organicmaps/organicmaps
Pull request #10528: [routing] Add possibility to save routes as tracks
Author: login: cyber-toad
Labels: name: Android
description: Android development
Created: 2025-05-10T15:02:46Z | Merged: 2025-05-25T21:38:40Z

Save button is added to Manage Route dialog and stores track with pre-generated name (the same as used for track recorder's tracks).
You can see saved track under the route line from router.
UI suggestions are welcome.


![image](https://github.com/user-attachments/assets/0d912db7-6769-4a0b-9404-82a5548d453b)



Diff:
diff --git a/android/app/src/main/cpp/app/organicmaps/Framework.cpp b/android/app/src/main/cpp/app/organicmaps/Framework.cpp
index 8faef56eab6..4c42f3569e1 100644
--- a/android/app/src/main/cpp/app/organicmaps/Framework.cpp
+++ b/android/app/src/main/cpp/app/organicmaps/Framework.cpp
@@ -1143,6 +1143,12 @@ Java_app_organicmaps_Framework_nativeShowTrackRect(JNIEnv * env, jclass, jlong t
   frm()->ShowTrack(static_cast<kml::TrackId>(track));
 }
 
+JNIEXPORT void JNICALL
+Java_app_organicmaps_Framework_nativeSaveRoute(JNIEnv *, jclass)
+{
+  frm()->SaveRoute();
+}
+
 JNIEXPORT jstring JNICALL
 Java_app_organicmaps_Framework_nativeGetBookmarkDir(JNIEnv * env, jclass)
 {
diff --git a/android/app/src/main/java/app/organicmaps/Framework.java b/android/app/src/main/java/app/organicmaps/Framework.java
index aa55adee7ce..6cb44a302d6 100644
--- a/android/app/src/main/java/app/organicmaps/Framework.java
+++ b/android/app/src/main/java/app/organicmaps/Framework.java
@@ -350,4 +350,6 @@ public static native void nativeAddRoutePoint(String title, String subtitle, @No
   public static native void nativeDidCloseProductsPopup(String reason);
 
   public static native void nativeDidSelectProduct(String title, String link);
+
+  public static native void nativeSaveRoute();
 }
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 b322ccbb601..640e96bbcdf 100644
--- a/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java
+++ b/android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java
@@ -91,7 +91,7 @@ final class RoutingBottomMenuController implements View.OnClickListener
 
   @NonNull
   static RoutingBottomMenuController newInstance(@NonNull Activity activity, @NonNull View frame,
-                                                 @Nullable RoutingBottomMenuListener listener)
+                                                 @NonNull RoutingBottomMenuListener listener)
   {
     View altitudeChartFrame = getViewById(activity, frame, R.id.altitude_chart_panel);
     View timeElevationLine = getViewById(activity, frame, R.id.time_elevation_line);
@@ -159,6 +159,9 @@ private RoutingBottomMenuController(@NonNull Activity context,
                                                          res.getDimensionPixelSize(R.dimen.margin_half));
     Button manageRouteButton = altitudeChartFrame.findViewById(R.id.btn__manage_route);
     manageRouteButton.setOnClickListener(this);
+
+    Button saveButton = altitudeChartFrame.findViewById(R.id.btn__save);
+    saveButton.setOnClickListener(this);
   }
 
   void showAltitudeChartAndRoutingDetails()
@@ -169,6 +172,9 @@ void showAltitudeChartAndRoutingDetails()
       showRouteAltitudeChart();
     showRoutingDetails();
     UiUtils.show(mAltitudeChartFrame);
+    Button saveButton = mAltitudeChartFrame.findViewById(R.id.btn__save);
+    saveButton.setText(R.string.save);
+    saveButton.setEnabled(true);
   }
 
   void hideAltitudeChartAndRoutingDetails()
@@ -485,14 +491,21 @@ private static void initDistanceBuilderSequence(@NonNull Context context, @NonNu
   public void onClick(View v)
   {
     final int id = v.getId();
-    if (id == R.id.btn__my_position_use && mListener != null)
+    if (id == R.id.btn__my_position_use)
       mListener.onUseMyPositionAsStart();
-    else if (id == R.id.btn__search_point && mListener != null)
+    else if (id == R.id.btn__search_point)
     {
       final RouteMarkType pointType = (RouteMarkType) mActionMessage.getTag();
       mListener.onSearchRoutePoint(pointType);
     }
-    else if (id == R.id.btn__manage_route && mListener != null)
+    else if (id == R.id.btn__manage_route)
       mListener.onManageRouteOpen();
+    else if (id == R.id.btn__save)
+    {
+      Framework.nativeSaveRoute();
+      Button saveButton = v.findViewById(R.id.btn__save);
+      saveButton.setEnabled(false);
+      saveButton.setText(R.string.saved);
+    }
   }
 }
diff --git a/android/app/src/main/java/app/organicmaps/routing/RoutingPlanController.java b/android/app/src/main/java/app/organicmaps/routing/RoutingPlanController.java
index de22cff8659..418ba01b39c 100644
--- a/android/app/src/main/java/app/organicmaps/routing/RoutingPlanController.java
+++ b/android/app/src/main/java/app/organicmaps/routing/RoutingPlanController.java
@@ -86,7 +86,7 @@ private void setupRouterButton(@IdRes int buttonId, final @DrawableRes int iconR
   RoutingPlanController(View root, Activity activity,
                         ActivityResultLauncher<Intent> startDrivingOptionsForResult,
                         @NonNull RoutingPlanInplaceController.RoutingPlanListener routingPlanListener,
-                        @Nullable RoutingBottomMenuListener listener)
+                        @NonNull RoutingBottomMenuListener listener)
   {
     super(root, activity);
     mFrame = root;
diff --git a/android/app/src/main/res/layout-land/altitude_chart_panel.xml b/android/app/src/main/res/layout-land/altitude_chart_panel.xml
index 1eac5978aab..d3734dbabe9 100644
--- a/android/app/src/main/res/layout-land/altitude_chart_panel.xml
+++ b/android/app/src/main/res/layout-land/altitude_chart_panel.xml
@@ -73,6 +73,17 @@
       android:drawableStart="@drawable/ic_manage_route"
       android:drawablePadding="6dp"/>
 
+    <Button
+        android:id="@+id/btn__save"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_weight="0"
+        android:layout_gravity="center_vertical"
+        android:layout_marginStart="@dimen/margin_base"
+        android:minHeight="@dimen/primary_button_min_height"
+        style="@style/MwmWidget.Button"
+        android:text="@string/save"/>
+
     <Button
       android:id="@+id/start"
       style="@style/MwmWidget.Button.Primary"
@@ -80,7 +91,7 @@
       android:layout_height="wrap_content"
       android:layout_weight="0"
       android:layout_gravity="center_vertical"
-      android:layout_marginStart="@dimen/margin_base"
+      android:layout_marginStart="@dimen/margin_double"
       android:minWidth="@dimen/start_button_width"
       android:text="@string/p2p_start"
       tools:showIn="@layout/menu_route_plan_line" />
diff --git a/android/app/src/main/res/layout-w1020dp-land/altitude_chart_panel.xml b/android/app/src/main/res/layout-w1020dp-land/altitude_chart_panel.xml
index a73953b6063..6a6be06010e 100644
--- a/android/app/src/main/res/layout-w1020dp-land/altitude_chart_panel.xml
+++ b/android/app/src/main/res/layout-w1020dp-land/altitude_chart_panel.xml
@@ -92,6 +92,15 @@
       android:drawableStart="@drawable/ic_manage_route"
       android:drawablePadding="6dp"/>
 
+    <Button
+        android:id="@+id/btn__save"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="@dimen/margin_base"
+        style="@style/MwmWidget.Button"
+        android:minHeight="@dimen/primary_button_min_height"
+        android:text="@string/save"/>
+
     <Button
       android:id="@+id/start"
       style="@style/MwmWidget.Button.Primary"
@@ -99,7 +108,7 @@
       android:layout_height="wrap_content"
       android:layout_weight="0"
       android:layout_gravity="center_vertical"
-      android:layout_marginStart="@dimen/margin_base"
+      android:layout_marginStart="@dimen/margin_double"
       android:minWidth="@dimen/start_button_width"
       android:text="@string/p2p_start"
       tools:showIn="@layout/menu_route_plan_line" />
diff --git a/android/app/src/main/res/layout/altitude_chart_panel.xml b/android/app/src/main/res/layout/altitude_chart_panel.xml
index f914fe23126..aab45a877bf 100644
--- a/android/app/src/main/res/layout/altitude_chart_panel.xml
+++ b/android/app/src/main/res/layout/altitude_chart_panel.xml
@@ -83,13 +83,28 @@
           android:layout_gravity="center_vertical" />
     </LinearLayout>
 
-    <Button
-      android:id="@+id/btn__manage_route"
-      android:layout_width="wrap_content"
-      android:layout_height="wrap_content"
-      style="@style/MwmWidget.Button"
-      android:text="@string/planning_route_manage_route"
-      android:drawableStart="@drawable/ic_manage_route"
-      android:drawablePadding="6dp"/>
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:gravity="center_vertical" >
+        <Button
+          android:id="@+id/btn__manage_route"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          style="@style/MwmWidget.Button"
+          android:text="@string/planning_route_manage_route"
+          android:drawableStart="@drawable/ic_manage_route"
+          android:drawablePadding="6dp"/>
+
+        <Button
+          android:id="@+id/btn__save"
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          style="@style/MwmWidget.Button"
+          android:layout_marginStart="@dimen/margin_base"
+          android:minHeight="@dimen/primary_button_min_height"
+          android:text="@string/save"/>
+    </LinearLayout>
 
 </LinearLayout>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 0c56f8e04f3..fcec4b6e954 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -648,6 +648,7 @@
     <string name="button_plan">Plan</string>
     <string name="placepage_remove_stop">Remove Stop</string>
     <string name="placepage_add_stop">Add Stop</string>
+    <string name="saved">Saved</string>
     <!-- Alert to ask user relogin to OpenStreetMap with OAuth2 flow after OAuth1 authentication is deprecated. -->
     <string name="alert_reauth_message">Please login to OpenStreetMap to automatically upload all your map edits. Learn more <a href="https://github.com/organicmaps/organicmaps/issues/6144">here</a>.</string>
     <string name="dialog_error_storage_title">Storage access problem</string>
diff --git a/map/bookmark_manager.cpp b/map/bookmark_manager.cpp
index 6a49214cb0e..c7492222c13 100644
--- a/map/bookmark_manager.cpp
+++ b/map/bookmark_manager.cpp
@@ -1170,6 +1170,51 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+std::string BookmarkManager::GenerateSavedRouteName(std::string const & from, std::string const & to)
+{
+  if (!from.empty() && !to.empty())
+    return from + " - " + to;
+  if (!from.empty())
+    return from;
+  if (!to.empty())
+    return to;
+  return GenerateTrackRecordingName();
+}
+
+kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> const & points, std::string const & from, std::string const & to)
+{
+  kml::MultiGeometry geometry;
+  geometry.m_lines.emplace_back();
+  geometry.m_timestamps.emplace_back();
+  auto & line = geometry.m_lines.back();
+
+  for (auto const & pt : points)
+    line.emplace_back(pt);
+
+  kml::TrackData trackData;
+  trackData.m_geometry = std::move(geometry);
+
+  auto trackName = GenerateSavedRouteName(from, to);
+  kml::SetDefaultStr(trackData.m_name, trackName);
+
+  kml::ColorData colorData;
+  colorData.m_rgba = GenerateTrackRecordingColor().GetRGBA();
+  kml::TrackLayer layer;
+  layer.m_color = colorData;
+  std::vector<kml::TrackLayer> m_layers;
+  m_layers.emplace_back(layer);
+  trackData.m_layers = std::move(m_layers);
+
+  trackData.m_timestamp = kml::TimestampClock::now();
+
+  auto editSession = GetEditSession();
+  auto const track = editSession.CreateTrack(std::move(trackData));
+  auto const groupId = LastEditedBMCategory();
+  auto const trackId = track->GetId();
+  AttachTrack(trackId, groupId);
+  return trackId;
+}
+
 void BookmarkManager::PrepareBookmarksAddresses(std::vector<SortBookmarkData> & bookmarksForSort,
                                                 AddressesCollection & newAddresses)
 {
diff --git a/map/bookmark_manager.hpp b/map/bookmark_manager.hpp
index 788feafe62c..d4f6822c0e9 100644
--- a/map/bookmark_manager.hpp
+++ b/map/bookmark_manager.hpp
@@ -29,7 +29,6 @@
 #include <string>
 #include <vector>
 
-
 namespace storage
 {
 class CountryInfoGetter;
@@ -436,6 +435,8 @@ class BookmarkManager final
   std::string GenerateTrackRecordingName() const;
   dp::Color GenerateTrackRecordingColor() const;
 
+  kml::TrackId SaveRoute(std::vector<m2::PointD> const & points, std::string const & from, std::string const & to);
+
 private:
   class MarksChangesTracker : public df::UserMarksProvider
   {
@@ -624,6 +625,7 @@ class BookmarkManager final
   void CleanupInvalidMetadata();
   std::string GetMetadataEntryName(kml::MarkGroupId groupId) const;
 
+  std::string GenerateSavedRouteName(std::string const & from, std::string const & to);
   void NotifyAboutStartAsyncLoading();
   void NotifyAboutFinishAsyncLoading(KMLDataCollectionPtr && collection);
   void NotifyAboutFile(bool success, std::string const & filePath, bool isTemporaryFile);
diff --git a/map/framework.cpp b/map/framework.cpp
index 8a176dd13f4..6e5dba19f18 100644
--- a/map/framework.cpp
+++ b/map/framework.cpp
@@ -1773,6 +1773,11 @@ bool Framework::IsTrackRecordingEnabled() const
   return GpsTracker::Instance().IsEnabled();
 }
 
+void Framework::SaveRoute()
+{
+  m_routingManager.SaveRoute();
+}
+
 void Framework::OnUpdateGpsTrackPointsCallback(vector<pair<size_t, location::GpsInfo>> && toAdd,
                                                pair<size_t, size_t> const & toRemove,
                                                TrackStatistics const & trackStatistics)
diff --git a/map/framework.hpp b/map/framework.hpp
index 51ad421cc0c..d57e0cdcbc6 100644
--- a/map/framework.hpp
+++ b/map/framework.hpp
@@ -444,6 +444,8 @@ class Framework : public PositionProvider,
   void SaveTrackRecordingWithName(std::string const & name);
   bool IsTrackRecordingEmpty() const;
   bool IsTrackRecordingEnabled() const;
+
+  void SaveRoute();
   /// Returns the elevation profile data of the currently recorded track.
   /// To get the data on the every track recording state update, this function should be called after receiving the callback from the `SetTrackRecordingUpdateHandler`.
   static const ElevationInfo & GetTrackRecordingElevationInfo();
diff --git a/map/map_tests/bookmarks_test.cpp b/map/map_tests/bookmarks_test.cpp
index ab18b3eb749..7f20b70252c 100644
--- a/map/map_tests/bookmarks_test.cpp
+++ b/map/map_tests/bookmarks_test.cpp
@@ -1639,4 +1639,18 @@ UNIT_CLASS_TEST(Runner, Bookmarks_RecentlyDeleted)
   TEST(!Platform::IsFileExistsByFullPath(filePath), ());
   TEST(!Platform::IsFileExistsByFullPath(deletedFilePath), ());
 }
+
+UNIT_CLASS_TEST(Runner, Bookmarks_TestSaveRoute)
+{
+  BookmarkManager bmManager(BM_CALLBACKS);
+  bmManager.EnableTestMode(true);
+  auto const points = {m2::PointD(0.0, 0.0), m2::PointD(0.001, 0.001)};
+  auto const trackId = bmManager.SaveRoute(points, "London", "Paris");
+  auto const * track = bmManager.GetTrack(trackId);
+  TEST_EQUAL(track->GetName(), "London - Paris", ());
+  auto const line = track->GetData().m_geometry.m_lines[0];
+  std::vector const expectedLine = {{geometry::PointWithAltitude(m2::PointD(0.0, 0.0)), geometry::PointWithAltitude(m2::PointD(0.001, 0.001))}};
+  TEST_EQUAL(line, expectedLine, ());
+}
+
 } // namespace bookmarks_test
diff --git a/map/routing_manager.cpp b/map/routing_manager.cpp
index fab7dd21e11..7f105b50226 100644
--- a/map/routing_manager.cpp
+++ b/map/routing_manager.cpp
@@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+static std::string GetNameFromPoint(RouteMarkData const & rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
+  return rmd.m_title;
+}
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  auto const routePoints = GetRoutePoints();
+  std::string const from = GetNameFromPoint(routePoints.front());
+  std::string const to = GetNameFromPoint(routePoints.back());
+  // remove equal sequential points
+  points.erase(
+      std::unique(points.begin(), points.end(), [](const m2::PointD & p1, const m2::PointD & p2) { return AlmostEqualAbs(p1, p2, kMwmPointAccuracy); }),
+      points.end());
+
+  m_bmManager->SaveRoute(points, from, to);
+}
+
 bool RoutingManager::DisableFollowMode()
 {
   bool const disabled = m_routingSession.DisableFollowMode();
diff --git a/map/routing_manager.hpp b/map/routing_manager.hpp
index 0141d16ab04..278cc18bbbe 100644
--- a/map/routing_manager.hpp
+++ b/map/routing_manager.hpp
@@ -142,6 +142,7 @@ class RoutingManager final
   // This method was added because we do not want to break the behaviour that is familiar to our
   // users.
   bool DisableFollowMode();
+  void SaveRoute();
 
   void SetRouteBuildingListener(RouteBuildingCallback const & buildingCallback)
   {
@@ -204,7 +205,7 @@ class RoutingManager final
     return m_routingSession.GetTurnNotificationsLocale();
   }
   // @return polyline of the route.
-  routing::FollowedPolyline const & GetRoutePolylineForTests() const
+  routing::FollowedPolyline const & GetRoutePolyline() const
   {
     return m_routingSession.GetRouteForTests()->GetFollowedPolyline();
   }
diff --git a/qt/routing_turns_visualizer.cpp b/qt/routing_turns_visualizer.cpp
index 516c58c6e9a..3744d6209df 100644
--- a/qt/routing_turns_visualizer.cpp
+++ b/qt/routing_turns_visualizer.cpp
@@ -4,7 +4,7 @@ namespace qt
 {
 void RoutingTurnsVisualizer::Visualize(RoutingManager & routingManager, df::DrapeApi & drape)
 {
-  auto const & polyline = routingManager.GetRoutePolylineForTests().GetPolyline();
+  auto const & polyline = routingManager.GetRoutePolyline().GetPolyline();
   auto const & turns = routingManager.GetTurnsOnRouteForTests();
 
   for (auto const & turn : turns)


Reviews:
user: login: vng
body: Need more feedback about UI button.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T03:19:48Z
user: login: biodranik
body: Thanks! There is no indication that a route has already been saved. Maybe disable the save button and change its name to Saved?

Also, does it make sense to fit the save button on the initial route preview screen, without opening the manage route dialog?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T20:46:16Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T21:39:53Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T21:40:50Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:21:14Z
user: login: vng
body: Ready to merge, ATM. Just squash 2 last commits.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-24T18:53:19Z
user: login: biodranik
body: To avoid accidental pressing of Save button, it's better to place it near Manage Route button.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T11:55:54Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:25:59Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:29:05Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:29:56Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:32:11Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:34:24Z
user: login: cyber-toad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T13:36:41Z
user: login: biodranik
body: Thanks, it works. Is it hard to move Save button to avoid accidental taps?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T15:14:54Z
user: login: biodranik
body: Thanks, LGTM

Next step is to properly save intermediate stops, so they can be restored and loaded later in the route planning preview. Without intermediate stops, without the ability to restore the saved route later, and without the ability to navigate the saved route this feature is very limited.

@vng merge?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-25T20:56:58Z

Review comments:
diff_hunk: @@ -1089,6 +1089,18 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  // remove equal sequential points
+  points.erase(
+      std::unique(points.begin(), points.end(), [](const m2::PointD & p1, const m2::PointD & p2) { return p1 == p2; }),
path: map/routing_manager.cpp
user: login: vng
body: We have some better criteria in kml/gpx import with comparing (lon, lat or mercator) points.
created_at: 2025-05-15T03:19:09Z
author_association: MEMBER
diff_hunk: @@ -1089,6 +1089,18 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  // remove equal sequential points
+  points.erase(
+      std::unique(points.begin(), points.end(), [](const m2::PointD & p1, const m2::PointD & p2) { return p1 == p2; }),
path: map/routing_manager.cpp
user: login: biodranik
body: Check math::AlmostEqualULPs or math::AlmostEqualAbs
created_at: 2025-05-15T20:31:22Z
author_association: MEMBER
diff_hunk: @@ -1143,6 +1143,12 @@ Java_app_organicmaps_Framework_nativeShowTrackRect(JNIEnv * env, jclass, jlong t
   frm()->ShowTrack(static_cast<kml::TrackId>(track));
 }
 
+JNIEXPORT void JNICALL
+Java_app_organicmaps_Framework_nativeSaveRoute(JNIEnv * env, jclass)
path: android/app/src/main/cpp/app/organicmaps/Framework.cpp
user: login: biodranik
body: nit:
```suggestion
Java_app_organicmaps_Framework_nativeSaveRoute(JNIEnv *, jclass)
```
created_at: 2025-05-15T20:32:54Z
author_association: MEMBER
diff_hunk: @@ -1170,6 +1170,40 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> points)
path: map/bookmark_manager.cpp
user: login: biodranik
body: ```suggestion
kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> const & points)
```
created_at: 2025-05-15T20:34:50Z
author_association: MEMBER
diff_hunk: @@ -1170,6 +1170,40 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> points)
path: map/bookmark_manager.cpp
user: login: biodranik
body: 
Any ideas about a good saved route name passed here as a parameter?
created_at: 2025-05-15T20:35:09Z
author_association: MEMBER
diff_hunk: @@ -1170,6 +1170,40 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> points)
path: map/bookmark_manager.cpp
user: login: cyber-toad
body: > Any ideas about a good saved route name passed here as a parameter?

You mean something better than GenerateTrackRecordingName()? When I tap on the screen some names are displayed, but it is not clear what to use as starting point and sometimes tapped names are too generic like 'bicycle lane'. 
created_at: 2025-05-15T21:39:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1170,6 +1170,40 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+kml::TrackId BookmarkManager::SaveRoute(std::vector<m2::PointD> points)
path: map/bookmark_manager.cpp
user: login: cyber-toad
body: > Suggested change

fixed
created_at: 2025-05-15T21:40:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1089,6 +1089,18 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  // remove equal sequential points
+  points.erase(
+      std::unique(points.begin(), points.end(), [](const m2::PointD & p1, const m2::PointD & p2) { return p1 == p2; }),
path: map/routing_manager.cpp
user: login: cyber-toad
body: Added AlmostEqualAbs
created_at: 2025-05-15T22:21:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -485,14 +491,23 @@ private static void initDistanceBuilderSequence(@NonNull Context context, @NonNu
   public void onClick(View v)
   {
     final int id = v.getId();
-    if (id == R.id.btn__my_position_use && mListener != null)
+    if (mListener == null)
path: android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java
user: login: biodranik
body: How is this case possible?
created_at: 2025-05-25T09:50:26Z
author_association: MEMBER
diff_hunk: @@ -1170,6 +1170,52 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+std::string BookmarkManager::GenerateSavedRouteName(std::string const & from, std::string const & to)
+{
+  if (!from.empty() && !to.empty())
+    return from + " - " + to;
+  else if (!from.empty())
+    return from;
+  else if (!to.empty())
+    return to;
+  else
+    return GenerateTrackRecordingName();
path: map/bookmark_manager.cpp
user: login: biodranik
body: nit:
```suggestion
  return GenerateTrackRecordingName();
```
created_at: 2025-05-25T11:38:20Z
author_association: MEMBER
diff_hunk: @@ -1639,4 +1639,18 @@ UNIT_CLASS_TEST(Runner, Bookmarks_RecentlyDeleted)
   TEST(!Platform::IsFileExistsByFullPath(filePath), ());
   TEST(!Platform::IsFileExistsByFullPath(deletedFilePath), ());
 }
+
+UNIT_CLASS_TEST(Runner, Bookmarks_TestSaveRoute)
+{
+  BookmarkManager bmManager(BM_CALLBACKS);
+  bmManager.EnableTestMode(true);
+  auto points = {m2::PointD(0.0, 0.0), m2::PointD(0.001, 0.001)};
+  auto trackId = bmManager.SaveRoute(points, "London", "Paris");
+  auto track = bmManager.GetTrack(trackId);
+  TEST_EQUAL(track->GetName(), "London - Paris", ());
+  auto line = track->GetData().m_geometry.m_lines[0];
+  vector expectedLine = {{geometry::PointWithAltitude(m2::PointD(0.0, 0.0)), geometry::PointWithAltitude(m2::PointD(0.001, 0.001))}};
path: map/map_tests/bookmarks_test.cpp
user: login: biodranik
body: ```suggestion
  std::vector const expectedLine = {{geometry::PointWithAltitude(m2::PointD(0.0, 0.0)), geometry::PointWithAltitude(m2::PointD(0.001, 0.001))}};
```
created_at: 2025-05-25T11:39:05Z
author_association: MEMBER
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
path: map/routing_manager.cpp
user: login: biodranik
body: No need to copy rmd here. static or anonymous namespace {} will not expose the function to the outside world.
```suggestion
static std::string GetNameFromPoint(RouteMarkData const & rmd)
```
created_at: 2025-05-25T11:40:39Z
author_association: MEMBER
diff_hunk: @@ -1639,4 +1639,18 @@ UNIT_CLASS_TEST(Runner, Bookmarks_RecentlyDeleted)
   TEST(!Platform::IsFileExistsByFullPath(filePath), ());
   TEST(!Platform::IsFileExistsByFullPath(deletedFilePath), ());
 }
+
+UNIT_CLASS_TEST(Runner, Bookmarks_TestSaveRoute)
+{
+  BookmarkManager bmManager(BM_CALLBACKS);
+  bmManager.EnableTestMode(true);
+  auto points = {m2::PointD(0.0, 0.0), m2::PointD(0.001, 0.001)};
+  auto trackId = bmManager.SaveRoute(points, "London", "Paris");
+  auto track = bmManager.GetTrack(trackId);
+  TEST_EQUAL(track->GetName(), "London - Paris", ());
+  auto line = track->GetData().m_geometry.m_lines[0];
path: map/map_tests/bookmarks_test.cpp
user: login: biodranik
body: nit: use const

Note that for pointers it's clearer to use `auto const * track` (it also has different behavior as explained [here](https://stackoverflow.com/a/58283128)).
created_at: 2025-05-25T11:47:22Z
author_association: MEMBER
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
path: map/routing_manager.cpp
user: login: biodranik
body: What is in the m_title in this case?
created_at: 2025-05-25T11:47:48Z
author_association: MEMBER
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
+  return rmd.m_title;
+}
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  auto routePoints = GetRoutePoints();
+  std::string from = GetNameFromPoint(routePoints.front());
+  std::string to = GetNameFromPoint(routePoints.back());
+  // remove equal sequential points
+  points.erase(
+      std::unique(points.begin(), points.end(), [](const m2::PointD & p1, const m2::PointD & p2) { return AlmostEqualAbs(p1, p2, kMwmPointAccuracy); }),
+      points.end());
path: map/routing_manager.cpp
user: login: biodranik
body: Why are equal points appearing in our routes? Should filtering be implemented inside GetRoutePolyline() instead (which in turn should be renamed to something else esp. for processing intermediate stops)?

@vng 
created_at: 2025-05-25T11:51:35Z
author_association: MEMBER
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
+  return rmd.m_title;
+}
+
+void RoutingManager::SaveRoute()
+{
+  auto points = GetRoutePolyline().GetPolyline().GetPoints();
+  auto routePoints = GetRoutePoints();
+  std::string from = GetNameFromPoint(routePoints.front());
+  std::string to = GetNameFromPoint(routePoints.back());
path: map/routing_manager.cpp
user: login: biodranik
body: nit
```suggestion
  std::string const from = GetNameFromPoint(routePoints.front());
  std::string const to = GetNameFromPoint(routePoints.back());
```
created_at: 2025-05-25T11:54:55Z
author_association: MEMBER
diff_hunk: @@ -485,14 +491,23 @@ private static void initDistanceBuilderSequence(@NonNull Context context, @NonNu
   public void onClick(View v)
   {
     final int id = v.getId();
-    if (id == R.id.btn__my_position_use && mListener != null)
+    if (mListener == null)
path: android/app/src/main/java/app/organicmaps/routing/RoutingBottomMenuController.java
user: login: cyber-toad
body: It is interesting, this check (mListener != null) was since the first version of the file in all if's, I just extracted it to make code cleaner. But looks like the only way this class is initialized is when not-null listener is passed, so I'll remove this check for test.
created_at: 2025-05-25T13:25:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1170,6 +1170,52 @@ dp::Color BookmarkManager::GenerateTrackRecordingColor() const
   return kml::ColorFromPredefinedColor(kml::GetRandomPredefinedColor());
 }
 
+std::string BookmarkManager::GenerateSavedRouteName(std::string const & from, std::string const & to)
+{
+  if (!from.empty() && !to.empty())
+    return from + " - " + to;
+  else if (!from.empty())
+    return from;
+  else if (!to.empty())
+    return to;
+  else
+    return GenerateTrackRecordingName();
path: map/bookmark_manager.cpp
user: login: cyber-toad
body: Fixed
created_at: 2025-05-25T13:29:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1639,4 +1639,18 @@ UNIT_CLASS_TEST(Runner, Bookmarks_RecentlyDeleted)
   TEST(!Platform::IsFileExistsByFullPath(filePath), ());
   TEST(!Platform::IsFileExistsByFullPath(deletedFilePath), ());
 }
+
+UNIT_CLASS_TEST(Runner, Bookmarks_TestSaveRoute)
+{
+  BookmarkManager bmManager(BM_CALLBACKS);
+  bmManager.EnableTestMode(true);
+  auto points = {m2::PointD(0.0, 0.0), m2::PointD(0.001, 0.001)};
+  auto trackId = bmManager.SaveRoute(points, "London", "Paris");
+  auto track = bmManager.GetTrack(trackId);
+  TEST_EQUAL(track->GetName(), "London - Paris", ());
+  auto line = track->GetData().m_geometry.m_lines[0];
+  vector expectedLine = {{geometry::PointWithAltitude(m2::PointD(0.0, 0.0)), geometry::PointWithAltitude(m2::PointD(0.001, 0.001))}};
path: map/map_tests/bookmarks_test.cpp
user: login: cyber-toad
body: Fixed
created_at: 2025-05-25T13:29:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
path: map/routing_manager.cpp
user: login: cyber-toad
body: m_title could be some generic thing like "Forest" if we tap on area/etc. But if it is some object (POI, city point etc) we can use title.
created_at: 2025-05-25T13:32:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1639,4 +1639,18 @@ UNIT_CLASS_TEST(Runner, Bookmarks_RecentlyDeleted)
   TEST(!Platform::IsFileExistsByFullPath(filePath), ());
   TEST(!Platform::IsFileExistsByFullPath(deletedFilePath), ());
 }
+
+UNIT_CLASS_TEST(Runner, Bookmarks_TestSaveRoute)
+{
+  BookmarkManager bmManager(BM_CALLBACKS);
+  bmManager.EnableTestMode(true);
+  auto points = {m2::PointD(0.0, 0.0), m2::PointD(0.001, 0.001)};
+  auto trackId = bmManager.SaveRoute(points, "London", "Paris");
+  auto track = bmManager.GetTrack(trackId);
+  TEST_EQUAL(track->GetName(), "London - Paris", ());
+  auto line = track->GetData().m_geometry.m_lines[0];
path: map/map_tests/bookmarks_test.cpp
user: login: cyber-toad
body: Fixed
created_at: 2025-05-25T13:34:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
path: map/routing_manager.cpp
user: login: cyber-toad
body: Fixed
created_at: 2025-05-25T13:36:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1089,6 +1089,27 @@ void RoutingManager::SetUserCurrentPosition(m2::PointD const & position)
   }
 }
 
+std::string GetNameFromPoint(RouteMarkData rmd)
+{
+  if (rmd.m_subTitle.empty())
+    return "";
path: map/routing_manager.cpp
user: login: biodranik
body: Thanks! We can tune track names after testing it extensively.
created_at: 2025-05-25T15:13:55Z
author_association: MEMBER

PR comments:
user: login: antroyd
created_at: 2025-05-10T15:22:12Z
body: Looking very good; especially, if the route could be exported to .kml or .gpx after saving it to favourites. Which version will this option be rolled out with?
author_association: NONE
user: login: vng
created_at: 2025-05-14T09:08:43Z
body: Please, rebase on the latest master to run Actions.
author_association: MEMBER
user: login: cyber-toad
created_at: 2025-05-14T20:43:23Z
body: > Please, rebase on the latest master to run Actions.

branch is updated and build is green
author_association: CONTRIBUTOR
user: login: cyber-toad
created_at: 2025-05-15T21:19:03Z
body: > Thanks! There is no indication that a route has already been saved. Maybe disable the save button and change its name to Saved?

Save button is disabled and caption is changed now.

> 
> Also, does it make sense to fit the save button on the initial route preview screen, without opening the manage route dialog?

I implemented it initially, but if buttons "Save" and "Start" are in one line, than there were much less space for altitude chart.


author_association: CONTRIBUTOR
user: login: cyber-toad
created_at: 2025-05-18T09:40:46Z
body: @biodranik @vng please have a look at my comments and let me know what else is required for this feature.
Failed test is osm auth integration test with dev server.
author_association: CONTRIBUTOR
user: login: biodranik
created_at: 2025-05-18T10:49:14Z
body: @cyber-toad thanks again for starting this important part of the folowing feature (sharing/navigating the saved tracks https://github.com/organicmaps/organicmaps/issues/1360 )

The https://github.com/organicmaps/organicmaps/issues/1785 issue mentions that intermediate stops also should be saved (to restore them later for navigation/rendering). Can you please check what would be the best way to implement it in KML and GPX?

Re: track names. Often there are names of the starting and (or) ending points displayed in the Place Page. Saving them as is into the track waypoints and into the track name greatly improves usability (now recorded tracks are not distinguishable from saved tracks).

Regarding the UX/UI (after some testing on a phone):
- Landscape definitely has a space on the left for the Save button on the route build screen.
- Portrait also has space (almost all line is empty near Manage Route button.
- Manage Route looks unnatural, is there a separate issue to improve its UX? The current panel with the elevation can be made draggable up, like a place page, and allow customizing the route below.
- After testing, it looks like a simpler approach with the current implementation is to automatically close the manage route dialog when pressing save to avoid confusion (cancel or plan button?) and save time.
author_association: MEMBER
user: login: cyber-toad
created_at: 2025-05-19T21:39:35Z
body: > @cyber-toad thanks again for starting this important part of the folowing feature (sharing/navigating the saved tracks #1360 )
> 
> The #1785 issue mentions that intermediate stops also should be saved (to restore them later for navigation/rendering). Can you please check what would be the best way to implement it in KML and GPX?

You mean that we need to extend current TrackData structure and store points there? But I don't think we have anything in renderer now that supports some special points - all of them are displayed equally?

> 
> Regarding the UX/UI (after some testing on a phone):
> 
>     * Landscape definitely has a space on the left for the Save button on the route build screen.
> 
>     * Portrait also has space (almost all line is empty near Manage Route button.
> 
>     * Manage Route looks unnatural, is there a separate issue to improve its UX? The current panel with the elevation can be made draggable up, like a place page, and allow customizing the route below.
> 
>     * After testing, it looks like a simpler approach with the current implementation is to automatically close the manage route dialog when pressing save to avoid confusion (cancel or plan button?) and save time.

Let's decide for now do we move it to the main route page (with altitude char) or keep on "Manage route".  Save on main page saves 1 click (drag), but I can't guess how often users need this, maybe it will overload main routing panel. Maybe we could have some icon (like "share" on place page) to make it lighter? 
For close when Save is pressed - it will save a click, but do we need to show any popup that save is completed, without it user could be confused - I'm not sure what are Android guidelines for such behavior.


author_association: CONTRIBUTOR
user: login: cyber-toad
created_at: 2025-05-21T22:15:59Z
body: Simple track name generation (if "from" or "to" point is a POI) is added.
author_association: CONTRIBUTOR
user: login: biodranik
created_at: 2025-05-23T19:58:46Z
body: > But I don't think we have anything in renderer now that supports some special points - all of them are displayed equally?

Even if renderer draws them equally, that should not break route save/restore/share. Proper rendering can be added later, and it should not be too hard, because intermediate points are already rendered for a planned route. Rendering them in the same way can be easier.

> Save on main page saves 1 click (drag), but I can't guess how often users need this, maybe it will overload main routing panel. 

Users should know about this feature => it should be visible. If we get many complaints about overloading, then it can be hidden away. This is an important step for route planning on a phone.

Note that together with intermediate stops, the mode of the route (and likely avoid route options too) should also be saved as some metadata.
author_association: MEMBER
user: login: cyber-toad
created_at: 2025-05-24T21:50:16Z
body: thanks, noticed about couple of bugs, will fix them first and then update history
author_association: CONTRIBUTOR
user: login: cyber-toad
created_at: 2025-05-25T08:22:43Z
body: @vng commits are fixed

New button
![image](https://github.com/user-attachments/assets/c1bfdddc-18f6-4f4f-bc5d-ceff7702d3dc)

After button is pressed
![image](https://github.com/user-attachments/assets/3c524272-e0e4-4d36-9c9d-09fa7f580c75)

Horizontal layout
![image](https://github.com/user-attachments/assets/6dae56dc-ad50-4809-b69e-f572b420cc3f)

Please have a look.
author_association: CONTRIBUTOR
user: login: cyber-toad
created_at: 2025-05-25T17:08:00Z
body: > Thanks, it works. Is it hard to move Save button to avoid accidental taps?

Fixed, here are examples:

Vertical layout:

![image](https://github.com/user-attachments/assets/7f9aba7f-a066-45ba-9861-e54c5ef0f5bf)

Horizontal layout:

![image](https://github.com/user-attachments/assets/c58e4355-5481-47f4-9ab9-c1f178295fe0)

author_association: CONTRIBUTOR
user: login: biodranik
created_at: 2025-06-27T09:31:10Z
body: There is a layout bug:
![telegram-cloud-photo-size-2-5400125345937617886-y](https://github.com/user-attachments/assets/5ff82d0a-e66c-41f7-82e6-035004bf7c29)

author_association: MEMBER
user: login: antroyd
created_at: 2025-06-27T09:45:31Z
body: Already reported, see #10782.
author_association: NONE