diff --git a/android/flavors/gms-enabled/app/organicmaps/location/GoogleFusedLocationProvider.java b/android/flavors/gms-enabled/app/organicmaps/location/GoogleFusedLocationProvider.java index d4256276c1..0c30ef0fe9 100644 --- a/android/flavors/gms-enabled/app/organicmaps/location/GoogleFusedLocationProvider.java +++ b/android/flavors/gms-enabled/app/organicmaps/location/GoogleFusedLocationProvider.java @@ -1,5 +1,7 @@ package app.organicmaps.location; +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static app.organicmaps.util.concurrency.UiThread.runLater; import android.app.PendingIntent; @@ -8,6 +10,7 @@ import android.location.Location; import android.os.Looper; import androidx.annotation.NonNull; +import androidx.annotation.RequiresPermission; import com.google.android.gms.common.api.ApiException; import com.google.android.gms.common.api.ResolvableApiException; @@ -65,9 +68,8 @@ class GoogleFusedLocationProvider extends BaseLocationProvider mContext = context; } - @SuppressWarnings("MissingPermission") - // A permission is checked externally @Override + @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}) public void start(long interval) { Logger.d(TAG); diff --git a/android/src/app/organicmaps/MwmActivity.java b/android/src/app/organicmaps/MwmActivity.java index fd2b5fe3d4..43386f123d 100644 --- a/android/src/app/organicmaps/MwmActivity.java +++ b/android/src/app/organicmaps/MwmActivity.java @@ -2,6 +2,8 @@ package app.organicmaps; import android.annotation.SuppressLint; import android.app.Activity; +import android.app.Dialog; +import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.location.Location; @@ -14,12 +16,17 @@ import android.view.View; import android.view.WindowManager; import android.widget.TextView; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.IntentSenderRequest; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.UiThread; import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; @@ -29,6 +36,7 @@ import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.ViewModelProvider; import app.organicmaps.Framework.PlacePageActivationListener; import app.organicmaps.api.Const; +import app.organicmaps.background.AppBackgroundTracker; import app.organicmaps.background.Notifier; import app.organicmaps.base.BaseMwmFragmentActivity; import app.organicmaps.base.CustomNavigateUpListener; @@ -81,6 +89,7 @@ import app.organicmaps.settings.UnitLocale; import app.organicmaps.sound.TtsPlayer; import app.organicmaps.util.Config; import app.organicmaps.util.Counters; +import app.organicmaps.util.LocationUtils; import app.organicmaps.util.SharingUtils; import app.organicmaps.util.ThemeSwitcher; import app.organicmaps.util.ThemeUtils; @@ -99,6 +108,11 @@ import java.util.ArrayList; import java.util.Objects; import java.util.Stack; +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static app.organicmaps.location.LocationState.LOCATION_TAG; + public class MwmActivity extends BaseMwmFragmentActivity implements PlacePageActivationListener, View.OnTouchListener, @@ -114,7 +128,8 @@ public class MwmActivity extends BaseMwmFragmentActivity NoConnectionListener, MenuBottomSheetFragment.MenuBottomSheetInterfaceWithHeader, PlacePageController.PlacePageRouteSettingsListener, - MapButtonsController.MapButtonClickListener + MapButtonsController.MapButtonClickListener, + AppBackgroundTracker.OnTransitionListener { private static final String TAG = MwmActivity.class.getSimpleName(); @@ -129,12 +144,7 @@ public class MwmActivity extends BaseMwmFragmentActivity EditorHostFragment.class.getName(), ReportFragment.class.getName() }; - public static final int REQ_CODE_ERROR_DRIVING_OPTIONS_DIALOG = 5; public static final int REQ_CODE_DRIVING_OPTIONS = 6; - private static final int REQ_CODE_ISOLINES_ERROR = 8; - - public static final String ERROR_DRIVING_OPTIONS_DIALOG_TAG = "error_driving_options_dialog_tag"; - private static final String ISOLINES_ERROR_DIALOG_TAG = "isolines_dialog_tag"; private static final String MAIN_MENU_ID = "MAIN_MENU_BOTTOM_SHEET"; private static final String LAYERS_MENU_ID = "LAYERS_MENU_BOTTOM_SHEET"; @@ -186,6 +196,17 @@ public class MwmActivity extends BaseMwmFragmentActivity @Nullable private WindowInsetsCompat mCurrentWindowInsets; + @Nullable + private Dialog mLocationErrorDialog; + + @SuppressWarnings("NotNullFieldNotInitialized") + @NonNull + private ActivityResultLauncher mLocationPermissionRequest; + + @SuppressWarnings("NotNullFieldNotInitialized") + @NonNull + private ActivityResultLauncher mLocationResolutionRequest; + public interface LeftAnimationTrackListener { void onTrackStarted(boolean collapsed); @@ -353,6 +374,9 @@ public class MwmActivity extends BaseMwmFragmentActivity protected void onSafeCreate(@Nullable Bundle savedInstanceState) { super.onSafeCreate(savedInstanceState); + + MwmApplication.backgroundTracker(this).addListener(this); + mIsTabletLayout = getResources().getBoolean(R.bool.tabletLayout); if (!mIsTabletLayout) @@ -377,6 +401,12 @@ public class MwmActivity extends BaseMwmFragmentActivity initViews(isLaunchByDeepLink); updateViewsInsets(); + // Note: You must call registerForActivityResult() before the fragment or activity is created. + mLocationPermissionRequest = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), + this::onLocationPermissionsResult); + mLocationResolutionRequest = registerForActivityResult(new ActivityResultContracts.StartIntentSenderForResult(), + this::onLocationResolutionResult); + boolean isConsumed = savedInstanceState == null && processIntent(getIntent()); boolean isFirstLaunch = Counters.isFirstLaunch(this); // If the map activity is launched by any incoming intent (deeplink, update maps event, etc) @@ -485,7 +515,7 @@ public class MwmActivity extends BaseMwmFragmentActivity startActivity(new Intent(MwmActivity.this, FeatureCategoryActivity.class)); else { - new AlertDialog.Builder(this, R.style.MwmTheme_AlertDialog) + new MaterialAlertDialogBuilder(this, R.style.MwmTheme_AlertDialog) .setTitle(R.string.message_invalid_feature_position) .setPositiveButton(R.string.ok, null) .show(); @@ -621,8 +651,7 @@ public class MwmActivity extends BaseMwmFragmentActivity break; case myPosition: LocationState.nativeSwitchToNextMode(); - if (!LocationHelper.INSTANCE.isActive()) - LocationHelper.INSTANCE.start(); + startLocation(); break; case toggleMapLayer: toggleMapLayerBottomSheet(); @@ -770,8 +799,7 @@ public class MwmActivity extends BaseMwmFragmentActivity public void startLocationToPoint(final @Nullable MapObject endPoint) { closeFloatingPanels(); - if (!LocationHelper.INSTANCE.isActive()) - LocationHelper.INSTANCE.start(); + startLocation(); MapObject startPoint = LocationHelper.INSTANCE.getMyPosition(); RoutingController.get().prepare(startPoint, endPoint); @@ -967,6 +995,17 @@ public class MwmActivity extends BaseMwmFragmentActivity refreshLightStatusBar(); } + @Override + public void onTransit(boolean foreground) + { + Logger.d(TAG, "foreground = " + foreground); + + if (foreground) + resumeLocationInForeground(); + else + pauseLocationInBackground(); + } + @Override public void recreate() { @@ -991,7 +1030,7 @@ public class MwmActivity extends BaseMwmFragmentActivity if (mOnmapDownloader != null) mOnmapDownloader.onPause(); mNavigationController.onActivityPaused(this); - LocationHelper.INSTANCE.closeLocationDialog(); + pauseLocationInBackground(); super.onPause(); } @@ -1003,7 +1042,6 @@ public class MwmActivity extends BaseMwmFragmentActivity BookmarkManager.INSTANCE.addLoadingListener(this); RoutingController.get().attach(this); IsolinesManager.from(getApplicationContext()).attach(this::onIsolinesStateChanged); - LocationHelper.INSTANCE.attach(this); LocationState.nativeSetListener(this); LocationHelper.INSTANCE.addListener(this); onMyPositionModeChanged(LocationState.nativeGetMode()); @@ -1020,7 +1058,6 @@ public class MwmActivity extends BaseMwmFragmentActivity BookmarkManager.INSTANCE.removeLoadingListener(this); LocationHelper.INSTANCE.removeListener(this); LocationState.nativeRemoveListener(); - LocationHelper.INSTANCE.detach(); RoutingController.get().detach(); IsolinesManager.from(getApplicationContext()).detach(); mSearchController.detach(); @@ -1033,6 +1070,10 @@ public class MwmActivity extends BaseMwmFragmentActivity { super.onSafeDestroy(); mNavigationController.destroy(); + mLocationPermissionRequest.unregister(); + mLocationPermissionRequest = null; + mLocationResolutionRequest.unregister(); + mLocationResolutionRequest = null; } @Override @@ -1612,16 +1653,33 @@ public class MwmActivity extends BaseMwmFragmentActivity @Override public void onMyPositionModeChanged(int newMode) { - Logger.d(TAG, "location newMode = " + newMode); + Logger.d(LOCATION_TAG, "newMode = " + newMode); mMapButtonsViewModel.setMyPositionMode(newMode); RoutingController controller = RoutingController.get(); if (controller.isPlanning()) showAddStartOrFinishFrame(controller, true); } + /** + * Dismiss location error dialog from the screen, if any. + */ + private void dismissLocationErrorDialog() + { + if (mLocationErrorDialog != null && mLocationErrorDialog.isShowing()) + mLocationErrorDialog.dismiss(); + mLocationErrorDialog = null; + } + + /** + * Called when location is updated. + * @param location new location + */ @Override + @UiThread public void onLocationUpdated(@NonNull Location location) { + dismissLocationErrorDialog(); + final RoutingController routing = RoutingController.get(); if (!routing.isNavigating()) return; @@ -1642,13 +1700,256 @@ public class MwmActivity extends BaseMwmFragmentActivity } } + /** + * Called when compass data is updated. + * @param north offset from the north + */ @Override + @UiThread public void onCompassUpdated(double north) { Map.onCompassUpdated(north, false); mNavigationController.updateNorth(); } + /** + * Start location services when the user presses a button or starts routing. + */ + private void startLocation() + { + Logger.d(LOCATION_TAG); + + if (ActivityCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED) + { + Logger.i(LOCATION_TAG, "Permission ACCESS_FINE_LOCATION is granted"); + LocationHelper.INSTANCE.start(); + return; + } + + // Always try to optimistically request FINE permission when the user presses a button or starts routing. + // Android will suppress annoying dialogs and skip directly to onLocationPermissionsResult(). + Logger.i(LOCATION_TAG, "Requesting ACCESS_FINE_LOCATION permission"); + dismissLocationErrorDialog(); + mLocationPermissionRequest.launch(new String[]{ + ACCESS_COARSE_LOCATION, + ACCESS_FINE_LOCATION + }); + } + + /** + * Resume location services when entering the foreground. + */ + private void resumeLocationInForeground() + { + LocationHelper.INSTANCE.setRotation(getWindowManager().getDefaultDisplay().getRotation()); + LocationState.nativeSetLocationPendingTimeoutListener(this::onLocationPendingTimeout); + + if (LocationState.nativeGetMode() == LocationState.NOT_FOLLOW_NO_POSITION) + { + Logger.i(LOCATION_TAG, "Location updates are stopped by the user manually."); + LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); + LocationHelper.INSTANCE.stop(); + } + else if (ActivityCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED) + { + Logger.i(LOCATION_TAG, "Permission ACCESS_FINE_LOCATION is granted"); + LocationHelper.INSTANCE.start(); + } + else if (ActivityCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) + { + Logger.i(LOCATION_TAG, "Permission ACCESS_COARSE_LOCATION is granted"); + LocationHelper.INSTANCE.start(); + } + else + { + Logger.w(LOCATION_TAG, "Permissions ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION are not granted"); + LocationState.nativeOnLocationError(LocationState.ERROR_DENIED); + LocationHelper.INSTANCE.stop(); + + Logger.i(LOCATION_TAG, "Requesting ACCESS_FINE_LOCATION + ACCESS_FINE_LOCATION permissions"); + dismissLocationErrorDialog(); + mLocationPermissionRequest.launch(new String[]{ + ACCESS_COARSE_LOCATION, + ACCESS_FINE_LOCATION + }); + } + } + + /** + * Pause location services when entering the background. + */ + private void pauseLocationInBackground() + { + dismissLocationErrorDialog(); + LocationState.nativeRemoveLocationPendingTimeoutListener(); + + if (!LocationHelper.INSTANCE.isActive()) + return; + + Logger.i(LOCATION_TAG); + LocationHelper.INSTANCE.stop(); + } + + /** + * Called on the result of the system location dialog. + * @param permissions permissions granted or refused. + */ + @UiThread + private void onLocationPermissionsResult(java.util.Map permissions) + { + // Print permissions that have been granted or refused. + for (java.util.Map.Entry entry : permissions.entrySet()) + { + final String permission = entry.getKey().substring(entry.getKey().lastIndexOf('.') + 1); + if (entry.getValue()) + Logger.i(LOCATION_TAG, "Permission " + permission + " has been granted"); + else + Logger.w(LOCATION_TAG, "Permission " + permission + " has been refused"); + } + + // Sic: Android Studio requires explicit calls to checkSelfPermission() for @RequiresPermission in start(). + if (ActivityCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) + { + LocationHelper.INSTANCE.start(); + return; + } + + Logger.w(LOCATION_TAG, "Permissions ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION have been refused"); + LocationState.nativeOnLocationError(LocationState.ERROR_DENIED); + LocationHelper.INSTANCE.stop(); + + if (mLocationErrorDialog != null && mLocationErrorDialog.isShowing()) + { + Logger.w(LOCATION_TAG, "Don't show 'location denied' error dialog because another dialog is in progress"); + return; + } + + mLocationErrorDialog = new MaterialAlertDialogBuilder(this, R.style.MwmTheme_AlertDialog) + .setTitle(R.string.enable_location_services) + .setMessage(R.string.location_is_disabled_long_text) + .setOnDismissListener(dialog -> mLocationErrorDialog = null) + .setNegativeButton(R.string.close, null) + .show(); + } + + /** + * Called by GoogleFusedLocationProvider to request to GPS and/or Wi-Fi. + * @param pendingIntent an intent to launch. + */ + @Override + @UiThread + public void onLocationResolutionRequired(@NonNull PendingIntent pendingIntent) + { + Logger.d(LOCATION_TAG); + + // Cancel our dialog in favor of system dialog. + dismissLocationErrorDialog(); + + // Launch system permission resolution dialog. + Logger.i(LOCATION_TAG, "Starting location resolution dialog"); + IntentSenderRequest intentSenderRequest = new IntentSenderRequest.Builder(pendingIntent.getIntentSender()).build(); + mLocationResolutionRequest.launch(intentSenderRequest); + } + + /** + * Triggered by onLocationResolutionRequired(). + * @param result invocation result. + */ + @UiThread + private void onLocationResolutionResult(@NonNull ActivityResult result) + { + final int resultCode = result.getResultCode(); + Logger.d(LOCATION_TAG, "resultCode = " + resultCode); + + if (resultCode != Activity.RESULT_OK) + { + Logger.w(LOCATION_TAG, "Location resolution has been refused"); + LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); + LocationHelper.INSTANCE.stop(); + return; + } + + Logger.i(LOCATION_TAG, "Location resolution has been granted"); + LocationHelper.INSTANCE.restart(); + } + + /** + * Called by AndroidNativeLocationProvider when no suitable location methods are available. + */ + @Override + @UiThread + public void onLocationDisabled() + { + Logger.d(LOCATION_TAG, "settings = " + LocationUtils.areLocationServicesTurnedOn(this)); + + LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); + LocationHelper.INSTANCE.stop(); + + if (mLocationErrorDialog != null && mLocationErrorDialog.isShowing()) + { + Logger.d(LOCATION_TAG, "Don't show 'location disabled' error dialog because another dialog is in progress"); + return; + } + + final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this, R.style.MwmTheme_AlertDialog) + .setTitle(R.string.enable_location_services) + .setMessage(R.string.location_is_disabled_long_text) + .setOnDismissListener(dialog -> mLocationErrorDialog = null) + .setNegativeButton(R.string.close, null); + final Intent intent = Utils.makeSystemLocationSettingIntent(this); + if (intent != null) + { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); + intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + builder.setPositiveButton(R.string.connection_settings, (dialog, which) -> startActivity(intent)); + } + mLocationErrorDialog = builder.show(); + } + + /** + * Called by the core when location updates were not received after the 30 second deadline. + */ + @UiThread + private void onLocationPendingTimeout() + { + // Sic: the callback can be called after the activity is destroyed because of being queued. + if (isDestroyed()) + { + Logger.w(LOCATION_TAG, "Ignore late callback from core because activity is already destroyed"); + return; + } + + Logger.d(LOCATION_TAG, "services = " + LocationUtils.areLocationServicesTurnedOn(this)); + + // + // For all cases below we don't stop location provider until user explicitly clicks "Stop" in the dialog. + // + + if (mLocationErrorDialog != null && mLocationErrorDialog.isShowing()) + { + Logger.d(LOCATION_TAG, "Don't show 'location timeout' error dialog because another dialog is in progress"); + return; + } + + mLocationErrorDialog = new MaterialAlertDialogBuilder(this, R.style.MwmTheme_AlertDialog) + .setTitle(R.string.current_location_unknown_title) + .setMessage(R.string.current_location_unknown_message) + .setOnDismissListener(dialog -> mLocationErrorDialog = null) + .setNegativeButton(R.string.current_location_unknown_stop_button, (dialog, which) -> + { + Logger.w(LOCATION_TAG, "Disabled by user"); + LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); + LocationHelper.INSTANCE.stop(); + }) + .setPositiveButton(R.string.current_location_unknown_continue_button, (dialog, which) -> + { + // Do nothing - provider will continue to search location. + }) + .show(); + } + @Override public void onUseMyPositionAsStart() { diff --git a/android/src/app/organicmaps/location/AndroidNativeProvider.java b/android/src/app/organicmaps/location/AndroidNativeProvider.java index e9ae5e2b56..b8fd62e1b2 100644 --- a/android/src/app/organicmaps/location/AndroidNativeProvider.java +++ b/android/src/app/organicmaps/location/AndroidNativeProvider.java @@ -1,5 +1,7 @@ package app.organicmaps.location; +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static app.organicmaps.util.concurrency.UiThread.runLater; import android.content.Context; @@ -9,6 +11,7 @@ import android.os.Bundle; import android.os.Looper; import androidx.annotation.NonNull; +import androidx.annotation.RequiresPermission; import androidx.core.location.LocationListenerCompat; import androidx.core.location.LocationManagerCompat; import androidx.core.location.LocationRequestCompat; @@ -72,9 +75,9 @@ class AndroidNativeProvider extends BaseLocationProvider throw new IllegalStateException("Can't get LOCATION_SERVICE"); } - @SuppressWarnings("MissingPermission") // A permission is checked externally @Override + @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}) public void start(long interval) { Logger.d(TAG); diff --git a/android/src/app/organicmaps/location/BaseLocationProvider.java b/android/src/app/organicmaps/location/BaseLocationProvider.java index 91f0b1a55e..f6bda3d36b 100644 --- a/android/src/app/organicmaps/location/BaseLocationProvider.java +++ b/android/src/app/organicmaps/location/BaseLocationProvider.java @@ -1,9 +1,13 @@ package app.organicmaps.location; +import static android.Manifest.permission.ACCESS_COARSE_LOCATION; +import static android.Manifest.permission.ACCESS_FINE_LOCATION; + import android.app.PendingIntent; import android.location.Location; import androidx.annotation.NonNull; +import androidx.annotation.RequiresPermission; import androidx.annotation.UiThread; abstract class BaseLocationProvider @@ -28,6 +32,7 @@ abstract class BaseLocationProvider mListener = listener; } + @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}) protected abstract void start(long interval); protected abstract void stop(); } diff --git a/android/src/app/organicmaps/location/LocationHelper.java b/android/src/app/organicmaps/location/LocationHelper.java index 038a51ee1e..31a3c0d355 100644 --- a/android/src/app/organicmaps/location/LocationHelper.java +++ b/android/src/app/organicmaps/location/LocationHelper.java @@ -2,40 +2,33 @@ package app.organicmaps.location; import static android.Manifest.permission.ACCESS_COARSE_LOCATION; import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; -import android.app.Activity; -import android.app.Dialog; import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; import android.location.Location; import android.location.LocationManager; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.IntentSenderRequest; -import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; import androidx.annotation.UiThread; -import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; import app.organicmaps.Framework; -import app.organicmaps.MwmApplication; -import app.organicmaps.R; -import app.organicmaps.background.AppBackgroundTracker; import app.organicmaps.base.Initializable; import app.organicmaps.bookmarks.data.FeatureId; import app.organicmaps.bookmarks.data.MapObject; import app.organicmaps.routing.RoutingController; import app.organicmaps.util.Config; -import app.organicmaps.util.Listeners; import app.organicmaps.util.LocationUtils; import app.organicmaps.util.NetworkPolicy; -import app.organicmaps.util.Utils; import app.organicmaps.util.log.Logger; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -public enum LocationHelper implements Initializable, AppBackgroundTracker.OnTransitionListener, BaseLocationProvider.Listener +import java.util.LinkedHashSet; +import java.util.Set; + +public enum LocationHelper implements Initializable, BaseLocationProvider.Listener { INSTANCE; @@ -54,9 +47,9 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack @NonNull private Context mContext; - private static final String TAG = LocationHelper.class.getSimpleName(); + private static final String TAG = LocationState.LOCATION_TAG; @NonNull - private final Listeners mListeners = new Listeners<>(); + private final Set mListeners = new LinkedHashSet<>(); @Nullable private Location mSavedLocation; private double mSavedNorth = Double.NaN; @@ -67,17 +60,11 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack @SuppressWarnings("NotNullFieldNotInitialized") @NonNull private BaseLocationProvider mLocationProvider; - @Nullable - private AppCompatActivity mActivity; private long mInterval; private boolean mInFirstRun; private boolean mActive; - @Nullable - private Dialog mErrorDialog; - @Nullable - private ActivityResultLauncher mPermissionRequest; - @Nullable - private ActivityResultLauncher mResolutionRequest; + + private int mRotation = 0; @Override public void initialize(@NonNull Context context) @@ -85,7 +72,6 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack mContext = context; mSensorHelper = new SensorHelper(context); mLocationProvider = LocationProviderFactory.getProvider(mContext, this); - MwmApplication.backgroundTracker(context).addListener(this); } @Override @@ -131,54 +117,17 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack return mActive; } - @Override - public void onTransit(boolean foreground) + public void setRotation(int rotation) { - Logger.d(TAG, "foreground = " + foreground + " mode = " + LocationState.nativeGetMode()); - - if (foreground) - { - if (isActive()) - return; - - if (LocationState.nativeGetMode() == LocationState.NOT_FOLLOW_NO_POSITION) - { - Logger.d(TAG, "Location updates are stopped by the user manually, so skip provider start" - + " until the user starts it manually."); - return; - } - - Logger.d(TAG, "Starting in foreground"); - start(); - } - else - { - if (!isActive()) - return; - - Logger.d(TAG, "Stopping in background"); - stop(); - } - } - - public void closeLocationDialog() - { - if (mErrorDialog != null && mErrorDialog.isShowing()) - mErrorDialog.dismiss(); - mErrorDialog = null; + Logger.i(TAG, "rotation = " + rotation); + mRotation = rotation; } void notifyCompassUpdated(double north) { - mSavedNorth = north; - if (mActivity != null) - { - int rotation = mActivity.getWindowManager().getDefaultDisplay().getRotation(); - mSavedNorth = LocationUtils.correctCompassAngle(rotation, mSavedNorth); - } + mSavedNorth = LocationUtils.correctCompassAngle(mRotation, north); for (LocationListener listener : mListeners) listener.onCompassUpdated(mSavedNorth); - mListeners.finishIterate(); } private void notifyLocationUpdated() @@ -186,11 +135,8 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack if (mSavedLocation == null) throw new IllegalStateException("No saved location"); - closeLocationDialog(); - for (LocationListener listener : mListeners) listener.onLocationUpdated(mSavedLocation); - mListeners.finishIterate(); // If we are still in the first run mode, i.e. user is staying on the first run screens, // not on the map, we mustn't post location update to the core. Only this preserving allows us @@ -213,11 +159,14 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack @Override public void onLocationChanged(@NonNull Location location) { - if (!isActive()) - return; - Logger.d(TAG, "provider = " + mLocationProvider.getClass().getSimpleName() + " location = " + location); + if (!isActive()) + { + Logger.w(TAG, "Provider is not active"); + return; + } + if (!LocationUtils.isAccuracySatisfied(location)) { Logger.w(TAG, "Unsatisfied accuracy for location = " + location); @@ -242,26 +191,16 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack @UiThread public void onLocationResolutionRequired(@NonNull PendingIntent pendingIntent) { - if (!isActive()) - return; - Logger.d(TAG); - if (mResolutionRequest == null) + if (!isActive()) { - Logger.d(TAG, "Can't resolve location permissions because UI is not attached"); - stop(); - LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); + Logger.w(TAG, "Provider is not active"); return; } - // Cancel our dialog in favor of system dialog. - closeLocationDialog(); - - // Launch system permission resolution dialog. - IntentSenderRequest intentSenderRequest = new IntentSenderRequest.Builder(pendingIntent.getIntentSender()) - .build(); - mResolutionRequest.launch(intentSenderRequest); + for (LocationListener listener : mListeners) + listener.onLocationResolutionRequired(pendingIntent); } @Override @@ -280,114 +219,13 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack public void onLocationDisabled() { Logger.d(TAG, "provider = " + mLocationProvider.getClass().getSimpleName() + - " permissions = " + LocationUtils.isLocationGranted(mContext) + " settings = " + LocationUtils.areLocationServicesTurnedOn(mContext)); stop(); LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); - if (mActivity == null) - { - Logger.d(TAG, "Don't show 'location disabled' error dialog because Activity is not attached"); - return; - } - - if (mErrorDialog != null && mErrorDialog.isShowing()) - { - Logger.d(TAG, "Don't show 'location disabled' error dialog because another dialog is in progress"); - return; - } - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mActivity, R.style.MwmTheme_AlertDialog) - .setTitle(R.string.enable_location_services) - .setMessage(R.string.location_is_disabled_long_text) - .setOnDismissListener(dialog -> mErrorDialog = null) - .setNegativeButton(R.string.close, null); - final Intent intent = Utils.makeSystemLocationSettingIntent(mActivity); - if (intent != null) - { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - builder.setPositiveButton(R.string.connection_settings, (dialog, which) -> mActivity.startActivity(intent)); - } - mErrorDialog = builder.show(); - } - - @UiThread - private void onLocationDenied() - { - Logger.d(TAG, "provider = " + mLocationProvider.getClass().getSimpleName() + - " permissions = " + LocationUtils.isLocationGranted(mContext) + - " settings = " + LocationUtils.areLocationServicesTurnedOn(mContext)); - - stop(); - LocationState.nativeOnLocationError(LocationState.ERROR_DENIED); - - if (mActivity == null) - { - Logger.w(TAG, "Don't show 'location denied' error dialog because Activity is not attached"); - return; - } - - if (mErrorDialog != null && mErrorDialog.isShowing()) - { - Logger.w(TAG, "Don't show 'location denied' error dialog because another dialog is in progress"); - return; - } - - mErrorDialog = new MaterialAlertDialogBuilder(mActivity, R.style.MwmTheme_AlertDialog) - .setTitle(R.string.enable_location_services) - .setMessage(R.string.location_is_disabled_long_text) - .setOnDismissListener(dialog -> mErrorDialog = null) - .setNegativeButton(R.string.close, null) - .show(); - } - - @UiThread - private void onLocationPendingTimeout() - { - Logger.d(TAG, " permissions = " + LocationUtils.isLocationGranted(mContext) + - " settings = " + LocationUtils.areLocationServicesTurnedOn(mContext)); - - // - // For all cases below we don't stop location provider until user explicitly clicks "Stop" in the dialog. - // - - if (!isActive()) - { - Logger.d(TAG, "Don't show 'location timeout' error dialog because provider is already stopped"); - return; - } - - if (mActivity == null) - { - Logger.d(TAG, "Don't show 'location timeout' error dialog because Activity is not attached"); - return; - } - - if (mErrorDialog != null && mErrorDialog.isShowing()) - { - Logger.d(TAG, "Don't show 'location timeout' error dialog because another dialog is in progress"); - return; - } - - final AppCompatActivity activity = mActivity; - mErrorDialog = new MaterialAlertDialogBuilder(activity, R.style.MwmTheme_AlertDialog) - .setTitle(R.string.current_location_unknown_title) - .setMessage(R.string.current_location_unknown_message) - .setOnDismissListener(dialog -> mErrorDialog = null) - .setNegativeButton(R.string.current_location_unknown_stop_button, (dialog, which) -> - { - Logger.w(TAG, "Disabled by user"); - LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); - stop(); - }) - .setPositiveButton(R.string.current_location_unknown_continue_button, (dialog, which) -> - { - // Do nothing - provider will continue to search location. - }) - .show(); + for (LocationListener listener : mListeners) + listener.onLocationDisabled(); } /** @@ -398,24 +236,24 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack @UiThread public void addListener(@NonNull LocationListener listener) { - Logger.d(TAG, "listener: " + listener + " count was: " + mListeners.getSize()); + Logger.d(TAG, "listener: " + listener + " count was: " + mListeners.size()); - mListeners.register(listener); + mListeners.add(listener); if (mSavedLocation != null) listener.onLocationUpdated(mSavedLocation); if (!Double.isNaN(mSavedNorth)) listener.onCompassUpdated(mSavedNorth); } - @UiThread /** * Removes given location listener. * @param listener listener to unregister. */ + @UiThread public void removeListener(@NonNull LocationListener listener) { - Logger.d(TAG, "listener: " + listener + " count was: " + mListeners.getSize()); - mListeners.unregister(listener); + Logger.d(TAG, "listener: " + listener + " count was: " + mListeners.size()); + mListeners.remove(listener); } private void calcLocationUpdatesInterval() @@ -481,40 +319,30 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack { Logger.d(TAG); stop(); + if (ContextCompat.checkSelfPermission(mContext, ACCESS_COARSE_LOCATION) != PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(mContext, ACCESS_FINE_LOCATION) != PERMISSION_GRANTED) + { + Logger.w(TAG, "Location is not restarted in foreground because of missing permissions"); + return; + } start(); } /** * Starts polling location updates. */ + @RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION}) public void start() { - Logger.d(TAG); - if (isActive()) - throw new IllegalStateException("Already started"); - - if (!LocationUtils.isLocationGranted(mContext)) { - Logger.w(TAG, "Dynamic permissions ACCESS_COARSE_LOCATION and/or ACCESS_FINE_LOCATION are not granted"); - Logger.d(TAG, "error mode = " + LocationState.nativeGetMode()); - LocationState.nativeOnLocationError(LocationState.ERROR_DENIED); - - if (mPermissionRequest == null) - { - Logger.w(TAG, "Don't request location permissions because Activity is not attached"); - return; - } - mPermissionRequest.launch(new String[]{ - ACCESS_COARSE_LOCATION, - ACCESS_FINE_LOCATION - }); + Logger.d(TAG, "Already started"); return; } + Logger.i(TAG); checkForAgpsUpdates(); - LocationState.nativeSetLocationPendingTimeoutListener(this::onLocationPendingTimeout); mSensorHelper.start(); final long oldInterval = mInterval; calcLocationUpdatesInterval(); @@ -529,17 +357,15 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack */ public void stop() { - Logger.d(TAG); - if (!isActive()) { - Logger.w(TAG, "Already stopped"); + Logger.d(TAG, "Already stopped"); return; } + Logger.i(TAG); mLocationProvider.stop(); mSensorHelper.stop(); - LocationState.nativeRemoveLocationPendingTimeoutListener(); mActive = false; } @@ -563,83 +389,6 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack manager.sendExtraCommand(LocationManager.GPS_PROVIDER, "force_time_injection", null); } - /** - * Attach UI to helper. - */ - @UiThread - public void attach(@NonNull AppCompatActivity activity) - { - Logger.d(TAG, "activity = " + activity); - - if (mActivity != null) - { - Logger.e(TAG, "Another Activity = " + mActivity + " is already attached"); - detach(); - } - - mActivity = activity; - - mPermissionRequest = mActivity.registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), - result -> onRequestPermissionsResult()); - mResolutionRequest = mActivity.registerForActivityResult(new ActivityResultContracts.StartIntentSenderForResult(), - result -> onLocationResolutionResult(result.getResultCode())); - } - - /** - * Detach UI from helper. - */ - @UiThread - public void detach() - { - Logger.d(TAG, "activity = " + mActivity); - - if (mActivity == null) - { - Logger.e(TAG, "Activity is not attached"); - return; - } - - assert mPermissionRequest != null; - mPermissionRequest.unregister(); - mPermissionRequest = null; - assert mResolutionRequest != null; - mResolutionRequest.unregister(); - mResolutionRequest = null; - mActivity = null; - } - - @UiThread - private void onRequestPermissionsResult() - { - Logger.d(TAG); - - if (LocationUtils.isLocationGranted(mContext)) - { - Logger.i(TAG, "Permissions have been granted"); - if (!isActive()) - start(); - return; - } - - Logger.w(TAG, "Permissions have not been granted"); - onLocationDenied(); - } - - @UiThread - private void onLocationResolutionResult(int resultCode) - { - if (resultCode != Activity.RESULT_OK) - { - Logger.w(TAG, "Resolution has not been granted"); - stop(); - LocationState.nativeOnLocationError(LocationState.ERROR_GPS_OFF); - return; - } - - Logger.i(TAG, "Resolution has been granted"); - restart(); - } - @UiThread public boolean isInFirstRun() { @@ -669,11 +418,7 @@ public enum LocationHelper implements Initializable, AppBackgroundTrack notifyLocationUpdated(); Logger.d(TAG, "Current location is available, so play the nice zoom animation"); Framework.nativeRunFirstLaunchAnimation(); - return; } - - // Restart location service to show alert dialog if any location error. - restart(); } public double getSavedNorth() diff --git a/android/src/app/organicmaps/location/LocationListener.java b/android/src/app/organicmaps/location/LocationListener.java index 0c33ac4a10..a6d0c3eadb 100644 --- a/android/src/app/organicmaps/location/LocationListener.java +++ b/android/src/app/organicmaps/location/LocationListener.java @@ -1,5 +1,6 @@ package app.organicmaps.location; +import android.app.PendingIntent; import android.location.Location; import androidx.annotation.NonNull; @@ -12,4 +13,14 @@ public interface LocationListener { // No op. } + + default void onLocationDisabled() + { + // No op. + } + + default void onLocationResolutionRequired(@NonNull PendingIntent pendingIntent) + { + // No op. + } } diff --git a/android/src/app/organicmaps/location/LocationState.java b/android/src/app/organicmaps/location/LocationState.java index e3501f5871..2cd2f051b9 100644 --- a/android/src/app/organicmaps/location/LocationState.java +++ b/android/src/app/organicmaps/location/LocationState.java @@ -15,7 +15,7 @@ public final class LocationState void onMyPositionModeChanged(int newMode); } - interface PendingTimeoutListener + public interface PendingTimeoutListener { void onLocationPendingTimeout(); } @@ -45,10 +45,10 @@ public final class LocationState public static native void nativeSetListener(@NonNull ModeChangeListener listener); public static native void nativeRemoveListener(); - static native void nativeSetLocationPendingTimeoutListener(@NonNull PendingTimeoutListener listener); - static native void nativeRemoveLocationPendingTimeoutListener(); + public static native void nativeSetLocationPendingTimeoutListener(@NonNull PendingTimeoutListener listener); + public static native void nativeRemoveLocationPendingTimeoutListener(); - static native void nativeOnLocationError(int errorCode); + public static native void nativeOnLocationError(int errorCode); static native void nativeLocationUpdated(long time, double lat, double lon, float accuracy, double altitude, float speed, float bearing); diff --git a/android/src/app/organicmaps/util/LocationUtils.java b/android/src/app/organicmaps/util/LocationUtils.java index 941dd1ae55..cb2ffefd6e 100644 --- a/android/src/app/organicmaps/util/LocationUtils.java +++ b/android/src/app/organicmaps/util/LocationUtils.java @@ -1,10 +1,6 @@ package app.organicmaps.util; -import static android.Manifest.permission.ACCESS_COARSE_LOCATION; -import static android.Manifest.permission.ACCESS_FINE_LOCATION; - import android.content.Context; -import android.content.pm.PackageManager; import android.location.Location; import android.location.LocationManager; import android.os.Build; @@ -12,7 +8,6 @@ import android.provider.Settings; import android.view.Surface; import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; public class LocationUtils { @@ -116,14 +111,4 @@ public class LocationUtils return false; } } - - public static boolean isFineLocationGranted(@NonNull Context context) - { - return ContextCompat.checkSelfPermission(context, ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; - } - - public static boolean isLocationGranted(@NonNull Context context) - { - return isFineLocationGranted(context) || ContextCompat.checkSelfPermission(context, ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; - } }