diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51343a1ff..5e78fdc33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -346,6 +346,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 diff --git a/android/app/src/main/java/app/organicmaps/intent/Factory.java b/android/app/src/main/java/app/organicmaps/intent/Factory.java index da6c76b20..02847cbbf 100644 --- a/android/app/src/main/java/app/organicmaps/intent/Factory.java +++ b/android/app/src/main/java/app/organicmaps/intent/Factory.java @@ -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; diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b0185acc9..d811ac672 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -975,4 +975,8 @@ Override the default map download server used for map downloads. Leave empty to use CoMaps default server. Not set Please enter a URL starting with http:// or https:// + + Custom map imported successfully. Restart the app to load it. + Invalid MWM file. Please select a valid map file. + Failed to import the map file. Please try again. diff --git a/android/sdk/src/main/cpp/app/organicmaps/sdk/MapManager.cpp b/android/sdk/src/main/cpp/app/organicmaps/sdk/MapManager.cpp index 6d18e55ef..1efe997e7 100644 --- a/android/sdk/src/main/cpp/app/organicmaps/sdk/MapManager.cpp +++ b/android/sdk/src/main/cpp/app/organicmaps/sdk/MapManager.cpp @@ -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); diff --git a/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CountryItem.java b/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CountryItem.java index f2ba56517..c8cca3176 100644 --- a/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CountryItem.java +++ b/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CountryItem.java @@ -53,6 +53,9 @@ public final class CountryItem implements Comparable 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 + "\", 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) + "% }"; } } diff --git a/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CustomMwmManager.java b/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CustomMwmManager.java new file mode 100644 index 000000000..641c42d22 --- /dev/null +++ b/android/sdk/src/main/java/app/organicmaps/sdk/downloader/CustomMwmManager.java @@ -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 getCustomMwmFiles(@NonNull Context context) + { + Map 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 getCustomMapVersionDirs(@NonNull Context context) + { + List 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 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; + } + } + +} diff --git a/libs/platform/local_country_file_utils.cpp b/libs/platform/local_country_file_utils.cpp index 8fd5a8844..5febfbfb0 100644 --- a/libs/platform/local_country_file_utils.cpp +++ b/libs/platform/local_country_file_utils.cpp @@ -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 & d FindAllDiffsInDirectory(base::JoinPath(dir, fwt.first /* subdir */), diffs); } +void FindAllCustomMaps(string const & dataDir, std::vector & 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 & 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); diff --git a/libs/platform/local_country_file_utils.hpp b/libs/platform/local_country_file_utils.hpp index 0cf595e6b..94d55f23e 100644 --- a/libs/platform/local_country_file_utils.hpp +++ b/libs/platform/local_country_file_utils.hpp @@ -46,6 +46,10 @@ void FindAllLocalMapsAndCleanup(int64_t latestVersion, std::string const & dataD void FindAllDiffs(std::string const & dataDir, std::vector & 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 & localFiles); + // This method removes: // * partially downloaded non-latest maps (with version less than |latestVersion|) // * empty directories diff --git a/libs/storage/storage.cpp b/libs/storage/storage.cpp index 3a42039c9..7abc14b87 100644 --- a/libs/storage/storage.cpp +++ b/libs/storage/storage.cpp @@ -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(); diff --git a/libs/storage/storage.hpp b/libs/storage/storage.hpp index 98117be89..5f0034ceb 100644 --- a/libs/storage/storage.hpp +++ b/libs/storage/storage.hpp @@ -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;