diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index 95df86fc..680e6333 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -401,6 +401,12 @@ public void setZIndex(String markerId, float zIndex) { private Optional lastCameraPositionOnLoad; + /** Whether the opening camera decision was already made - see applyOpeningCameraIfReady. */ + private boolean openingCameraApplied; + + /** The opening view came from the stored position, so marker auto-navigation must not stomp it. */ + private boolean openingCameraRestored; + private int windowWidth = 0; private final ActivityResultLauncher pickZipActivityLauncher = registerForActivityResult( @@ -569,17 +575,10 @@ protected void onCreate(Bundle savedInstanceState) { var async = this.getLastCameraPosition() .subscribe(pos -> { Log.d(TAG, "Got previous camera position to reset us to: " + pos); - - pos.ifPresent(userMapCameraPosition -> { - if (this.mapProvider != null) { - this.mapProvider.moveCamera( - userMapCameraPosition.getLat(), - userMapCameraPosition.getLon(), - userMapCameraPosition.getZoom() - ); - } - }); - + // The camera position arrives off a different async path than the + // provider - whichever lands second has to make the move, or a saved + // view is silently dropped whenever the datastore answers first. + this.applyOpeningCameraIfReady(); }, error -> Log.e(TAG, "Failed to get last camera position!", error)); this.handleAuthAndShowDevices(); @@ -633,6 +632,7 @@ public void onMapReady(IMapProvider provider) { mapProvider.setMapStyle(this.getPreferredMapStyle()); this.enableMyLocation(false); + this.applyOpeningCameraIfReady(); // 地图准备好后,刷新一次当前显示的信标位置 this.showLastDeviceLocations(); @@ -1115,6 +1115,86 @@ public void onClickMyLocation(View view) { this.animateCameraToMyLocation(); } + /** + * Where the map opens, decided once both async sides have arrived: the provider + * (onMapReady) and the persisted camera position (getLastCameraPosition). The + * decision is made exactly once - the second call, from whichever side landed + * last, is the one that moves the camera. + */ + private void applyOpeningCameraIfReady() { + if (this.openingCameraApplied || this.mapProvider == null + || this.lastCameraPositionOnLoad == null) { + return; + } + this.openingCameraApplied = true; + + final Optional saved = this.lastCameraPositionOnLoad; + if (saved.isPresent()) { + final UserMapCameraPosition pos = saved.get(); + this.mapProvider.moveCamera(pos.getLat(), pos.getLon(), pos.getZoom()); + this.openingCameraRestored = true; + return; + } + + // Nothing was ever stored. The provider's own default zooms out until the + // planet tiles across the screen, so pick a deliberate opening view instead: + // the phone's position when permission allows, else a country-scale view for + // the app language, else a zoom that shows the world exactly once. Markers + // that exist already own the opening view through their own navigation. + if (!this.currentMarkers.isEmpty()) { + return; + } + if (ActivityCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED + || ActivityCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) { + this.fusedLocationClient.getLastLocation() + .addOnSuccessListener(this, location -> { + if (this.mapProvider == null) { + return; + } + // Markers may have arrived while the fix was in flight - they own + // the opening view, and a belated phone position must not stomp it. + if (!this.currentMarkers.isEmpty()) { + return; + } + if (location != null) { + this.mapProvider.animateCamera(location.getLatitude(), + location.getLongitude(), this.initialZoom(), null); + } else { + this.applyLocaleOpeningView(); + } + }) + .addOnFailureListener(this, e -> { + // A failed fix is the same empty answer as a null one: fall back to + // the locale view unless markers claimed the opening meanwhile. + if (this.mapProvider != null && this.currentMarkers.isEmpty()) { + this.applyLocaleOpeningView(); + } + }); + return; + } + this.applyLocaleOpeningView(); + } + + /** + * The opening view when neither a stored camera position nor a phone fix exists: + * a country-scale frame for the app language, or a world view tight enough that + * the planet appears once rather than tiled. + */ + private void applyLocaleOpeningView() { + if (this.mapProvider == null) { + return; + } + // The activity's configured locale, not Locale.getDefault(): a per-app language set + // through locales_config never rewrites the default, and the tag is "de" with no + // country - so it is the language that decides, not the region. + final Locale appLocale = this.getResources().getConfiguration().getLocales().get(0); + if ("de".equals(appLocale.getLanguage())) { + this.mapProvider.moveCamera(50.96, 10.42, 5.5f); + } else { + this.mapProvider.moveCamera(30.0, 10.0, 2.5f); + } + } + private void animateCameraToMyLocation() { if (ActivityCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) { @@ -2511,8 +2591,9 @@ private synchronized void showBeaconOnMap(final BeaconInformation beacon, final String markerId = this.mapProvider.addMarker(marker); this.currentMarkers.put(beaconId, markerId); // 存储markerId而不是Marker对象 - if (this.currentMarkers.size() == 1) { - // for the first marker, navigate to it smoothly on the map! + if (this.currentMarkers.size() == 1 && !this.openingCameraRestored) { + // The first marker steers the camera - unless the user's own stored view + // was just put back, which outranks any pin. this.goToBeaconOnMap(beaconId, this.initialZoom()); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/OsmMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/OsmMapProvider.java index b8161ef7..0fdac5cb 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/OsmMapProvider.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/OsmMapProvider.java @@ -289,11 +289,30 @@ public void clearPolylines() { this.mapView.invalidate(); } + /** + * Runs {@code action} once the map view has a size - immediately when it already has one. + * + *

osmdroid scrolls in pixels, and a camera call on a zero-sized MapView centers half a + * screen wrong: the restored view then opens consistently off to one side. onMapReady + * fires straight out of {@link #initialize}, before the first layout, so the camera + * restore is the call that reliably hits this. + */ + private void whenLaidOut(final Runnable action) { + if (this.mapView.getWidth() == 0) { + this.mapView.addOnFirstLayoutListener( + (v, left, top, right, bottom) -> action.run()); + } else { + action.run(); + } + } + @Override public void moveCamera(double latitude, double longitude, float zoom) { if (this.controller == null) return; - this.controller.setCenter(new GeoPoint(latitude, longitude)); - this.controller.setZoom((double) zoom); + this.whenLaidOut(() -> { + this.controller.setCenter(new GeoPoint(latitude, longitude)); + this.controller.setZoom((double) zoom); + }); } @Override @@ -308,7 +327,8 @@ public void animateCamera(double latitude, double longitude, float zoom, Runnabl // API (unlike Google's AMap.CancelableCallback), so the caller's callback runs once the // animation has had time to finish rather than on a true completion event. 300ms is // osmdroid's own default MapView animation speed. - this.controller.animateTo(new GeoPoint(latitude, longitude), (double) zoom, 300L); + this.whenLaidOut(() -> this.controller.animateTo( + new GeoPoint(latitude, longitude), (double) zoom, 300L)); if (callback != null) { this.mapView.postDelayed(callback, 300); }