From 683b35bdd4cf8b6ef4ec45a1a7c97c183a731fcc Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Thu, 15 Oct 2026 17:55:26 +0200 Subject: [PATCH 1/3] Restore the stored camera view, and open somewhere sensible without one - The datastore could answer before onMapReady; the saved position was then silently dropped and the map opened at the provider's world-tiled default. applyOpeningCameraIfReady waits for both sides and decides exactly once. - With nothing stored: markers that exist keep owning the opening view, otherwise the phone's fix when permission allows, else a country-scale frame for the app language (DACH), else a zoom that shows the world once. - A stored view now wins over the first-marker auto-navigation, which used to stomp the restored position at every open. Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/opentagviewer/MapsActivity.java | 96 ++++++++++++++++--- 1 file changed, 83 insertions(+), 13 deletions(-) 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..56e198e4 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,75 @@ 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; + } + if (location != null) { + this.mapProvider.animateCamera(location.getLatitude(), + location.getLongitude(), this.initialZoom(), null); + } else { + 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; + } + switch (Locale.getDefault().getCountry()) { + case "DE": + case "AT": + case "CH": + case "LI": + this.mapProvider.moveCamera(50.96, 10.42, 5.5f); + return; + default: + 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 +2580,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()); } } From 013037ea4da193335e9bba1d1020af572eccad89 Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Thu, 15 Oct 2026 22:24:37 +0200 Subject: [PATCH 2/3] Wait for the osmdroid view to have a size before moving the camera - onMapReady fires out of initialize() before the MapView's first layout, so the stored-camera restore ran setCenter on a zero-sized view: osmdroid scrolls in pixels and the opening view landed half a screen off to the side - Defer moveCamera/animateCamera via addOnFirstLayoutListener until the view has been measured; calls after layout run immediately Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../opentagviewer/ui/maps/OsmMapProvider.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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); } From 1ca9c33ce51fa549edb91c11c818161109b5f648 Mon Sep 17 00:00:00 2001 From: Ruben Barkow-Kuder Date: Thu, 15 Oct 2026 22:29:05 +0200 Subject: [PATCH 3/3] Close the leftover races and the locale mismatch in the opening camera - The phone-fix callback rechecks currentMarkers: the request fires before showLastDeviceLocations runs, and a fix landing afterwards would stomp the marker view that just claimed the opening - A failed getLastLocation now falls back to the locale view unless markers arrived meanwhile - before, the map could stay at the provider's world default with openingCameraApplied already consumed - The locale fallback matches the configured locale's language ("de") instead of Locale.getDefault()'s region: the app's German locale has no country component, so the DACH frame was never reached Co-Authored-By: Devin/SWE-2 <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/opentagviewer/MapsActivity.java | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) 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 56e198e4..680e6333 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -1151,12 +1151,24 @@ private void applyOpeningCameraIfReady() { 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; } @@ -1172,15 +1184,14 @@ private void applyLocaleOpeningView() { if (this.mapProvider == null) { return; } - switch (Locale.getDefault().getCountry()) { - case "DE": - case "AT": - case "CH": - case "LI": - this.mapProvider.moveCamera(50.96, 10.42, 5.5f); - return; - default: - this.mapProvider.moveCamera(30.0, 10.0, 2.5f); + // 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); } }