diff --git a/app/build.gradle b/app/build.gradle index 138546b4..df2f007e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -102,6 +102,8 @@ android { buildConfigField 'String', 'WS_API_USER_ID', "\"$wsApiUserId\"" buildConfigField 'String', 'WS_API_KEY', "\"$wsApiKey\"" + multiDexEnabled true + } flavorDimensions "mode" productFlavors { @@ -164,23 +166,26 @@ ext { androidxVersion = '1.1.0' androidxCardViewVersion = '1.0.0' androidxConstraintLayoutVersion = '1.1.3' - androidxTestVersion = '1.0.0' - assertjVersion = '3.13.2' - autoServiceVersion = '1.0-rc4' + androidxTestVersion = '1.2.0' + assertjVersion = '3.14.0' + autoServiceVersion = '1.0-rc6' bubbleSeekbarVersion = '3.20' butterknifeVersion = '10.2.0' - daggerVersion = '2.24' - glideVersion = '4.9.0' + daggerVersion = '2.25.2' + glideVersion = '4.10.0' gsonVersion = '2.8.6' jsonVersion = '20190722' junitVersion = '4.12' + multiDexVersion = '2.0.1' mockitoVersion = '2.28.2' - okHttpVersion = '3.12.0' + okHttpVersion = '4.2.2' osmbonuspackVersion = '6.6.0' - osmdroidVersion = '6.1.0' + osmdroidMapforgeVersion = '6.1.2' + osmdroidVersion = '6.1.2' playServicesVersion = '17.0.0' - retrofitVersion = '2.6.1' // Dictates version of okhttp in their dependencies which clashes in tests. - robolectricVersion = '4.3' + retrofitVersion = '2.6.2' + // Dictates version of okhttp in their dependencies which clashes in tests. + robolectricVersion = '4.3.1' rxAndroidVersion = '2.1.1' rxJavaVersion = '2.2.12' securekeysVersion = '2.2.0' // Adjust the version in the project's build.gradle, too. @@ -196,6 +201,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxVersion" implementation "androidx.cardview:cardview:$androidxCardViewVersion" implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion" + implementation "androidx.multidex:multidex:$multiDexVersion" implementation "androidx.preference:preference:$androidxVersion" implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.MKergall:osmbonuspack:$osmbonuspackVersion" @@ -213,6 +219,7 @@ dependencies { implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" implementation "org.osmdroid:osmdroid-android:$osmdroidVersion" + implementation "org.osmdroid:osmdroid-mapsforge:$osmdroidMapforgeVersion" googleImplementation "com.google.android.gms:play-services-analytics:$playServicesVersion" diff --git a/app/src/google/res/xml/preferences.xml b/app/src/google/res/xml/preferences.xml index f3eabd3e..e5482a74 100644 --- a/app/src/google/res/xml/preferences.xml +++ b/app/src/google/res/xml/preferences.xml @@ -1,37 +1,74 @@ + - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bcf96846..cb8002d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ - + diff --git a/app/src/main/java/fi/bitrite/android/ws/BaseWSAndroidApplication.java b/app/src/main/java/fi/bitrite/android/ws/BaseWSAndroidApplication.java index a5f0096b..61d5589e 100644 --- a/app/src/main/java/fi/bitrite/android/ws/BaseWSAndroidApplication.java +++ b/app/src/main/java/fi/bitrite/android/ws/BaseWSAndroidApplication.java @@ -1,6 +1,5 @@ package fi.bitrite.android.ws; -import android.app.Application; import android.content.SharedPreferences; import com.bumptech.glide.request.RequestOptions; @@ -8,6 +7,7 @@ import javax.inject.Inject; +import androidx.multidex.MultiDexApplication; import dagger.android.AndroidInjector; import dagger.android.DispatchingAndroidInjector; import dagger.android.HasAndroidInjector; @@ -16,7 +16,7 @@ import fi.bitrite.android.ws.repository.SettingsRepository; import fi.bitrite.android.ws.util.WSGlide; -public abstract class BaseWSAndroidApplication extends Application implements HasAndroidInjector { +public abstract class BaseWSAndroidApplication extends MultiDexApplication implements HasAndroidInjector { public static final String TAG = "WSAndroidApplication"; private static AppInjector mAppInjector; diff --git a/app/src/main/java/fi/bitrite/android/ws/auth/Authenticator.java b/app/src/main/java/fi/bitrite/android/ws/auth/Authenticator.java index 2bb6a4e5..1effb618 100644 --- a/app/src/main/java/fi/bitrite/android/ws/auth/Authenticator.java +++ b/app/src/main/java/fi/bitrite/android/ws/auth/Authenticator.java @@ -218,7 +218,7 @@ public enum ErrorCause { WrongAPIKey, AccountTemporarilyBlocked, IpTemporarilyBlocked, - Unknown; + Unknown } public final ErrorCause errorCause; diff --git a/app/src/main/java/fi/bitrite/android/ws/model/MapsForgeTheme.java b/app/src/main/java/fi/bitrite/android/ws/model/MapsForgeTheme.java new file mode 100644 index 00000000..2a399eb8 --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/model/MapsForgeTheme.java @@ -0,0 +1,46 @@ +package fi.bitrite.android.ws.model; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +public class MapsForgeTheme implements Serializable { + private String name; + private String id; + private Map localizedNames; + private String filePath; + + public MapsForgeTheme(String name, String id, String filePath) { + this.name = name; + this.id = id; + this.filePath = filePath; + localizedNames = new HashMap<>(); + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getFilePath() { + return filePath; + } + + public String getLocalizedDisplayName(String lang) { + if (!localizedNames.containsKey(lang)) { + lang = "en"; + } + if (localizedNames.get(lang) != null) { + return localizedNames.get(lang) + " (" + name + ")"; + } else { + return name; + } + } + + public void addLocalizedName(String lang, String name) { + localizedNames.put(lang, name); + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/repository/BaseSettingsRepository.java b/app/src/main/java/fi/bitrite/android/ws/repository/BaseSettingsRepository.java index 2b4ee34a..4de4488c 100644 --- a/app/src/main/java/fi/bitrite/android/ws/repository/BaseSettingsRepository.java +++ b/app/src/main/java/fi/bitrite/android/ws/repository/BaseSettingsRepository.java @@ -3,13 +3,23 @@ import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; -import android.preference.PreferenceManager; import android.text.TextUtils; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + import androidx.annotation.VisibleForTesting; +import androidx.preference.PreferenceManager; import fi.bitrite.android.ws.R; +import fi.bitrite.android.ws.model.MapsForgeTheme; import fi.bitrite.android.ws.model.ZoomedLocation; public abstract class BaseSettingsRepository { @@ -30,13 +40,21 @@ public enum DistanceUnit { private final static String KEYPREFIX_USERFILTER_VALUE = "userfilter-value-"; private final String mKeyDistanceUnit; - private final String mKeyTileSource; + + private final String mOnlineMapSource; + + private final String mOfflineMapEnabled; + private final String mOfflineMapSelection; + private final String mOfflineMapSourceFiles; + private final String mOfflineThemeSourceFiles; + private final String mOfflineThemeSelection; + private final String mKeyMessageRefreshInterval; private final String mKeyDataSaverMode; private final String mKeyDevSimulateNoNetwork; private final String mDefaultDistanceUnit; - private final String mDefaultTileSource = TileSourceFactory.OpenTopo.name(); + private final String mDefaultOnlineMapSource = TileSourceFactory.OpenTopo.name(); private final int mDefaultMessageRefreshInterval; private final boolean mDefaultDataSaverMode; private final boolean mDefaultDevSimulateNoNetwork; @@ -52,12 +70,22 @@ public enum DistanceUnit { private final String mDistanceUnitKilometerLong; private final String mDistanceUnitMilesLong; + private final Gson gson; + BaseSettingsRepository(Context context) { mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); final Resources res = context.getResources(); mKeyDistanceUnit = res.getString(R.string.prefs_distance_unit_key); - mKeyTileSource = res.getString(R.string.prefs_tile_source_key); + + mOnlineMapSource = res.getString(R.string.prefs_online_map_source); + + mOfflineMapEnabled= res.getString(R.string.prefs_offline_map_enabled); + mOfflineMapSelection = res.getString(R.string.prefs_offline_map_selection); + mOfflineMapSourceFiles = res.getString(R.string.prefs_offline_map_source_files); + mOfflineThemeSelection = res.getString(R.string.prefs_offline_theme_selection); + mOfflineThemeSourceFiles = res.getString(R.string.prefs_offline_theme_source_files); + mKeyMessageRefreshInterval = res.getString(R.string.prefs_message_refresh_interval_min_key); mKeyDataSaverMode = res.getString(R.string.prefs_data_saver_mode_key); mKeyDevSimulateNoNetwork = res.getString(R.string.prefs_dev_simulate_no_network_key); @@ -80,44 +108,41 @@ public enum DistanceUnit { mDistanceUnitMilesShort = res.getString(R.string.distance_unit_miles_short); mDistanceUnitKilometerLong = res.getString(R.string.distance_unit_kilometers_long); mDistanceUnitMilesLong = res.getString(R.string.distance_unit_miles_long); + + gson = new Gson(); } public DistanceUnit getDistanceUnit() { String distanceUnitStr = mSharedPreferences.getString(mKeyDistanceUnit, mDefaultDistanceUnit); - if (TextUtils.equals(distanceUnitStr, mDistanceUnitKilometerRaw)) { - return DistanceUnit.KILOMETER; - } else if (TextUtils.equals(distanceUnitStr, mDistanceUnitMilesRaw)) { + if (TextUtils.equals(distanceUnitStr, mDistanceUnitMilesRaw)) { return DistanceUnit.MILES; - } else { - assert false; - return null; } + return DistanceUnit.KILOMETER; } + public String getDistanceUnitLong() { DistanceUnit distanceUnit = getDistanceUnit(); switch (distanceUnit) { - case KILOMETER: return mDistanceUnitKilometerLong; case MILES: return mDistanceUnitMilesLong; + case KILOMETER: default: - assert false; - return null; + return mDistanceUnitKilometerLong; } } public String getDistanceUnitShort() { DistanceUnit distanceUnit = getDistanceUnit(); switch (distanceUnit) { - case KILOMETER: return mDistanceUnitKilometerShort; case MILES: return mDistanceUnitMilesShort; + case KILOMETER: default: - assert false; - return null; + return mDistanceUnitKilometerShort; } } - public String getTileSourceStr() { - return mSharedPreferences.getString(mKeyTileSource, mDefaultTileSource); + public String getOnlineMapSourceStr() { + return mSharedPreferences.getString(mOnlineMapSource, mDefaultOnlineMapSource); } private void setLocation(String key, ZoomedLocation position) { @@ -130,6 +155,59 @@ private void setLocation(String key, ZoomedLocation position) { } + public boolean isOfflineMapEnabled() { + return mSharedPreferences.getBoolean(mOfflineMapEnabled, false); + } + + public void setAvailableOfflineMapSources(Set sources) { + mSharedPreferences.edit().putString(mOfflineMapSourceFiles, gson.toJson(sources)).apply(); + } + + public Set getAvailableOfflineMapSources() { + return new HashSet<>( + gson.fromJson(mSharedPreferences.getString(mOfflineMapSourceFiles, ""), + new TypeToken>() {}.getType())); + } + + public Set getAvailableOfflineMapSelection() { + return mSharedPreferences.getStringSet(mOfflineMapSelection, new HashSet<>()); + } + + public File[] getOfflineMapSourceFiles() { + Set sources = getAvailableOfflineMapSources(); + Set sourceFiles = new HashSet<>(); + for (String source : sources) { + File f = new File(source); + if (f.exists()) { + sourceFiles.add(new File(source)); + } + } + return sourceFiles.toArray(new File[0]); + } + + public void setAvailableOfflineThemeSources(List sources) { + mSharedPreferences.edit().putString(mOfflineThemeSourceFiles, gson.toJson(sources)).apply(); + } + + public List getAvailableOfflineThemeSources() { + List themes = gson.fromJson(mSharedPreferences.getString(mOfflineThemeSourceFiles, ""), + new TypeToken>(){}.getType()); + if (themes == null) { + themes = new ArrayList<>(); + } + themes.add(new MapsForgeTheme("Default theme", "default_theme", "")); + return themes; + } + + public void setSelectedOfflineMapTheme(MapsForgeTheme theme) { + mSharedPreferences.edit().putString(mOfflineThemeSelection, gson.toJson(theme)).apply(); + } + + public MapsForgeTheme getSelectedOfflineMapTheme() { + return gson.fromJson(mSharedPreferences.getString(mOfflineThemeSelection, ""), + new TypeToken(){}.getType()); + } + public ZoomedLocation getLastMapLocation(boolean defaultIfNone) { if (!mSharedPreferences.contains(KEY_MAP_LAST_LOCATION + KEYSUFFIX_LOCATION_LATITUDE) && !defaultIfNone) { @@ -222,4 +300,5 @@ public String getDataSaverModeKey() { public String getDevSimulateNoNetworkKey() { return mKeyDevSimulateNoNetwork; } + } diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java index ccac7b05..35b771ec 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java @@ -24,6 +24,8 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.mapsforge.map.rendertheme.ExternalRenderTheme; +import org.mapsforge.map.rendertheme.XmlRenderTheme; import org.osmdroid.api.IGeoPoint; import org.osmdroid.api.IMapController; import org.osmdroid.bonuspack.clustering.StaticCluster; @@ -31,9 +33,13 @@ import org.osmdroid.events.MapListener; import org.osmdroid.events.ScrollEvent; import org.osmdroid.events.ZoomEvent; +import org.osmdroid.mapsforge.MapsForgeTileProvider; +import org.osmdroid.mapsforge.MapsForgeTileSource; import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import org.osmdroid.tileprovider.util.SimpleRegisterReceiver; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.CustomZoomButtonsController; import org.osmdroid.views.MapView; import org.osmdroid.views.overlay.Marker; import org.osmdroid.views.overlay.ScaleBarOverlay; @@ -41,6 +47,8 @@ import org.osmdroid.views.overlay.mylocation.IMyLocationProvider; import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay; +import java.io.File; +import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -63,6 +71,7 @@ import butterknife.Unbinder; import fi.bitrite.android.ws.R; import fi.bitrite.android.ws.api.response.UserSearchByLocationResponse; +import fi.bitrite.android.ws.model.MapsForgeTheme; import fi.bitrite.android.ws.model.SimpleUser; import fi.bitrite.android.ws.model.User; import fi.bitrite.android.ws.model.ZoomedLocation; @@ -70,6 +79,7 @@ import fi.bitrite.android.ws.repository.FavoriteRepository; import fi.bitrite.android.ws.repository.Resource; import fi.bitrite.android.ws.repository.SettingsRepository; +import fi.bitrite.android.ws.ui.util.OfflineMapHelper; import fi.bitrite.android.ws.ui.util.UserFilterManager; import fi.bitrite.android.ws.ui.util.UserMarker; import fi.bitrite.android.ws.ui.util.UserMarkerClusterer; @@ -174,6 +184,10 @@ public void onCreate(Bundle savedInstanceState) { mMarkerClusterer.setOnClusterClickListener(this::onClusterClick); mHideLocationBtn = mSettingsRepository.getHideLocationButton(); + + if (mSettingsRepository.isOfflineMapEnabled()) { + MapsForgeTileSource.createInstance(requireActivity().getApplication()); + } } @Nullable @@ -182,7 +196,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c @Nullable Bundle savedInstanceState) { Context context = getContext(); Configuration.getInstance().load( - context, PreferenceManager.getDefaultSharedPreferences(context)); + context, PreferenceManager.getDefaultSharedPreferences(requireContext())); // setting this before the layout is inflated is a good idea // it 'should' ensure that the map has a writable location for the map cache, even without // permissions. if no tiles are displayed, you can try overriding the cache path using @@ -209,14 +223,45 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c mLastPosition = null; mMap.setVerticalMapRepetitionEnabled(false); - mMap.setBuiltInZoomControls(false); + mMap.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); mMap.setMultiTouchControls(true); - String tileSourceStr = mSettingsRepository.getTileSourceStr(); - if (!TileSourceFactory.containsTileSource(tileSourceStr)) { - tileSourceStr = TileSourceFactory.DEFAULT_TILE_SOURCE.name(); + if (mSettingsRepository.isOfflineMapEnabled() && + OfflineMapHelper.containsExistingFile(mSettingsRepository.getOfflineMapSourceFiles())) { + // use offline map + MapsForgeTheme mapsForgeTheme = mSettingsRepository.getSelectedOfflineMapTheme(); + if (mapsForgeTheme == null) { + // create empty default theme + mapsForgeTheme = new MapsForgeTheme("Default", "default_theme_id", ""); + } + + File style = new File(mapsForgeTheme.getFilePath()); + XmlRenderTheme theme = null; + + try { + theme = new ExternalRenderTheme(style); + OfflineMapHelper.setThemeStyle(theme, mapsForgeTheme.getId()); + } catch (FileNotFoundException ignored) { + // if theme == null, default rendering theme is used. + } + + // TODO: set map language when osmdroid updated to v6.0.3 + MapsForgeTileSource fromFiles = MapsForgeTileSource.createFromFiles( + mSettingsRepository.getOfflineMapSourceFiles(), theme, style.getName()); + + MapsForgeTileProvider forge = new MapsForgeTileProvider( + new SimpleRegisterReceiver(getContext()), + fromFiles, null + ); + mMap.setTileProvider(forge); + } else { + // use online map + String tileSourceStr = mSettingsRepository.getOnlineMapSourceStr(); + if (!TileSourceFactory.containsTileSource(tileSourceStr)) { + tileSourceStr = TileSourceFactory.DEFAULT_TILE_SOURCE.name(); + } + mMap.setTileSource(TileSourceFactory.getTileSource(tileSourceStr)); } - mMap.setTileSource(TileSourceFactory.getTileSource(tileSourceStr)); if (hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { addMyLocationOverlay(); @@ -332,33 +377,32 @@ private void askForPermission(String permission, int requestCode) { @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case REQUEST_CODE_FINE_LOCATION: - if (grantResults.length > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - addMyLocationOverlay(); - startLocationManager(); - - // Delays moving to the current location by a tiny bit as the location manager - // was just started and the current location is therefore not yet known. Without - // this delay the "location not known" toast was shown and just afterwards the - // map was moved to the current position. However, if indeed the current - // position is not yet known that toast is shown even after that delay. - Disposable unused = Completable - .timer(100, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onGotoCurrentLocationClicked); - } else { - // if the permission for location is denied with checked "Never ask again", - // the current location button will be hidden. - mHideLocationBtn = !shouldShowRequestPermissionRationale(permissions[0]); - } + if (requestCode != REQUEST_CODE_FINE_LOCATION) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + return; + } + + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + addMyLocationOverlay(); + startLocationManager(); - setGotoCurrentLocationStatus(); - break; - default: - super.onRequestPermissionsResult(requestCode, permissions, grantResults); + // Delays moving to the current location by a tiny bit as the location manager + // was just started and the current location is therefore not yet known. Without + // this delay the "location not known" toast was shown and just afterwards the + // map was moved to the current position. However, if indeed the current + // position is not yet known that toast is shown even after that delay. + Disposable unused = Completable + .timer(100, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onGotoCurrentLocationClicked); + } else { + // if the permission for location is denied with checked "Never ask again", + // the current location button will be hidden. + mHideLocationBtn = !shouldShowRequestPermissionRationale(permissions[0]); } + + setGotoCurrentLocationStatus(); } private void startLocationManager() { @@ -633,10 +677,7 @@ private boolean markerUpdateRequired(final Marker existingMarker, SimpleUser use if (!existingMarker.getPosition().equals(user.location)) { return true; } - if (!existingMarker.getIcon().equals(getMarkerIconForHost(isFavoriteHost))) { - return true; - } - return false; + return !existingMarker.getIcon().equals(getMarkerIconForHost(isFavoriteHost)); } private Drawable getMarkerIconForHost(boolean isFavoriteHost) { diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java index 22ee3413..b0395b30 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java @@ -1,33 +1,53 @@ package fi.bitrite.android.ws.ui; +import android.Manifest; +import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.content.res.Resources; +import android.net.Uri; import android.os.Bundle; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.preference.ListPreference; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; +import android.text.Html; import android.text.TextUtils; +import android.util.Log; + +import com.google.gson.Gson; import org.osmdroid.tileprovider.tilesource.ITileSource; import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import java.io.File; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; +import java.util.Set; import javax.inject.Inject; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.preference.ListPreference; +import androidx.preference.MultiSelectListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.SwitchPreference; import butterknife.BindString; import butterknife.ButterKnife; import fi.bitrite.android.ws.BuildConfig; import fi.bitrite.android.ws.R; import fi.bitrite.android.ws.di.Injectable; +import fi.bitrite.android.ws.model.MapsForgeTheme; import fi.bitrite.android.ws.repository.SettingsRepository; import fi.bitrite.android.ws.ui.preference.RefreshIntervalPreferenceDialogFragment; import fi.bitrite.android.ws.ui.util.ActionBarTitleHelper; +import fi.bitrite.android.ws.ui.util.OfflineMapHelper; public class SettingsFragment extends PreferenceFragmentCompat implements Injectable, SharedPreferences.OnSharedPreferenceChangeListener { @@ -35,10 +55,21 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Inject @Inject ActionBarTitleHelper mActionBarTitleHelper; @Inject SettingsRepository mSettingsRepository; + @BindString(R.string.prefs_online_map_source) String mOnlineMapSource; + + @BindString(R.string.prefs_offline_map_enabled) String mOfflineMapSwitch; + @BindString(R.string.prefs_offline_map_selection) String mOfflineMapSelection; + @BindString(R.string.prefs_offline_map_source_files) String mOfflineMapSourceFiles; + @BindString(R.string.prefs_offline_theme_selection) String mOfflineThemeSelection; + @BindString(R.string.prefs_offline_theme_source_files) String mOfflineThemeSourceFiles; + + @BindString(R.string.prefs_offline_map_prefer_installed_locales) String mOfflineMapLanguage; + @BindString(R.string.prefs_distance_unit_key) String mKeyDistanceUnit; - @BindString(R.string.prefs_tile_source_key) String mTileMapSource; @BindString(R.string.prefs_message_refresh_interval_min_key) String mKeyMessageRefreshInterval; + private final static int MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 667; + public static Fragment create() { Bundle bundle = new Bundle(); @@ -50,7 +81,7 @@ public static Fragment create() { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - ButterKnife.bind(this, getActivity()); + ButterKnife.bind(this, requireActivity()); } @Override @@ -60,6 +91,20 @@ public void onResume() { mActionBarTitleHelper.set(getString(R.string.title_fragment_settings)); mSettingsRepository.registerOnChangeListener(this); + + + findPreference(mOfflineMapSwitch).setSummaryProvider( + preference -> { + String fileDir = OfflineMapHelper.defaultMapDataDirectory(requireContext()); + List fnames = new ArrayList<>(); + for (File f : mSettingsRepository.getOfflineMapSourceFiles()) { + fnames.add(f.getAbsolutePath().replace(fileDir, "")); + } + return TextUtils.join(", ", fnames); + } + ); + + setOfflinemapSettings(); setSummary(); } @@ -69,6 +114,11 @@ public void onPause() { super.onPause(); } + @Override + public void onDestroy() { + super.onDestroy(); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { // Load the preferences from an XML resource @@ -81,25 +131,65 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key == null) { + return; + } + + if (key.equals(mOfflineMapSwitch) && sharedPreferences.getBoolean(mOfflineMapSwitch, false)) { + // ask for storage permission + if (ContextCompat.checkSelfPermission(requireActivity(), + Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + // Permission is not granted + requestPermissions( + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, + MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); + } else { + searchOfflineMapData(); + } + } + setOfflinemapSettings(); setSummary(); } - private void setSummary() { - // distance units - findPreference(mKeyDistanceUnit).setSummary(getString( - R.string.prefs_distance_unit_summary, - mSettingsRepository.getDistanceUnitLong())); - // map sources - setAvailableMapSources((ListPreference) findPreference(mTileMapSource)); + private void searchOfflineMapData() { + OfflineMapHelper.searchOfflineMapData(mSettingsRepository,false, requireActivity()); + Set availableSources = mSettingsRepository.getAvailableOfflineMapSources(); + if (availableSources.isEmpty()) { + // no maps found. show help and uncheck button + showMapDownloadDialog(); + ((SwitchPreference) findPreference(mOfflineMapSwitch)).setChecked(false); + } + } - // message refresh interval - Resources res = getResources(); - int intervalMin = mSettingsRepository.getMessageRefreshIntervalMin(); - findPreference(mKeyMessageRefreshInterval).setSummary(intervalMin > 0 - ? res.getQuantityString(R.plurals.prefs_message_refresh_interval_min_summary, - intervalMin, intervalMin) - : getString(R.string.prefs_message_refresh_interval_min_summary_disabled)); + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode != MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + return; + } + + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + searchOfflineMapData(); + } else { + ((SwitchPreference) findPreference(mOfflineMapSwitch)).setChecked(false); + } + setOfflinemapSettings(); + } + + + private void showMapDownloadDialog() { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.no_offline_maps_found_title) + .setMessage(Html.fromHtml(String.format(getString(R.string.no_offline_maps_found_message), OfflineMapHelper.defaultMapDataDirectory(requireContext())))) + .setPositiveButton(R.string.alert_neutral_button, (dialog, id) -> dialog.dismiss()) + .setNeutralButton(R.string.open_map_provider_overview, ((dialog, which) -> { + String url = "https://github.com/mapsforge/mapsforge/blob/master/docs/Mapsforge-Maps.md"; + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + })) + .setCancelable(true) + .create() + .show(); } @Override @@ -111,16 +201,92 @@ public void onDisplayPreferenceDialog(Preference preference) { if (dialogFragment != null) { dialogFragment.setTargetFragment(this, 0); - dialogFragment.show(getFragmentManager(), dialogFragment.getClass().getCanonicalName()); + dialogFragment.show(requireFragmentManager(), dialogFragment.getClass().getCanonicalName()); } else { super.onDisplayPreferenceDialog(preference); } } - private void setAvailableMapSources(final ListPreference tileSourcePreference) { + private void setOfflinemapSettings() { + boolean offlineMapEnabled = ((SwitchPreference) findPreference(mOfflineMapSwitch)).isChecked(); + if (offlineMapEnabled) { + listOfflineMapSources(findPreference(mOfflineMapSelection)); + listOfflineStyleSources(findPreference(mOfflineThemeSelection)); + } + findPreference(mOfflineThemeSelection).setEnabled(offlineMapEnabled); + findPreference(mOnlineMapSource).setEnabled(!offlineMapEnabled); + + } + + private void setSummary() { + // online map sources + listAvailableOnlineMapSources(findPreference(mOnlineMapSource)); + + // message refresh interval + Resources res = getResources(); + int intervalMin = mSettingsRepository.getMessageRefreshIntervalMin(); + findPreference(mKeyMessageRefreshInterval).setSummary(intervalMin > 0 + ? res.getQuantityString(R.plurals.prefs_message_refresh_interval_min_summary, + intervalMin, intervalMin) + : getString(R.string.prefs_message_refresh_interval_min_summary_disabled)); + + // distance units + findPreference(mKeyDistanceUnit).setSummary(getString( + R.string.prefs_distance_unit_summary, + mSettingsRepository.getDistanceUnitLong())); + } + + private void listOfflineMapSources(final MultiSelectListPreference pref) { + Set maps = mSettingsRepository.getAvailableOfflineMapSources(); + + final List sourceNames = new LinkedList<>(); + final List sourceValues = new LinkedList<>(); + for (String map: maps) { + File f = new File(map); + if (f.exists()) { + sourceNames.add(f.getName()); + sourceValues.add(f.getAbsolutePath()); + } + } + + pref.setEntries(sourceNames.toArray(new CharSequence[0])); + pref.setEntryValues(sourceValues.toArray(new CharSequence[0])); + if (pref.getValues().isEmpty() && pref.getEntryValues().length > 0) { + // use all maps as default + Set values = new HashSet<>(); + for (CharSequence cs : pref.getEntryValues()) { + values.add(cs.toString()); + } + pref.setValues(values); + } + } + + private void listOfflineStyleSources(final ListPreference pref) { + List themes = mSettingsRepository.getAvailableOfflineThemeSources(); + + final List sourceNames = new LinkedList<>(); + final List sourceValues = new LinkedList<>(); + Gson gson = new Gson(); + for (MapsForgeTheme theme : themes) { + if (new File(theme.getFilePath()).exists() || theme.getId().equals("default_theme")) { + sourceNames.add(theme.getLocalizedDisplayName(Locale.getDefault().getLanguage())); + sourceValues.add(gson.toJson(theme)); + } + } + + pref.setEntries(sourceNames.toArray(new CharSequence[0])); + pref.setEntryValues(sourceValues.toArray(new CharSequence[0])); + if (pref.getValue() == null) { + // if user has selected nothing, use default theme + pref.setSummary(themes.get(themes.size() - 1).getLocalizedDisplayName(Locale.getDefault().getLanguage())); + pref.setValue(pref.getEntryValues()[0].toString()); + } + } + + private void listAvailableOnlineMapSources(final ListPreference tileSourcePreference) { tileSourcePreference.setSummary(getString( R.string.prefs_tile_source_summary, - mSettingsRepository.getTileSourceStr())); + mSettingsRepository.getOnlineMapSourceStr())); final List tileSourceNames = new LinkedList<>(); final List tileSourceValues = new LinkedList<>(); final List tileSources = TileSourceFactory.getTileSources(); @@ -133,7 +299,7 @@ private void setAvailableMapSources(final ListPreference tileSourcePreference) { TileSourceFactory.ChartbundleWAC); tileSources.removeAll(blacklisted_sources); - // Some maps are only available in the US + // Some maps only cover the US List usOnlySources = Arrays.asList( TileSourceFactory.USGS_SAT, TileSourceFactory.USGS_TOPO); @@ -156,9 +322,9 @@ private void setAvailableMapSources(final ListPreference tileSourcePreference) { } CharSequence[] tileSourceNamesCS = - tileSourceNames.toArray(new CharSequence[tileSourceNames.size()]); + tileSourceNames.toArray(new CharSequence[0]); CharSequence[] tileSourceValuesCS = - tileSourceValues.toArray(new CharSequence[tileSourceValues.size()]); + tileSourceValues.toArray(new CharSequence[0]); tileSourcePreference.setEntries(tileSourceNamesCS); tileSourcePreference.setEntryValues(tileSourceValuesCS); diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/util/OfflineMapHelper.java b/app/src/main/java/fi/bitrite/android/ws/ui/util/OfflineMapHelper.java new file mode 100644 index 00000000..18f8e92f --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/ui/util/OfflineMapHelper.java @@ -0,0 +1,202 @@ +package fi.bitrite.android.ws.ui.util; + +import android.content.Context; +import android.os.Environment; +import android.util.Xml; + +import org.mapsforge.map.rendertheme.XmlRenderTheme; +import org.mapsforge.map.rendertheme.XmlRenderThemeStyleLayer; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import androidx.fragment.app.FragmentActivity; +import fi.bitrite.android.ws.R; +import fi.bitrite.android.ws.model.MapsForgeTheme; +import fi.bitrite.android.ws.repository.SettingsRepository; +import io.reactivex.disposables.Disposable; + +public class OfflineMapHelper { + + public static final String TAG = "OfflineMapHelper"; + + public static String defaultMapDataDirectory(Context context) { + return context.getExternalFilesDir(null).toString() + "/"; + } + + public static boolean containsExistingFile(File[] files) { + for (File f : files) { + if (f.exists()) { + return true; + } + } + return false; + } + + public static void searchOfflineMapData(SettingsRepository settingsRepository, boolean extendedSearchScope, FragmentActivity activity) { + File[] startFolders; + if (extendedSearchScope) { + startFolders = new File[]{ + new File(defaultMapDataDirectory(activity)), + Environment.getExternalStorageDirectory()}; + } else { + startFolders = new File[]{ + new File(defaultMapDataDirectory(activity))}; + } + + Disposable progressDisposable = ProgressDialog.create(R.string.map_search_in_progress) + .showDelayed(activity, 100, TimeUnit.MILLISECONDS); + + List mapfiles = OfflineMapHelper.findMapsforgeMapFiles(startFolders); + settingsRepository.setAvailableOfflineMapSources(new HashSet<>(mapfiles)); + + List themes = new ArrayList<>(); + for (File themefile : OfflineMapHelper.findMapsforgeThemeFiles(startFolders)) { + themes.addAll(OfflineMapHelper.getThemeStyles(themefile)); + } + settingsRepository.setAvailableOfflineThemeSources(themes); + + progressDisposable.dispose(); + } + + private static List findMapsforgeMapFiles(File[] rootFolders) { + List mapFiles = new ArrayList<>(); + for (File file : findFilesWithExtension(new ArrayList<>(), rootFolders, ".map")) { + if (isMapsforgeBinaryOSM(file)) { + mapFiles.add(file.getAbsolutePath()); + } + } + return mapFiles; + } + + + private static List findMapsforgeThemeFiles(File[] rootFolders) { + List mapFiles = new ArrayList<>(); + for (File file : findFilesWithExtension(new ArrayList<>(), rootFolders, ".xml")) { + if (isMapsforgeThemeXML(file)) { + mapFiles.add(file); + } + } + return mapFiles; + } + + private static List getThemeStyles(File theme) { + XmlPullParser parser = Xml.newPullParser(); + try (InputStream in_s = new FileInputStream(theme)) { + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(in_s, null); + return extractThemes(parser, theme.getAbsolutePath()); + } catch (XmlPullParserException|IOException ignored) {} + + return new ArrayList<>(); + } + + private static List extractThemes(XmlPullParser parser, final String filePath) + throws XmlPullParserException, IOException { + + List themes = new ArrayList<>(); + + int eventType = parser.getEventType(); + String elementName; + MapsForgeTheme theme = null; + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case XmlPullParser.START_TAG: + elementName = parser.getName(); + if ("layer".equalsIgnoreCase(elementName)) { + if ("true".equalsIgnoreCase(parser.getAttributeValue(null, "visible"))) { + theme = new MapsForgeTheme(new File(filePath).getName(), parser.getAttributeValue(null, "id"), filePath); + } + } else if (theme != null && "name".equalsIgnoreCase(elementName)) { + String lang = parser.getAttributeValue(null,"lang"); + String localizedThemeName = parser.getAttributeValue(null,"value"); + theme.addLocalizedName(lang, localizedThemeName); + } + break; + case XmlPullParser.END_TAG: + elementName = parser.getName(); + if ("layer".equalsIgnoreCase(elementName) && theme != null ) { + themes.add(theme); + theme = null; + } + } + eventType = parser.next(); + } + + return themes; + } + + public static void setThemeStyle(XmlRenderTheme theme, String id) { + theme.setMenuCallback(themestyle -> { + String themeId = id.isEmpty() ? themestyle.getDefaultValue() : id; + XmlRenderThemeStyleLayer baseLayer = themestyle.getLayer(themeId); + if (baseLayer == null) { + return null; + } + + // add the categories from overlays + Set result = baseLayer.getCategories(); + for (XmlRenderThemeStyleLayer overlay : baseLayer.getOverlays()) { + result.addAll(overlay.getCategories()); + } + + return result; + }); + } + + + private static List findFilesWithExtension(List foundFiles, File[] filesArray, String ext) { + if (filesArray == null) { + return foundFiles; + } + + for (File file : filesArray) { + if (file.isDirectory()) { + findFilesWithExtension(foundFiles,file.listFiles(path -> + (path.isDirectory() || path.getName().toLowerCase().endsWith(ext))), ext); + } else if (file.getName().toLowerCase().endsWith(ext)) { + foundFiles.add(file); + } + } + return foundFiles; + } + + + private static boolean isMapsforgeBinaryOSM(File file) { + // https://github.com/mapsforge/mapsforge/blob/master/docs/Specification-Binary-Map-File.md + int magicByteSize = 20; + String magicByteString = "mapsforge binary OSM"; + + byte[] buffer = new byte[magicByteSize]; + try (InputStream is = new FileInputStream(file.getAbsolutePath())) { + is.read(buffer); + } catch (IOException ignored) { } + return new String(buffer).contentEquals(magicByteString); + } + + private static boolean isMapsforgeThemeXML(File file) { + String identifier = "http://mapsforge.org/renderTheme"; + try (BufferedReader br = new BufferedReader(new FileReader(file))) { + String line; + while ((line = br.readLine()) != null) { + if (line.contains(identifier)) { + return true; + } + } + } catch (Exception e) { + return false; + } + return false; + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java index 852fc0fc..e1b2165c 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java @@ -159,7 +159,7 @@ private String getClusterText(int bucket) { if (bucket < BUCKETS[0]) { return String.valueOf(bucket); } - return String.valueOf(bucket) + "+"; + return bucket + "+"; } private class TextDrawable extends Drawable { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 543a4c6b..7478d700 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -98,7 +98,7 @@ km mi - Kartenstil + Online-Kartenstil %1$s (nur USA) Aktualisierungsintervall für Nachrichten @@ -262,4 +262,8 @@ Der Server hat die Anfrage abgewiesen. Bitte aktualisiere die App.\n(%1$s) Zu viele Loginversuche. Dieses Konto ist zur Zeit gesperrt. Bitte versuche es später noch einmal. + Suche nach Offline-Karten + Keine Offline-Karten gefunden + Liste von Offline-Kartenanbietern + Kopiere entpackte Offline-Karten nach %1$s und versuche es erneut. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6d1ca6ae..df0ee38d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -92,7 +92,7 @@ Unidad de distancia Denunciar distancias en %1$s - Estilo de mapa + Origen de mapa en línea %1$s (sólo EE.UU.) Kilómetros Millas @@ -248,4 +248,8 @@ El servidor rechazó la solicitud. Por favor actualice la aplicación.\n(%1$s) Demasiados intentos de inicio de sesión. Su cuenta está temporalmente bloqueada. Por favor, inténtelo de nuevo más tarde. + Lista de proveedores de mapas fuera de línea + No se han encontrado mapas fuera de línea + Copie los datos del mapa fuera de línea descomprimidos en %1$s e inténtelo de nuevo. + Búsqueda de mapas fuera de línea diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 554d9130..337f91b7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -97,7 +97,7 @@ km mi - Style de la carte + Source de la carte en ligne %1$s (É.-U. seulement) @@ -254,4 +254,8 @@ Le serveur a rejeté la demande. STP mettre à jour l\'application.\n(%1$s) Trop de tentatives de connexion. Votre compte est temporairement bloqué. Veuillez réessayer plus tard. + Copiez la carte hors ligne dans %1$s et réessayez. + Recherche de cartes hors ligne + Aucune carte hors-ligne trouvée + Liste des fournisseurs de cartes hors ligne diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d50a96e..24615850 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,11 +102,16 @@ km mi - tile_source - Map style + Online map source %1$s %1$s (US only) + offline_map_enabled + offline_map_selection + offline_map_source_files + offline_theme_selection + offline_theme_source_files + message_reload_interval_min Messages poll frequency @@ -274,4 +279,14 @@ Too many login attempts. Your account is temporarily blocked. Please try again later. @string/title_fragment_messages + prefs_online_map_source + prefs_offline_maps_prefs_help_text + prefs_offline_map_search + prefs_offline_map_prefer_installed_locales + Searching for offline maps + prefs_offline_map_theme_sources + prefs_offline_map_search_scope + No offline maps found + Copy unzipped offline map data to %1$s and try again. + List of offline map providers diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index f9daff04..ae2c2355 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -5,20 +5,35 @@ Reflect changes in `app/src/google/res/xml/preferences.xml`. --> + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> - + - + + + + + + + + + + + - + + + + + + diff --git a/build.gradle b/build.gradle index ca272c82..5afa3c31 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:3.5.2' classpath 'com.saantiaguilera.securekeys:plugin:2.2.0' // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle.properties b/gradle.properties index 59590122..3a5e83fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ android.enableJetifier=true android.useAndroidX=true -android.jetifier.blacklist = shadows-supportv4-4.3.jar +android.jetifier.blacklist = shadows-supportv4-4.3.1.jar