[android] Rework Save Maps To settings

- update UI
- add storage labels
- add storage total sizes
- get storage labels from the system on Android 7+
- keep using currently configured storage even if its read-only
- improve storage sanity checks
- more logging details

Signed-off-by: Konstantin Pastbin <konstantin.pastbin@gmail.com>
This commit is contained in:
Konstantin Pastbin 2022-05-12 21:44:32 +03:00 committed by Alexander Borsuk
parent e8da7ee19f
commit 6276577a1d
9 changed files with 349 additions and 235 deletions

View file

@ -6,4 +6,5 @@
android:layout_height="wrap_content"
android:minHeight="@dimen/height_item_oneline"
android:checkMark="?android:attr/listChoiceIndicatorSingle"
android:minLines="3"
tools:text="/storage/mnt/"/>

View file

@ -166,7 +166,7 @@ abstract class BaseRoutingErrorDialogFragment extends BaseMwmDialogFragment
}
Map<String, String> group = new HashMap<>();
group.put(GROUP_NAME, getString(R.string.maps) + " (" + mMissingMaps.size() + ") ");
group.put(GROUP_NAME, getString(R.string.downloader_status_maps) + " (" + mMissingMaps.size() + ") ");
group.put(GROUP_SIZE, StringUtils.getFileSizeString(requireContext(), size));
List<Map<String, String>> groups = new ArrayList<>();

View file

@ -9,11 +9,20 @@ public class StorageItem
private final String mPath;
// Free size.
private final long mFreeSize;
// Total size.
private final long mTotalSize;
// User-visible description.
private final String mLabel;
// Is it read-only storage?
private final boolean mReadonly;
public StorageItem(String path, long size)
public StorageItem(String path, long freeSize, long totalSize, final String label, boolean isReadonly)
{
mPath = path;
mFreeSize = size;
mFreeSize = freeSize;
mTotalSize = totalSize;
mLabel = label;
mReadonly = isReadonly;
}
@Override
@ -37,12 +46,6 @@ public class StorageItem
return 0;
}
@Override
public String toString()
{
return mPath + ", " + mFreeSize;
}
public String getFullPath()
{
return mPath;
@ -52,4 +55,19 @@ public class StorageItem
{
return mFreeSize;
}
public long getTotalSize()
{
return mTotalSize;
}
public String getLabel()
{
return mLabel;
}
public boolean isReadonly()
{
return mReadonly;
}
}

View file

@ -1,17 +1,19 @@
package com.mapswithme.maps.settings;
import android.app.Activity;
import android.view.LayoutInflater;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.format.Formatter;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ForegroundColorSpan;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.CheckedTextView;
import androidx.annotation.NonNull;
import com.mapswithme.maps.R;
import java.util.ArrayList;
import java.util.List;
import com.mapswithme.util.ThemeUtils;
import com.mapswithme.util.UiUtils;
class StoragePathAdapter extends BaseAdapter
{
@ -50,10 +52,29 @@ class StoragePathAdapter extends BaseAdapter
convertView = mActivity.getLayoutInflater().inflate(R.layout.item_storage, parent, false);
StorageItem item = mPathManager.getStorageItems().get(position);
final boolean isCurrent = position == mPathManager.getCurrentStorageIndex();
CheckedTextView checkedView = (CheckedTextView) convertView;
checkedView.setText(item.getFullPath() + ": " + StoragePathFragment.getSizeString(item.getFreeSize()));
checkedView.setChecked(position == mPathManager.getCurrentStorageIndex());
checkedView.setEnabled(position == mPathManager.getCurrentStorageIndex() || isStorageBigEnough(position));
checkedView.setChecked(isCurrent);
checkedView.setEnabled(!item.isReadonly() && (isStorageBigEnough(position) || isCurrent));
final String size = mActivity.getString(R.string.maps_storage_free_size,
Formatter.formatShortFileSize(mActivity, item.getFreeSize()),
Formatter.formatShortFileSize(mActivity, item.getTotalSize()));
SpannableStringBuilder sb = new SpannableStringBuilder(item.getLabel() + "\n" + size);
sb.setSpan(new ForegroundColorSpan(ThemeUtils.getColor(mActivity, android.R.attr.textColorSecondary)),
sb.length() - size.length(), sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
sb.setSpan(new AbsoluteSizeSpan(UiUtils.dimen(mActivity, R.dimen.text_size_body_3)),
sb.length() - size.length(), sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
final String path = item.getFullPath() + (item.isReadonly() ? " (read-only)" : "");
sb.append("\n" + path);
sb.setSpan(new ForegroundColorSpan(ThemeUtils.getColor(mActivity, android.R.attr.textColorSecondary)),
sb.length() - path.length(), sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
sb.setSpan(new AbsoluteSizeSpan(UiUtils.dimen(mActivity, R.dimen.text_size_body_4)),
sb.length() - path.length(), sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
checkedView.setText(sb);
return convertView;
}

View file

@ -5,8 +5,10 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckedTextView;
import android.widget.ListView;
import android.widget.TextView;
import android.text.format.Formatter;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
@ -15,7 +17,6 @@ import com.mapswithme.maps.Framework;
import com.mapswithme.maps.R;
import com.mapswithme.maps.base.OnBackPressListener;
import com.mapswithme.maps.dialog.DialogUtils;
import com.mapswithme.util.Constants;
import com.mapswithme.util.StorageUtils;
import com.mapswithme.util.Utils;
import com.mapswithme.util.concurrency.ThreadPool;
@ -23,7 +24,6 @@ import com.mapswithme.util.concurrency.UiThread;
import java.io.File;
import java.util.List;
import java.util.Locale;
public class StoragePathFragment extends BaseSettingsFragment
implements OnBackPressListener
@ -60,7 +60,7 @@ public class StoragePathFragment extends BaseSettingsFragment
{
super.onResume();
mPathManager.startExternalStorageWatching((items, idx) -> updateList());
mPathManager.updateExternalStorages();
mPathManager.scanAvailableStorages();
updateList();
}
@ -88,7 +88,7 @@ public class StoragePathFragment extends BaseSettingsFragment
private void updateList()
{
long dirSize = getWritableDirSize();
mHeader.setText(getString(R.string.maps) + ": " + getSizeString(dirSize));
mHeader.setText(getString(R.string.maps_storage_downloaded) + ": " + Formatter.formatShortFileSize(getActivity(), dirSize));
mAdapter.update(dirSize);
}
@ -100,10 +100,11 @@ public class StoragePathFragment extends BaseSettingsFragment
public void changeStorage(int newIndex)
{
final int currentIndex = mPathManager.getCurrentStorageIndex();
if (newIndex == currentIndex || currentIndex == -1 || !mAdapter.isStorageBigEnough(newIndex))
final List<StorageItem> items = mPathManager.getStorageItems();
if (newIndex == currentIndex || currentIndex == -1 || items.get(newIndex).isReadonly()
|| !mAdapter.isStorageBigEnough(newIndex))
return;
final List<StorageItem> items = mPathManager.getStorageItems();
final String oldPath = items.get(currentIndex).getFullPath();
final String newPath = items.get(newIndex).getFullPath();
@ -135,38 +136,18 @@ public class StoragePathFragment extends BaseSettingsFragment
if (!result)
{
final String message = "Error moving maps files";
new AlertDialog.Builder(requireActivity())
.setTitle(message)
.setTitle(R.string.move_maps_error)
.setPositiveButton(R.string.report_a_bug,
(dlg, which) -> Utils.sendBugReport(requireActivity(), message))
(dlg, which) -> Utils.sendBugReport(requireActivity(), "Error moving map files"))
.show();
}
mPathManager.updateExternalStorages();
mPathManager.scanAvailableStorages();
updateList();
});
});
}
static String getSizeString(long size)
{
final String[] units = { "Kb", "Mb", "Gb" };
long current = Constants.KB;
int i = 0;
for (; i < units.length; ++i)
{
final long bound = Constants.KB * current;
if (size < bound)
break;
current = bound;
}
// left 1 digit after the comma and add postfix string
return String.format(Locale.US, "%.1f %s", (double) size / current, units[i]);
}
@Override
public boolean onBackPressed()
{

View file

@ -7,11 +7,14 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.mapswithme.maps.Framework;
import com.mapswithme.maps.R;
import com.mapswithme.maps.downloader.MapManager;
import com.mapswithme.util.Config;
import com.mapswithme.util.StorageUtils;
@ -20,13 +23,10 @@ import com.mapswithme.util.log.Logger;
import com.mapswithme.util.log.LoggerFactory;
import java.io.File;
import java.io.FileFilter;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class StoragePathManager
{
@ -78,7 +78,7 @@ public class StoragePathManager
@Override
public void onReceive(Context context, Intent intent)
{
updateExternalStorages();
scanAvailableStorages();
if (mStoragesChangedListener != null)
mStoragesChangedListener.onStorageListChanged(mItems, mCurrentStorageIndex);
@ -126,118 +126,165 @@ public class StoragePathManager
}
/**
* Updates the list of available external storages.
*
* The scan order is following:
*
* 1. Current directory from Config.
* 2. Application-specific directories on shared/external storage devices.
* 3. Application-specific directory on the internal memory.
*
* Directories are checked for the free space and the write access.
* Adds a storage into the list if it passes sanity checks.
*/
public void updateExternalStorages() throws AssertionError
private void addStorageOption(File dir, boolean isInternal, String configPath)
{
List<File> candidates = new ArrayList<>();
// Current directory.
// Config.getStoragePath() can be empty on the first run.
String configDir = Config.getStoragePath();
if (!TextUtils.isEmpty(configDir))
candidates.add(new File(configDir));
// External storages (SD cards and other).
for (File dir : mContext.getExternalFilesDirs(null))
// Internal storage must always exists, but Android is unpredictable.
// External storages can be null in some cases.
// https://github.com/organicmaps/organicmaps/issues/632
if (dir == null)
{
// There was an evidence that `dir` can be null on some Samsungs.
// https://github.com/organicmaps/organicmaps/issues/632
if (dir == null)
continue;
//
// If the contents of emulated storage devices are backed by a private user data partition,
// then there is little benefit to apps storing data here instead of the private directories
// returned by Context#getFilesDir(), etc.
//
boolean isStorageEmulated;
try
{
isStorageEmulated = Environment.isExternalStorageEmulated(dir);
}
catch (IllegalArgumentException e)
{
// isExternalStorageEmulated may throw IllegalArgumentException
// https://github.com/organicmaps/organicmaps/issues/538
isStorageEmulated = false;
}
if (!isStorageEmulated)
candidates.add(dir);
LOGGER.w(TAG, "The system returned 'null' " + (isInternal ? "internal" : "external") + " storage");
return;
}
// Internal storage must always exists, but Android is unpredictable.
// https://github.com/organicmaps/organicmaps/issues/632
File internalDir = mContext.getFilesDir();
if (internalDir != null)
candidates.add(internalDir);
String commentedPath = null;
try
{
// Add the trailing separator because the native code assumes that all paths have it.
final String path = StorageUtils.addTrailingSeparator(dir.getCanonicalPath());
final boolean isCurrent = path.equals(configPath);
final long totalSize = dir.getTotalSpace();
final long freeSize = dir.getUsableSpace();
if (candidates.isEmpty())
throw new AssertionError("Can't find available storage");
commentedPath = path + (StorageUtils.addTrailingSeparator(dir.getPath()).equals(path)
? "" : " (" + dir.getPath() + ")") + " - " +
(isCurrent ? "currently configured, " : "") +
(isInternal ? "internal" : "external") + ", " +
freeSize + " available out of " + totalSize + " bytes";
boolean isEmulated = false;
boolean isRemovable = false;
boolean isReadonly = false;
String state = null;
String label = null;
if (!isInternal)
{
try
{
isEmulated = Environment.isExternalStorageEmulated(dir);
isRemovable = Environment.isExternalStorageRemovable(dir);
state = Environment.getExternalStorageState(dir);
commentedPath += (isEmulated ? ", emulated" : "") +
(isRemovable ? ", removable" : "") +
(state != null ? ", state=" + state : "");
}
catch (IllegalArgumentException e)
{
// Thrown if the dir is not a valid storage device.
// https://github.com/organicmaps/organicmaps/issues/538
LOGGER.w(TAG, "isExternalStorage checks failed for " + commentedPath);
}
// Get additional storage information for Android 7+.
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N)
{
final StorageManager sm = (StorageManager) mContext.getSystemService(mContext.STORAGE_SERVICE);
if (sm != null)
{
final StorageVolume sv = sm.getStorageVolume(dir);
if (sv != null)
{
label = sv.getDescription(mContext);
commentedPath += (sv.isPrimary() ? ", primary" : "") +
(!TextUtils.isEmpty(sv.getUuid()) ? ", uuid=" + sv.getUuid() : "") +
(!TextUtils.isEmpty(label) ? ", label='" + label + "'": "");
}
else
LOGGER.w(TAG, "Can't get StorageVolume for " + commentedPath);
}
else
LOGGER.w(TAG, "Can't get StorageManager for " + commentedPath);
}
}
if (state != null && !Environment.MEDIA_MOUNTED.equals(state)
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state))
{
LOGGER.w(TAG, "Not mounted: " + commentedPath);
return;
}
if (!dir.exists())
{
LOGGER.w(TAG, "Not exists: " + commentedPath);
return;
}
if (!dir.isDirectory())
{
LOGGER.w(TAG, "Not a directory: " + commentedPath);
return;
}
if (!dir.canWrite() || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state))
{
isReadonly = true;
LOGGER.w(TAG, "Not writable: " + commentedPath);
// Keep using currently configured storage even if its read-only.
if (isCurrent)
commentedPath += ", read-only";
else
return;
}
if (isEmulated)
{
// TODO: External emulated storage can be backed either by internal or adopted external storage.
// https://github.com/organicmaps/organicmaps/issues/2451
LOGGER.i(TAG, "Emulated storage: " + commentedPath);
return;
}
if (TextUtils.isEmpty(label))
label = isInternal ? mContext.getString(R.string.maps_storage_internal)
: (isRemovable ? mContext.getString(R.string.maps_storage_removable)
: (isEmulated ? mContext.getString(R.string.maps_storage_shared)
: mContext.getString(R.string.maps_storage_external)));
StorageItem item = new StorageItem(path, freeSize, totalSize, label, isReadonly);
mItems.add(item);
if (isCurrent)
mCurrentStorageIndex = mItems.size() - 1;
LOGGER.i(TAG, "Accepted " + commentedPath);
}
catch (SecurityException | IOException ex)
{
LOGGER.e(TAG, "Error: " + (commentedPath != null ? commentedPath : "(" + dir.getPath() + ")"), ex);
}
}
/**
* Updates the list of available storages.
* The scan order is following:
* 1. App-specific directories on shared/external storage devices.
* 2. App-specific directory in the internal memory.
*/
public void scanAvailableStorages() throws AssertionError
{
// Current configured storage directory, can be empty on the first run.
final String configPath = Config.getStoragePath();
LOGGER.i(TAG, "Currently configured storage: " + (TextUtils.isEmpty(configPath) ? "N/A" : configPath));
//
// Update internal state.
//
LOGGER.i(TAG, "Begin scanning storages");
mItems.clear();
mCurrentStorageIndex = -1;
Set<String> unique = new HashSet<>();
for (File dir : candidates)
// External storages (SD cards and other).
for (File externalDir : mContext.getExternalFilesDirs(null))
{
try
{
String path = dir.getCanonicalPath();
// Add the trailing separator because the native code assumes that all paths have it.
if (!path.endsWith(File.separator))
path = path + File.separator;
if (!dir.exists() || !dir.isDirectory())
{
LOGGER.i(TAG, "Rejected " + path + ": not a directory");
continue;
}
if (!dir.canWrite())
{
LOGGER.i(TAG, "Rejected " + path + ": not writable");
continue;
}
if (!unique.add(path))
{
LOGGER.i(TAG, "Rejected " + path + ": a duplicate");
continue;
}
final long freeSize = StorageUtils.getFreeBytesAtPath(path);
StorageItem item = new StorageItem(path, freeSize);
if (!TextUtils.isEmpty(configDir) && configDir.equals(path))
{
mCurrentStorageIndex = mItems.size();
}
LOGGER.i(TAG, "Accepted " + path + ": " + freeSize + " bytes available");
mItems.add(item);
}
catch (IllegalArgumentException | IOException ex)
{
LOGGER.e(TAG, "Rejected " + dir.getPath() + ": error", ex);
continue;
}
addStorageOption(externalDir, false, configPath);
}
File internalDir = mContext.getFilesDir();
addStorageOption(internalDir, true, configPath);
LOGGER.i(TAG, "End scanning storages");
if (!TextUtils.isEmpty(configDir) && mCurrentStorageIndex == -1)
if (mItems.isEmpty())
// Shut down the app.
throw new AssertionError("Can't find available storages");
if (!TextUtils.isEmpty(configPath) && mCurrentStorageIndex == -1)
{
LOGGER.w(TAG, configDir + ": can't find configured directory in the list above!");
for (StorageItem item : mItems)
LOGGER.w(TAG, item.toString());
LOGGER.w(TAG, "Currently configured storage is not available!");
}
}
@ -254,62 +301,79 @@ public class StoragePathManager
private static boolean containsMapData(String storagePath)
{
File path = new File(storagePath);
File[] candidates = path.listFiles(new FileFilter()
{
@Override
public boolean accept(File pathname)
File[] candidates = path.listFiles((pathname) ->
{
if (!pathname.isDirectory())
return false;
if (!pathname.isDirectory())
return false;
try
{
String name = pathname.getName();
if (name.length() != 6)
return false;
try
{
String name = pathname.getName();
if (name.length() != 6)
return false;
int version = Integer.valueOf(name);
return (version > 120000 && version <= 999999);
} catch (NumberFormatException ignored) {}
int version = Integer.valueOf(name);
return (version > 120000 && version <= 999999);
}
catch (NumberFormatException ignored)
{
}
return false;
}
});
return false;
});
return (candidates != null && candidates.length > 0 &&
candidates[0].list().length > 0);
}
/**
* Finds a first available storage with existing maps files.
* Returns the best available option if no maps files found.
* See updateExternalStorages() for the scan order details.
* Returns an available storage with existing maps files.
* Checks the currently configured storage first,
* then scans other storages. Defaults to the first available option if no maps files found
* (see scanAvailableStorages() for the scan order details).
*/
public static String findMapsStorage(@NonNull Application application)
{
StoragePathManager instance = new StoragePathManager(application);
instance.updateExternalStorages();
StoragePathManager mgr = new StoragePathManager(application);
mgr.scanAvailableStorages();
String path = null;
final int currentIdx = mgr.getCurrentStorageIndex();
List<StorageItem> items = instance.getStorageItems();
for (StorageItem item : items)
if (currentIdx != -1)
{
String path = item.getFullPath();
path = mgr.getStorageItems().get(currentIdx).getFullPath();
if (containsMapData(path))
{
LOGGER.i(TAG, "Found maps files at " + path);
LOGGER.i(TAG, "Found map files at the currently configured " + path);
return path;
}
else
{
LOGGER.i(TAG, "No maps files found at " + path);
LOGGER.w(TAG, "No map files found at the currenly configured " + path);
}
}
// Use the first item by default.
final String defaultDir = items.get(0).getFullPath();
LOGGER.i(TAG, "Using default directory: " + defaultDir);
return defaultDir;
LOGGER.i(TAG, "Looking for map files in available storages...");
for (int idx = 0; idx < mgr.getStorageItems().size(); ++idx)
{
if (idx == currentIdx)
continue;
path = mgr.getStorageItems().get(idx).getFullPath();
if (containsMapData(path))
{
LOGGER.i(TAG, "Found map files at " + path);
return path;
}
else
{
LOGGER.i(TAG, "No map files found at " + path);
}
}
// Use the first storage by default.
path = mgr.getStorageItems().get(0).getFullPath();
LOGGER.i(TAG, "Using default storage: " + path);
return path;
}
/**

View file

@ -123,9 +123,9 @@ public class StorageUtils
}
@NonNull
private static String addTrailingSeparator(@NonNull String dir)
public static String addTrailingSeparator(@NonNull String dir)
{
if (!dir.endsWith("/"))
if (!dir.endsWith(File.separator))
return dir + File.separator;
return dir;
}
@ -250,20 +250,6 @@ public class StorageUtils
}
}
public static long getFreeBytesAtPath(String path)
{
long size = 0;
try
{
size = new File(path).getFreeSpace();
} catch (RuntimeException e)
{
e.printStackTrace();
}
return size;
}
public static long getDirSizeRecursively(File file, FilenameFilter fileFilter)
{
if (file.isDirectory())

View file

@ -342,44 +342,6 @@
zh-Hans = 发表评论
zh-Hant = 撰寫評論
[maps]
comment = View and button titles for accessibility
tags = android,ios
en = Maps
ar = الخرائط
be = Мапы
bg = Карти
cs = Mapy
da = Kort
de = Karten
el = Χάρτες
es = Mapas
eu = Mapak
fa = نقشه ها
fi = Kartat
fr = Cartes
he = מפות
hu = Térképek
id = Peta
it = Mappe
ja = マップ
ko = 지도
nb = Kart
nl = Kaarten
pl = Mapy
pt = Mapas
pt-BR = Mapas
ro = Hărţi
ru = Карты
sk = Mapy
sv = Kartor
th = แผนที่
tr = Haritalar
uk = Мапи
vi = Bản đồ
zh-Hans = 地图
zh-Hant = 地圖
[mb]
comment = Settings/Downloader - size string, only strings different from English should be translated
tags = android
@ -1835,6 +1797,80 @@
zh-Hans = 选取地图应该下载的位置
zh-Hant = 請選擇下載地圖後要放的位置
[maps_storage_downloaded]
comment = E.g. "Downloaded maps: 500Mb" in Maps Storage settings
tags = android
en = Downloaded maps
ar = الخرائط
be = Мапы
bg = Карти
cs = Mapy
da = Kort
de = Karten
el = Χάρτες
es = Mapas
eu = Mapak
fa = نقشه ها
fi = Kartat
fr = Cartes
he = מפות
hu = Térképek
id = Peta
it = Mappe
ja = マップ
ko = 지도
nb = Kart
nl = Kaarten
pl = Mapy
pt = Mapas
pt-BR = Mapas
ro = Hărţi
ru = Загруженные карты
sk = Mapy
sv = Kartor
th = แผนที่
tr = Haritalar
uk = Завантаженнi мапи
vi = Bản đồ
zh-Hans = 地图
zh-Hant = 地圖
[maps_storage_internal]
comment = Internal storage type in Maps Storage settings (not accessible by the user)
tags = android
en = Internal hidden storage
ru = Внутренний скрытый накопитель
uk = Внутрішнє зховане сховище
[maps_storage_shared]
comment = Shared storage type in Maps Storage settings (a primary storage usually)
tags = android
en = Internal shared storage
be = Унутранае абагуленае сховішча
ru = Внутренний общий накопитель
uk = Внутрішнє спільне сховище
[maps_storage_removable]
comment = Removable external storage type in Maps Storage settings, e.g. an SD card
tags = android
en = SD card
be = SD-карта
ru = SD-карта
uk = SD-карта
[maps_storage_external]
comment = Generic external storage type in Maps Storage settings
tags = android
en = External shared storage
ru = Внешний общий накопитель
uk = Зовнiшне спільне сховище
[maps_storage_free_size]
comment = Free space out of total storage size in Maps Storage settings, e.g. "300 MB free (of 2 GB)"
tags = android
en = %1$@ free (of %2$@)
ru = %1$@ свободно (из %2$@)
[move_maps]
comment = Question dialog for transferring maps from one storage to another
tags = android
@ -1873,6 +1909,12 @@
zh-Hans = 移动地图?
zh-Hant = 移動地圖?
[move_maps_error]
comment = Error moving map files from one storage to another
tags = android
en = Error moving map files
ru = Ошибка перемещения файлов карт
[wait_several_minutes]
comment = Ask to wait user several minutes (some long process in modal dialog).
tags = android
@ -5228,7 +5270,7 @@
zh-Hant = 全部取消
[downloader_downloaded_subtitle]
comment = Downloaded maps category
comment = Downloaded maps list header
tags = android,ios
en = Downloaded
ar = تم تنزيلها
@ -5375,6 +5417,7 @@
zh-Hant = 在我附近
[downloader_status_maps]
comment = In maps downloader and country place page shows how many maps are downloaded / to download, e.g. "Maps: 3 of 10"
tags = android,ios
en = Maps
ar = الخرائط

View file

@ -334,7 +334,7 @@ CGFloat const kAnimationDuration = .05;
if (!_listHeader)
_listHeader = [MWMDownloaderDialogHeader headerForOwnerAlert:self];
[_listHeader setTitle:[NSString stringWithFormat:@"%@ (%@)", L(@"maps"), @(m_countries.size())]
[_listHeader setTitle:[NSString stringWithFormat:@"%@ (%@)", L(@"downloader_status_maps"), @(m_countries.size())]
size:self.countriesSize];
return _listHeader;
}