Compare commits

..

2 Commits

Author SHA1 Message Date
zyphlar
1b4e0fa8cc First attempt at allowing Android users to open/import MWMs directly
Signed-off-by: zyphlar <zyphlar@gmail.com>
2026-01-19 15:11:08 -08:00
Jean-Baptiste
e56e54f994 [android] Update colors used in the editor
Signed-off-by: Jean-Baptiste <jeanbaptiste.charron@outlook.fr>
2026-01-19 09:37:46 +01:00
22 changed files with 609 additions and 84 deletions

View File

@@ -346,6 +346,60 @@
<data android:mimeType="text/xml" />
</intent-filter>
<!-- Custom MWM map files -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:host="*"/>
<data android:mimeType="*/*"/>
<!-- See http://stackoverflow.com/questions/3400072/pathpattern-to-match-file-extension-does-not-work-if-a-period-exists-elsewhere-i -->
<data android:pathPattern="/.*\\.mwm" />
<data android:pathPattern="/.*\\.MWM" />
<data android:pathPattern="/.*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
</intent-filter>
<!-- Duplicate without mimeType for MWM files -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:host="*"/>
<data android:pathPattern="/.*\\.mwm" />
<data android:pathPattern="/.*\\.MWM" />
<data android:pathPattern="/.*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.mwm" />
<data android:pathPattern="/.*\\..*\\..*\\..*\\..*\\..*\\..*\\..*\\.MWM" />
</intent-filter>
</activity>
<activity

View File

@@ -331,6 +331,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
}
final IntentProcessor[] mIntentProcessors = {
new Factory.MwmFileProcessor(),
new Factory.UrlProcessor(),
new Factory.KmzKmlProcessor(),
};

View File

@@ -473,7 +473,16 @@ class DownloaderAdapter extends RecyclerView.Adapter<DownloaderAdapter.ViewHolde
{
mName.setText(mItem.name);
if (!mItem.isExpandable())
UiUtils.setTextAndHideIfEmpty(mSubtitle, mItem.description);
{
// Show version info for downloaded maps (in "My Maps" mode)
String subtitle = mItem.description;
if (mMyMapsMode && mItem.present && mItem.localVersion > 0)
{
String versionStr = formatVersion(mItem.localVersion);
subtitle = TextUtils.isEmpty(subtitle) ? "v" + versionStr : subtitle + " • v" + versionStr;
}
UiUtils.setTextAndHideIfEmpty(mSubtitle, subtitle);
}
}
if (mItem.isExpandable())
@@ -509,6 +518,21 @@ class DownloaderAdapter extends RecyclerView.Adapter<DownloaderAdapter.ViewHolde
}
return size;
}
/**
* Formats a version number (YYMMDD) into a readable date string (YY.MM.DD).
*/
private String formatVersion(long version)
{
if (version <= 0)
return "";
String v = String.valueOf(version);
// Pad with leading zeros if needed
while (v.length() < 6)
v = "0" + v;
// Format as YY.MM.DD
return v.substring(0, 2) + "." + v.substring(2, 4) + "." + v.substring(4, 6);
}
}
static class HeaderViewHolder extends BaseInnerViewHolder<String>

View File

@@ -23,10 +23,12 @@ import app.organicmaps.sdk.routing.RoutingController;
import app.organicmaps.sdk.search.SearchEngine;
import app.organicmaps.sdk.util.StorageUtils;
import app.organicmaps.sdk.util.concurrency.ThreadPool;
import app.organicmaps.sdk.downloader.CustomMwmManager;
import app.organicmaps.search.SearchActivity;
import java.io.File;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public class Factory
{
@@ -65,6 +67,92 @@ public class Factory
}
}
public static class MwmFileProcessor implements IntentProcessor
{
private static final String MWM_EXTENSION = ".mwm";
@Override
public boolean process(@NonNull Intent intent, @NonNull MwmActivity activity)
{
if (!Intent.ACTION_VIEW.equals(intent.getAction()))
return false;
final Uri uri = intent.getData();
if (uri == null)
return false;
// Check if this is an MWM file
if (!isMwmFile(activity, uri))
return false;
// Import the MWM file on a background thread
ThreadPool.getStorage().execute(() -> {
CustomMwmManager.ImportResult result = CustomMwmManager.importMwmFile(activity, uri);
// Show result on UI thread
activity.runOnUiThread(() -> {
switch (result)
{
case SUCCESS:
android.widget.Toast.makeText(activity,
activity.getString(app.organicmaps.R.string.custom_mwm_import_success),
android.widget.Toast.LENGTH_LONG).show();
// Reload maps to include the new custom map
Framework.nativeReloadWorldMaps();
break;
case ERROR_INVALID_FILE:
android.widget.Toast.makeText(activity,
activity.getString(app.organicmaps.R.string.custom_mwm_import_invalid),
android.widget.Toast.LENGTH_LONG).show();
break;
case ERROR_IO:
case ERROR_STORAGE:
android.widget.Toast.makeText(activity,
activity.getString(app.organicmaps.R.string.custom_mwm_import_error),
android.widget.Toast.LENGTH_LONG).show();
break;
}
});
});
return true;
}
private boolean isMwmFile(@NonNull MwmActivity activity, @NonNull Uri uri)
{
String fileName = null;
// Try to get filename from content resolver
if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme()))
{
try (android.database.Cursor cursor = activity.getContentResolver().query(
uri, new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null))
{
if (cursor != null && cursor.moveToFirst())
{
int nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
if (nameIndex >= 0)
fileName = cursor.getString(nameIndex);
}
}
catch (Exception ignored) {}
}
// Fallback to URI path
if (fileName == null)
{
String path = uri.getPath();
if (path != null)
{
int lastSlash = path.lastIndexOf('/');
fileName = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
}
}
return fileName != null && fileName.toLowerCase(Locale.US).endsWith(MWM_EXTENSION);
}
}
public static class UrlProcessor implements IntentProcessor
{
private static final int SEARCH_IN_VIEWPORT_ZOOM = 16;

View File

@@ -6,7 +6,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:background="@color/bg_editor"
android:background="?colorSurfaceContainerLow"
tools:context=".editor.EditorActivity">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -10,7 +10,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/frameLayout"
android:background="@color/bg_editor"
android:background="?colorSurfaceContainerLow"
android:layout_marginBottom="@dimen/margin_quarter">
<androidx.recyclerview.widget.RecyclerView

View File

@@ -9,7 +9,7 @@
style="@style/MwmWidget.FrameLayout.Elevation"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg_editor"
android:background="?colorSurfaceContainerLow"
android:layout_above="@+id/tv__mode_switch"
android:layout_below="@id/toolbar"/>
@@ -19,7 +19,7 @@
android:layout_height="wrap_content"
android:layout_alignTop="@+id/tv__mode_switch"
android:layout_alignParentBottom="true"
android:background="@color/bg_editor"/>
android:background="?colorSurfaceContainerLow"/>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tv__mode_switch"

View File

@@ -5,7 +5,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg_editor">
android:background="?colorSurfaceContainerLow">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -4,7 +4,7 @@
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg_editor"
android:background="?colorSurfaceContainerLow"
android:paddingStart="@dimen/margin_half"
android:paddingEnd="@dimen/margin_half"
android:scrollbars="vertical"/>

View File

@@ -7,7 +7,6 @@
android:orientation="vertical"
android:paddingEnd="@dimen/margin_base"
android:paddingStart="@dimen/margin_base"
android:background="@color/fg_editor"
android:animateLayoutChanges="true">
<com.google.android.material.textview.MaterialTextView
@@ -25,7 +24,6 @@
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/fg_editor"
android:scrollbars="vertical"/>
<com.google.android.material.button.MaterialButton

View File

@@ -16,8 +16,6 @@
<color name="bg_panel">@color/bg_window</color>
<color name="bg_primary_dark">#FF588157</color>
<color name="bg_app">#10140F</color>
<color name="bg_editor">#161b14</color>
<color name="fg_editor">#282e25</color>
<color name="bg_menu">#CC2D3237</color>

View File

@@ -65,8 +65,6 @@
<color name="bg_panel">@color/bg_window</color>
<color name="bg_primary_dark">#37653F</color> <!-- secondary dark -->
<color name="bg_app">@android:color/white</color>
<color name="bg_editor">#ebefe4</color>
<color name="fg_editor">#f9faf2</color>
<color name="bg_dialog_translucent">#BB000000</color>
<color name="bg_text_translucent">#99FFFFFF</color>

View File

@@ -975,4 +975,8 @@
<string name="download_resources_custom_url_message">Override the default map download server used for map downloads. Leave empty to use CoMaps default server.</string>
<string name="download_resources_custom_url_summary_none">Not set</string>
<string name="download_resources_custom_url_error_scheme">Please enter a URL starting with http:// or https://</string>
<!-- Custom MWM file import messages -->
<string name="custom_mwm_import_success">Custom map imported successfully. Restart the app to load it.</string>
<string name="custom_mwm_import_invalid">Invalid MWM file. Please select a valid map file.</string>
<string name="custom_mwm_import_error">Failed to import the map file. Please try again.</string>
</resources>

View File

@@ -89,7 +89,7 @@
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginBottom">@dimen/margin_half</item>
<item name="cardBackgroundColor">@color/fg_editor</item>
<item name="cardBackgroundColor">?appBackground</item>
<item name="android:padding">@dimen/margin_base</item>
<item name="cardPreventCornerOverlap">false</item>
</style>

View File

@@ -67,8 +67,8 @@ struct CountryItemBuilder
jclass m_class;
jmethodID m_ctor;
jfieldID m_Id, m_Name, m_DirectParentId, m_TopmostParentId, m_DirectParentName, m_TopmostParentName, m_Description,
m_Size, m_EnqueuedSize, m_TotalSize, m_ChildCount, m_TotalChildCount, m_Present, m_Progress, m_DownloadedBytes,
m_BytesToDownload, m_Category, m_Status, m_ErrorCode;
m_Size, m_EnqueuedSize, m_TotalSize, m_LocalVersion, m_ChildCount, m_TotalChildCount, m_Present, m_Progress,
m_DownloadedBytes, m_BytesToDownload, m_Category, m_Status, m_ErrorCode;
CountryItemBuilder(JNIEnv * env)
{
@@ -85,6 +85,7 @@ struct CountryItemBuilder
m_Size = env->GetFieldID(m_class, "size", "J");
m_EnqueuedSize = env->GetFieldID(m_class, "enqueuedSize", "J");
m_TotalSize = env->GetFieldID(m_class, "totalSize", "J");
m_LocalVersion = env->GetFieldID(m_class, "localVersion", "J");
m_ChildCount = env->GetFieldID(m_class, "childCount", "I");
m_TotalChildCount = env->GetFieldID(m_class, "totalChildCount", "I");
m_Present = env->GetFieldID(m_class, "present", "Z");
@@ -221,6 +222,9 @@ static void UpdateItem(JNIEnv * env, jobject item, storage::NodeAttrs const & at
env->SetLongField(item, ciBuilder.m_EnqueuedSize, attrs.m_downloadingMwmSize);
env->SetLongField(item, ciBuilder.m_TotalSize, attrs.m_mwmSize);
// Local version (YYMMDD format)
env->SetLongField(item, ciBuilder.m_LocalVersion, attrs.m_localMwmVersion);
// Child counts
env->SetIntField(item, ciBuilder.m_ChildCount, attrs.m_downloadingMwmCounter);
env->SetIntField(item, ciBuilder.m_TotalChildCount, attrs.m_mwmCounter);

View File

@@ -53,6 +53,9 @@ public final class CountryItem implements Comparable<CountryItem>
public long enqueuedSize;
public long totalSize;
// Local MWM file version (YYMMDD format, e.g. 251231). 0 if not downloaded.
public long localVersion;
public int childCount;
public int totalChildCount;
@@ -155,7 +158,7 @@ public final class CountryItem implements Comparable<CountryItem>
+ "\", category: \"" + category + "\", name: \"" + name + "\", directParentName: \"" + directParentName
+ "\", topmostParentName: \"" + topmostParentName + "\", present: " + present + ", status: " + status
+ ", errorCode: " + errorCode + ", headerId: " + headerId + ", size: " + size + ", enqueuedSize: " + enqueuedSize
+ ", totalSize: " + totalSize + ", childCount: " + childCount + ", totalChildCount: " + totalChildCount
+ ", progress: " + StringUtils.formatUsingUsLocale("%.2f", progress) + "% }";
+ ", totalSize: " + totalSize + ", localVersion: " + localVersion + ", childCount: " + childCount
+ ", totalChildCount: " + totalChildCount + ", progress: " + StringUtils.formatUsingUsLocale("%.2f", progress) + "% }";
}
}

View File

@@ -0,0 +1,359 @@
package app.organicmaps.sdk.downloader;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.organicmaps.sdk.Framework;
import app.organicmaps.sdk.util.StorageUtils;
import app.organicmaps.sdk.util.log.Logger;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Manages custom MWM map files that are manually imported by the user.
* Custom maps are stored in dated folders (YYMMDD format) and take precedence
* over downloaded maps when loading.
*/
public class CustomMwmManager
{
private static final String TAG = CustomMwmManager.class.getSimpleName();
private static final String CUSTOM_MAPS_DIR = "custom_maps";
private static final String MWM_EXTENSION = ".mwm";
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyMMdd", Locale.US);
public enum ImportResult
{
SUCCESS,
ERROR_INVALID_FILE,
ERROR_IO,
ERROR_STORAGE
}
/**
* Represents a custom MWM file with its metadata.
*/
public static class CustomMwmFile
{
public final String name; // e.g., "Portland"
public final String path; // Full path to the file
public final long version; // Date version as YYMMDD number
public final long fileSize;
public CustomMwmFile(String name, String path, long version, long fileSize)
{
this.name = name;
this.path = path;
this.version = version;
this.fileSize = fileSize;
}
}
/**
* Gets the custom maps root directory path.
*/
@NonNull
public static String getCustomMapsDir(@NonNull Context context)
{
String writableDir = Framework.nativeGetWritableDir();
return StorageUtils.addTrailingSeparator(writableDir) + CUSTOM_MAPS_DIR;
}
/**
* Gets or creates the custom maps directory for today's date.
* @return The directory path, or null if creation failed.
*/
@Nullable
public static String getTodayCustomMapsDir(@NonNull Context context)
{
String customMapsDir = getCustomMapsDir(context);
String today = DATE_FORMAT.format(new Date());
String todayDir = StorageUtils.addTrailingSeparator(customMapsDir) + today;
File dir = new File(todayDir);
if (!dir.exists() && !dir.mkdirs())
{
Logger.e(TAG, "Failed to create custom maps directory: " + todayDir);
return null;
}
return todayDir;
}
/**
* Imports an MWM file from a content URI into the custom maps directory.
* The file is saved in a dated folder based on today's date.
*
* @param context Application context
* @param uri Content URI of the MWM file
* @return ImportResult indicating success or the type of error
*/
@NonNull
public static ImportResult importMwmFile(@NonNull Context context, @NonNull Uri uri)
{
String fileName = getFileNameFromUri(context, uri);
if (fileName == null || !fileName.toLowerCase(Locale.US).endsWith(MWM_EXTENSION))
{
Logger.e(TAG, "Invalid file name or not an MWM file: " + fileName);
return ImportResult.ERROR_INVALID_FILE;
}
String destDir = getTodayCustomMapsDir(context);
if (destDir == null)
{
return ImportResult.ERROR_STORAGE;
}
File destFile = new File(destDir, fileName);
Logger.i(TAG, "Importing MWM file to: " + destFile.getAbsolutePath());
try
{
ContentResolver resolver = context.getContentResolver();
if (!StorageUtils.copyFile(resolver, uri, destFile))
{
Logger.e(TAG, "Failed to copy MWM file");
return ImportResult.ERROR_IO;
}
Logger.i(TAG, "Successfully imported MWM file: " + fileName);
return ImportResult.SUCCESS;
}
catch (IOException e)
{
Logger.e(TAG, "IOException while importing MWM file", e);
return ImportResult.ERROR_IO;
}
}
/**
* Gets the file name from a content URI.
*/
@Nullable
private static String getFileNameFromUri(@NonNull Context context, @NonNull Uri uri)
{
String fileName = null;
if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme()))
{
try (android.database.Cursor cursor = context.getContentResolver().query(
uri, new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, null, null, null))
{
if (cursor != null && cursor.moveToFirst())
{
int nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
if (nameIndex >= 0)
{
fileName = cursor.getString(nameIndex);
}
}
}
catch (Exception e)
{
Logger.e(TAG, "Failed to get file name from URI", e);
}
}
if (fileName == null)
{
// Try to get from path
String path = uri.getPath();
if (path != null)
{
int lastSlash = path.lastIndexOf('/');
fileName = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
}
}
return fileName;
}
/**
* Lists all custom MWM files, grouped by map name with the newest version taking precedence.
* @return Map from country name to CustomMwmFile (newest version)
*/
@NonNull
public static Map<String, CustomMwmFile> getCustomMwmFiles(@NonNull Context context)
{
Map<String, CustomMwmFile> result = new HashMap<>();
String customMapsDir = getCustomMapsDir(context);
File rootDir = new File(customMapsDir);
if (!rootDir.exists() || !rootDir.isDirectory())
{
return result;
}
File[] versionDirs = rootDir.listFiles(File::isDirectory);
if (versionDirs == null)
{
return result;
}
// Sort by version descending (newest first)
Arrays.sort(versionDirs, (a, b) -> {
long versionA = parseVersion(a.getName());
long versionB = parseVersion(b.getName());
return Long.compare(versionB, versionA);
});
for (File versionDir : versionDirs)
{
long version = parseVersion(versionDir.getName());
if (version <= 0)
{
continue;
}
File[] mwmFiles = versionDir.listFiles((dir, name) ->
name.toLowerCase(Locale.US).endsWith(MWM_EXTENSION));
if (mwmFiles == null)
{
continue;
}
for (File mwmFile : mwmFiles)
{
String name = mwmFile.getName();
// Remove .mwm extension to get country name
String countryName = name.substring(0, name.length() - MWM_EXTENSION.length());
// Only add if we don't already have a newer version
if (!result.containsKey(countryName))
{
result.put(countryName, new CustomMwmFile(
countryName,
mwmFile.getAbsolutePath(),
version,
mwmFile.length()
));
}
}
}
return result;
}
/**
* Gets all version directories in the custom maps folder, sorted by version descending.
* @return List of version directory paths
*/
@NonNull
public static List<String> getCustomMapVersionDirs(@NonNull Context context)
{
List<String> result = new ArrayList<>();
String customMapsDir = getCustomMapsDir(context);
File rootDir = new File(customMapsDir);
if (!rootDir.exists() || !rootDir.isDirectory())
{
return result;
}
File[] versionDirs = rootDir.listFiles(File::isDirectory);
if (versionDirs == null)
{
return result;
}
// Sort by version descending (newest first)
Arrays.sort(versionDirs, (a, b) -> {
long versionA = parseVersion(a.getName());
long versionB = parseVersion(b.getName());
return Long.compare(versionB, versionA);
});
for (File versionDir : versionDirs)
{
if (parseVersion(versionDir.getName()) > 0)
{
result.add(StorageUtils.addTrailingSeparator(versionDir.getAbsolutePath()));
}
}
return result;
}
/**
* Checks if a custom version of a map exists.
* @param countryName The country/map name (without .mwm extension)
* @return The CustomMwmFile if found, null otherwise
*/
@Nullable
public static CustomMwmFile getCustomMwmFile(@NonNull Context context, @NonNull String countryName)
{
Map<String, CustomMwmFile> customFiles = getCustomMwmFiles(context);
return customFiles.get(countryName);
}
/**
* Deletes a custom MWM file.
* @param file The CustomMwmFile to delete
* @return true if deletion was successful
*/
public static boolean deleteCustomMwmFile(@NonNull CustomMwmFile file)
{
File f = new File(file.path);
boolean deleted = f.delete();
if (deleted)
{
Logger.i(TAG, "Deleted custom MWM file: " + file.path);
// Clean up empty version directories
File parentDir = f.getParentFile();
if (parentDir != null)
{
String[] remaining = parentDir.list();
if (remaining != null && remaining.length == 0)
{
if (parentDir.delete())
{
Logger.i(TAG, "Deleted empty version directory: " + parentDir.getPath());
}
}
}
}
else
{
Logger.e(TAG, "Failed to delete custom MWM file: " + file.path);
}
return deleted;
}
/**
* Parses a version string (YYMMDD format) to a long.
* @return The version number, or 0 if parsing failed
*/
private static long parseVersion(String versionStr)
{
if (versionStr == null || versionStr.length() != 6)
{
return 0;
}
try
{
return Long.parseLong(versionStr);
}
catch (NumberFormatException e)
{
return 0;
}
}
}

View File

@@ -1,66 +0,0 @@
# Deploy your maps files server
This doc explain how to deploy your own instance of a CoMaps server with files from official CDNs (We are working to be able to download maps files without hardcoded countries.txt file embedded in the app)
We explain how to deploy with minimal config, but each tools have differents options to change server port or choose maps files that you want to download.
## Deploy the server
Our community has developped different tools to deploy easily an instance of a CoMaps server:
- [comaps-map-distributor](https://codeberg.org/gedankenstuecke/comaps-map-distributor)
- [comaps-server](https://github.com/myanesp/comaps-server)
### Deploy comaps-map-distributor
Prerequisites
- python3 and pip
- Launch your terminal
- Run `pip install comaps-map-distributor`
- Launch the tool with this command `comaps-map-distributor download-maps`
- Choose maps files you want to download from official CDNs
- Run `comaps-map-distributor serve-maps`
- Go to your mobile device -> CoMaps -> settings -> Advanced -> Custom Maps server
- Edit URL with your URL server and enjoy
### Deploy comaps-server
Prerequisites
- Docker
- Your server is accessible from your network
#### Docker
- Launch your terminal
- Run ``` docker run -d \
--name comaps-server \
--restart unless-stopped \
-e MAPS=all \
-e OUTPUT_DIR=/maps \
-p "80:80" \
ghcr.io/myanesp/comaps-server:latest```
- Go to your mobile device -> CoMaps -> settings -> Advanced -> Custom Maps server
- Edit URL with your URL server and enjoy
#### Docker compose
- Launch your terminal
- Create a `compose.yml` file with this config:
```services:
maps-server:
image: ghcr.io/myanesp/comaps-server
container_name: comaps-server
ports:
- "80:80"
environment:
- MAPS=World,WorldCoasts,Spain
- OUTPUT_DIR=/maps
volumes:
- ./maps:/maps
- TZ=Europe/Madrid```
- Execute `docker compose up`
- Go to your mobile device -> CoMaps -> settings -> Advanced -> Custom Maps server
- Edit URL with your URL server and enjoy
You can find more details in the [FAQ articles](https://www.comaps.app/support/how-can-i-host-a-custom-map-server-for-downloads/) to deploy your own HTTP maps server and find more details [here](https://www.comaps.app/support/how-can-i-set-a-custom-map-server-for-downloads/) about restrictions.

View File

@@ -33,6 +33,7 @@ namespace
char constexpr kBitsExt[] = ".bftsegbits";
char constexpr kNodesExt[] = ".bftsegnodes";
char constexpr kOffsetsExt[] = ".offsets";
char constexpr kCustomMapsDir[] = "custom_maps";
string GetAdditionalWorldScope()
{
@@ -205,6 +206,44 @@ void FindAllDiffs(std::string const & dataDir, std::vector<LocalCountryFile> & d
FindAllDiffsInDirectory(base::JoinPath(dir, fwt.first /* subdir */), diffs);
}
void FindAllCustomMaps(string const & dataDir, std::vector<LocalCountryFile> & localFiles)
{
string const customMapsPath = base::JoinPath(GetDataDirFullPath(dataDir), kCustomMapsDir);
if (!Platform::IsFileExistsByFullPath(customMapsPath) || !Platform::IsDirectory(customMapsPath))
return;
Platform::TFilesWithType versionDirs;
Platform::GetFilesByType(customMapsPath, Platform::EFileType::Directory, versionDirs);
for (auto const & versionDir : versionDirs)
{
string const & subdir = versionDir.first;
int64_t version;
// Custom maps use YYMMDD format for version directories
if (!ParseVersion(subdir, version))
continue;
string const versionPath = base::JoinPath(customMapsPath, subdir);
Platform::TFilesWithType files;
Platform::GetFilesByType(versionPath, Platform::EFileType::Regular, files);
for (auto const & file : files)
{
string name = file.first;
if (!name.ends_with(DATA_FILE_EXTENSION))
continue;
// Remove DATA_FILE_EXTENSION and use base name as a country file name.
base::GetNameWithoutExt(name);
localFiles.emplace_back(versionPath, CountryFile(std::move(name)), version);
}
}
LOG(LINFO, ("Found", localFiles.size(), "custom maps in", customMapsPath));
}
void FindAllLocalMapsAndCleanup(int64_t latestVersion, std::vector<LocalCountryFile> & localFiles)
{
FindAllLocalMapsAndCleanup(latestVersion, string(), localFiles);
@@ -223,6 +262,10 @@ void FindAllLocalMapsAndCleanup(int64_t latestVersion, string const & dataDir,
for (auto const & fwt : fwts)
{
string const & subdir = fwt.first;
// Skip the custom_maps directory - it's processed separately
if (subdir == kCustomMapsDir)
continue;
int64_t version;
if (!ParseVersion(subdir, version) || version > latestVersion)
continue;
@@ -236,6 +279,11 @@ void FindAllLocalMapsAndCleanup(int64_t latestVersion, string const & dataDir,
}
}
// Find custom maps in the custom_maps directory.
// Custom maps have higher priority and use future date versions (YYMMDD format).
// The storage will prefer the newest version when multiple versions exist.
FindAllCustomMaps(dataDir, localFiles);
// Check for World and WorldCoasts in app bundle or in resources.
Platform & platform = GetPlatform();
string const world(WORLD_FILE_NAME);

View File

@@ -46,6 +46,10 @@ void FindAllLocalMapsAndCleanup(int64_t latestVersion, std::string const & dataD
void FindAllDiffs(std::string const & dataDir, std::vector<LocalCountryFile> & diffs);
// Finds custom MWM files in the custom_maps/YYMMDD/ directories.
// Custom maps override downloaded maps when they have a newer version date.
void FindAllCustomMaps(std::string const & dataDir, std::vector<LocalCountryFile> & localFiles);
// This method removes:
// * partially downloaded non-latest maps (with version less than |latestVersion|)
// * empty directories

View File

@@ -1666,6 +1666,9 @@ void Storage::GetNodeAttrs(CountryId const & countryId, NodeAttrs & nodeAttrs) c
nodeAttrs.m_localMwmCounter += 1;
nodeAttrs.m_localMwmSize += localFile->GetSize(MapFileType::Map);
// For leaf nodes, store the local file version
if (d.ChildrenCount() == 0)
nodeAttrs.m_localMwmVersion = localFile->GetVersion();
});
nodeAttrs.m_present = m_localFiles.find(countryId) != m_localFiles.end();

View File

@@ -54,6 +54,7 @@ struct NodeAttrs
, m_mwmSize(0)
, m_localMwmSize(0)
, m_downloadingMwmSize(0)
, m_localMwmVersion(0)
, m_status(NodeStatus::Undefined)
, m_error(NodeErrorCode::NoError)
, m_present(false)
@@ -88,6 +89,10 @@ struct NodeAttrs
/// \note The size of leaves is the size is written in countries.txt.
MwmSize m_downloadingMwmSize;
/// Version of the local mwm file (YYMMDD format, e.g. 251231).
/// 0 if not downloaded.
int64_t m_localMwmVersion;
/// The name of the node in a local language. That means the language dependent on
/// a device locale.
std::string m_nodeLocalName;